diff --git a/.forgejo/workflows/master.yml b/.forgejo/workflows/master.yml index 522496ddd..e9a613d3b 100644 --- a/.forgejo/workflows/master.yml +++ b/.forgejo/workflows/master.yml @@ -3,8 +3,6 @@ name: CI on: push: branches: [master, develop] - pull_request: - branches: [master, develop] vars: docker_prefix: "http://harbor.cleverthis.com/docker/" diff --git a/benchmarks/bench_uko_persistence.py b/benchmarks/bench_uko_persistence.py new file mode 100644 index 000000000..b974fdc5c --- /dev/null +++ b/benchmarks/bench_uko_persistence.py @@ -0,0 +1,114 @@ +"""ASV benchmarks for UKO Graph Persistence service. + +Benchmarks the save() and restore() methods under varying triple counts. +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path +from unittest.mock import MagicMock + +from cleveragents.application.services.uko_persistence import ( + InMemoryPersistenceBackend, + JSONFilePersistenceBackend, + UKOGraphPersistence, +) +from cleveragents.domain.models.acms.index_backends import GraphIndexBackend + + +class UKOPersistenceBenchmarks: + """Benchmarks for UKO Graph Persistence.""" + + params = [10, 100, 1000] + param_names = ["triple_count"] + + def setup(self, triple_count: int) -> None: + """Set up benchmark fixtures.""" + self.triple_count = triple_count + self.temp_dir = tempfile.mkdtemp() + + # Create mock graph backend + self.graph_backend = MagicMock(spec=GraphIndexBackend) + + # Create sample triples + self.triples = [ + { + "subject": f"uko://code/module/mod_{i}", + "predicate": "uko:type", + "object": "uko-py:Module", + } + for i in range(triple_count) + ] + + # Set up graph backend to return triples + self.graph_backend.query.return_value = [ + { + "s": t["subject"], + "p": t["predicate"], + "o": t["object"], + } + for t in self.triples + ] + + def teardown(self, triple_count: int) -> None: + """Clean up after benchmark.""" + import shutil + + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def time_save_with_in_memory_backend(self, triple_count: int) -> None: + """Benchmark save() with InMemoryPersistenceBackend.""" + persistence_backend = InMemoryPersistenceBackend() + service = UKOGraphPersistence( + graph_backend=self.graph_backend, + project="local/benchmark", + persistence_backend=persistence_backend, + ) + service.save() + + def time_save_with_json_file_backend(self, triple_count: int) -> None: + """Benchmark save() with JSONFilePersistenceBackend.""" + persistence_backend = JSONFilePersistenceBackend(self.temp_dir) + service = UKOGraphPersistence( + graph_backend=self.graph_backend, + project="local/benchmark", + persistence_backend=persistence_backend, + ) + service.save() + + def time_restore_with_in_memory_backend(self, triple_count: int) -> None: + """Benchmark restore() with InMemoryPersistenceBackend.""" + persistence_backend = InMemoryPersistenceBackend() + persistence_backend.save("local/benchmark", self.triples) + + service = UKOGraphPersistence( + graph_backend=self.graph_backend, + project="local/benchmark", + persistence_backend=persistence_backend, + ) + service.restore() + + def time_restore_with_json_file_backend(self, triple_count: int) -> None: + """Benchmark restore() with JSONFilePersistenceBackend.""" + persistence_backend = JSONFilePersistenceBackend(self.temp_dir) + persistence_backend.save("local/benchmark", self.triples) + + service = UKOGraphPersistence( + graph_backend=self.graph_backend, + project="local/benchmark", + persistence_backend=persistence_backend, + ) + service.restore() + + def time_json_file_backend_load(self, triple_count: int) -> None: + """Benchmark JSONFilePersistenceBackend.load().""" + persistence_backend = JSONFilePersistenceBackend(self.temp_dir) + persistence_backend.save("local/benchmark", self.triples) + persistence_backend.load("local/benchmark") + + def time_in_memory_backend_load(self, triple_count: int) -> None: + """Benchmark InMemoryPersistenceBackend.load().""" + persistence_backend = InMemoryPersistenceBackend() + persistence_backend.save("local/benchmark", self.triples) + persistence_backend.load("local/benchmark") diff --git a/features/steps/uko_persistence_steps.py b/features/steps/uko_persistence_steps.py new file mode 100644 index 000000000..e84e0a759 --- /dev/null +++ b/features/steps/uko_persistence_steps.py @@ -0,0 +1,766 @@ +"""Step implementations for UKO Graph Persistence coverage tests.""" + +from __future__ import annotations + +import contextlib +import json +import tempfile +from collections.abc import Generator +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import structlog +from behave import given, then, when + +from cleveragents.application.services import uko_persistence as _uko_mod +from cleveragents.application.services.uko_persistence import ( + InMemoryPersistenceBackend, + JSONFilePersistenceBackend, + UKOGraphPersistence, +) +from cleveragents.domain.models.acms.index_backends import GraphIndexBackend + + +# ================================================================= +# Fixtures and helpers +# ================================================================= + + +def create_mock_graph_backend() -> MagicMock: + """Create a mock GraphIndexBackend.""" + backend = MagicMock(spec=GraphIndexBackend) + return backend + + +def create_sample_triples() -> list[dict[str, str]]: + """Create sample valid triples.""" + return [ + { + "subject": "uko://code/module/foo", + "predicate": "uko:type", + "object": "uko-py:Module", + }, + { + "subject": "uko://code/class/Foo", + "predicate": "uko:type", + "object": "uko-py:Class", + }, + ] + + +@contextlib.contextmanager +def _capture_uko_logs() -> Generator[list[dict[str, Any]]]: + """Capture structlog entries emitted by the uko_persistence module. + + Uses ``structlog.testing.LogCapture`` and temporarily replaces the + module-level logger to work around ``cache_logger_on_first_use=True``. + This approach avoids modifying global structlog configuration. + """ + cap = structlog.testing.LogCapture() + old_logger = _uko_mod.logger + # Build a fresh bound logger that routes through LogCapture without + # touching the global structlog configuration. + bound = structlog.wrap_logger( + structlog.PrintLogger(), + processors=[cap], + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=False, + ) + _uko_mod.logger = bound # type: ignore[assignment] + try: + yield cap.entries + finally: + _uko_mod.logger = old_logger + + +# ================================================================= +# JSONFilePersistenceBackend.load() — corrupted JSON file handling +# ================================================================= + + +@given("uko a JSON file persistence backend in a non-existent temp directory") +def step_json_file_persistence_backend_nonexistent(context: Any) -> None: + """Create a JSON file persistence backend in a non-existent temp directory.""" + temp_dir = Path(tempfile.gettempdir()) / "uko_test_nonexistent" / "subdir" + context.uko_temp_dir = str(temp_dir) + context.uko_json_backend = JSONFilePersistenceBackend(temp_dir) + # Set uko_test_triples so parameterized save steps work without a prior + # "uko an in-memory persistence backend" step. + context.uko_test_triples = create_sample_triples() + + +@given("uko the JSON file contains corrupted JSON data") +def step_json_file_corrupted(context: Any) -> None: + """Create a JSON file with corrupted data.""" + backend: JSONFilePersistenceBackend = context.uko_json_backend + path = backend._path_for("local/test") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{invalid json content", encoding="utf-8") + + +@given('uko the JSON file contains valid JSON but missing "triples" key') +def step_json_file_missing_triples_key(context: Any) -> None: + """Create a JSON file with valid JSON but missing triples key.""" + backend: JSONFilePersistenceBackend = context.uko_json_backend + path = backend._path_for("local/test") + path.parent.mkdir(parents=True, exist_ok=True) + data = {"version": "1", "project": "local/test"} + path.write_text(json.dumps(data), encoding="utf-8") + + +@given("uko the JSON file exists but is unreadable (permission denied)") +def step_json_file_unreadable(context: Any) -> None: + """Create a JSON file and make it unreadable.""" + backend: JSONFilePersistenceBackend = context.uko_json_backend + path = backend._path_for("local/test") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"version": "1", "project": "local/test", "triples": []}), + encoding="utf-8", + ) + path.chmod(0o000) + context.uko_unreadable_file = path + + +@when('uko I load from the JSON file backend for project "local/test"') +def step_load_from_json_backend(context: Any) -> None: + """Load from JSON file backend, capturing any log output.""" + backend: JSONFilePersistenceBackend = context.uko_json_backend + with _capture_uko_logs() as entries: + context.uko_load_result = backend.load("local/test") + context.uko_captured_logs = entries + + +@then("uko the result should be an empty list") +def step_result_is_empty_list(context: Any) -> None: + """Verify result is an empty list.""" + assert context.uko_load_result == [], ( + f"Expected empty list, got {context.uko_load_result}" + ) + + +@then("uko a warning should be logged for load failure") +def step_warning_logged_for_load_failure(context: Any) -> None: + """Verify a warning was logged for load failure.""" + logs: list[dict[str, Any]] = context.uko_captured_logs + warning_events = [ + e + for e in logs + if e.get("log_level") == "warning" and "load_failed" in e.get("event", "") + ] + assert warning_events, ( + f"Expected a warning log with 'load_failed' event, got: {logs}" + ) + + +# ================================================================= +# UKOGraphPersistence.save() — query failure path +# ================================================================= + + +@given("uko a graph backend that raises an exception on query") +def step_graph_backend_raises_on_query(context: Any) -> None: + """Create a mock graph backend that raises on query.""" + backend = create_mock_graph_backend() + backend.query.side_effect = RuntimeError("Query failed") + context.uko_failing_graph_backend = backend + + +@given("uko a UKOGraphPersistence service with the failing backend") +def step_persistence_service_with_failing_backend(context: Any) -> None: + """Create a UKOGraphPersistence service with a failing backend.""" + graph_backend = context.uko_failing_graph_backend + persistence_backend = InMemoryPersistenceBackend() + context.uko_persistence_service = UKOGraphPersistence( + graph_backend=graph_backend, + project="local/test", + persistence_backend=persistence_backend, + ) + + +@when("uko I call save on the persistence service") +def step_call_save_on_persistence_service(context: Any) -> None: + """Call save on the persistence service, capturing log output.""" + service: UKOGraphPersistence = context.uko_persistence_service + with _capture_uko_logs() as entries: + context.uko_save_result = service.save() + context.uko_captured_logs = entries + + +@then("uko a warning should be logged for query failure") +def step_warning_logged_for_query_failure(context: Any) -> None: + """Verify a warning was logged for query failure.""" + logs: list[dict[str, Any]] = context.uko_captured_logs + warning_events = [ + e + for e in logs + if e.get("log_level") == "warning" and "query_failed" in e.get("event", "") + ] + assert warning_events, ( + f"Expected a warning log with 'query_failed' event, got: {logs}" + ) + + +# ================================================================= +# UKOGraphPersistence.restore() — incomplete triple handling +# ================================================================= + + +@given('uko a persistence backend with triples missing the "subject" key') +def step_persistence_backend_missing_subject(context: Any) -> None: + """Create a persistence backend with triples missing subject.""" + backend = InMemoryPersistenceBackend() + backend.save( + "local/test", + [ + { + "predicate": "uko:type", + "object": "uko-py:Module", + } + ], + ) + context.uko_persistence_backend = backend + + +@given('uko a persistence backend with triples missing the "predicate" key') +def step_persistence_backend_missing_predicate(context: Any) -> None: + """Create a persistence backend with triples missing predicate.""" + backend = InMemoryPersistenceBackend() + backend.save( + "local/test", + [ + { + "subject": "uko://code/module/foo", + "object": "uko-py:Module", + } + ], + ) + context.uko_persistence_backend = backend + + +@given('uko a persistence backend with triples missing the "object" key') +def step_persistence_backend_missing_object(context: Any) -> None: + """Create a persistence backend with triples missing object.""" + backend = InMemoryPersistenceBackend() + backend.save( + "local/test", + [ + { + "subject": "uko://code/module/foo", + "predicate": "uko:type", + } + ], + ) + context.uko_persistence_backend = backend + + +@given("uko a persistence backend with triples having empty subject value") +def step_persistence_backend_empty_subject(context: Any) -> None: + """Create a persistence backend with triples having empty subject.""" + backend = InMemoryPersistenceBackend() + backend.save( + "local/test", + [ + { + "subject": "", + "predicate": "uko:type", + "object": "uko-py:Module", + } + ], + ) + context.uko_persistence_backend = backend + + +@given("uko a persistence backend with triples having empty predicate value") +def step_persistence_backend_empty_predicate(context: Any) -> None: + """Create a persistence backend with triples having empty predicate.""" + backend = InMemoryPersistenceBackend() + backend.save( + "local/test", + [ + { + "subject": "uko://code/module/foo", + "predicate": "", + "object": "uko-py:Module", + } + ], + ) + context.uko_persistence_backend = backend + + +@given("uko a persistence backend with triples having empty object value") +def step_persistence_backend_empty_object(context: Any) -> None: + """Create a persistence backend with triples having empty object.""" + backend = InMemoryPersistenceBackend() + backend.save( + "local/test", + [ + { + "subject": "uko://code/module/foo", + "predicate": "uko:type", + "object": "", + } + ], + ) + context.uko_persistence_backend = backend + + +@given("uko a UKOGraphPersistence service with the persistence backend") +def step_persistence_service_with_backend(context: Any) -> None: + """Create a UKOGraphPersistence service with the persistence backend.""" + graph_backend = create_mock_graph_backend() + persistence_backend = context.uko_persistence_backend + context.uko_persistence_service = UKOGraphPersistence( + graph_backend=graph_backend, + project="local/test", + persistence_backend=persistence_backend, + ) + + +@when("uko I call restore on the persistence service") +def step_call_restore_on_persistence_service(context: Any) -> None: + """Call restore on the persistence service, capturing log output.""" + service: UKOGraphPersistence = context.uko_persistence_service + with _capture_uko_logs() as entries: + context.uko_restore_result = service.restore() + context.uko_captured_logs = entries + + +@then("uko no triples should be added to the graph backend") +def step_no_triples_added(context: Any) -> None: + """Verify no triples were added to the graph backend.""" + service: UKOGraphPersistence = context.uko_persistence_service + service._graph_backend.add_triple.assert_not_called() + + +# ================================================================= +# UKOGraphPersistence.restore() — add_triple failure path +# ================================================================= + + +@given("uko a persistence backend with valid triples") +def step_persistence_backend_with_valid_triples(context: Any) -> None: + """Create a persistence backend with valid triples.""" + backend = InMemoryPersistenceBackend() + backend.save("local/test", create_sample_triples()) + context.uko_persistence_backend = backend + + +@given("uko a graph backend that raises an exception on add_triple") +def step_graph_backend_raises_on_add_triple(context: Any) -> None: + """Create a mock graph backend that raises on add_triple.""" + backend = create_mock_graph_backend() + backend.add_triple.side_effect = RuntimeError("Add triple failed") + context.uko_failing_graph_backend = backend + + +@then("uko a warning should be logged for triple failure") +def step_warning_logged_for_triple_failure(context: Any) -> None: + """Verify a warning was logged for triple failure.""" + logs: list[dict[str, Any]] = context.uko_captured_logs + warning_events = [ + e + for e in logs + if e.get("log_level") == "warning" and "triple_failed" in e.get("event", "") + ] + assert warning_events, ( + f"Expected a warning log with 'triple_failed' event, got: {logs}" + ) + + +@given("uko a persistence backend with mixed valid and invalid triples") +def step_persistence_backend_with_mixed_triples(context: Any) -> None: + """Create a persistence backend with mixed valid and invalid triples.""" + backend = InMemoryPersistenceBackend() + triples = [ + *create_sample_triples(), + { + "subject": "", + "predicate": "uko:type", + "object": "uko-py:Module", + }, + ] + backend.save("local/test", triples) + context.uko_persistence_backend = backend + + +@given("uko a graph backend that raises an exception only for specific triples") +def step_graph_backend_selective_failure(context: Any) -> None: + """Create a mock graph backend that fails only for specific triples.""" + backend = create_mock_graph_backend() + + def add_triple_side_effect( + project: str, subject: str, predicate: str, obj: str + ) -> None: + if subject == "uko://code/module/foo": + raise RuntimeError("Add triple failed for foo") + + backend.add_triple.side_effect = add_triple_side_effect + context.uko_failing_graph_backend = backend + + +@given("uko a UKOGraphPersistence service with the mixed backend") +def step_persistence_service_with_mixed_backend(context: Any) -> None: + """Create a UKOGraphPersistence service with mixed backend.""" + graph_backend = context.uko_failing_graph_backend + persistence_backend = context.uko_persistence_backend + context.uko_persistence_service = UKOGraphPersistence( + graph_backend=graph_backend, + project="local/test", + persistence_backend=persistence_backend, + ) + + +@then("uko the restore result should be greater than 0") +def step_restore_result_greater_than_zero(context: Any) -> None: + """Verify restore result is greater than 0.""" + assert context.uko_restore_result > 0, ( + f"Expected > 0, got {context.uko_restore_result}" + ) + + +@then("uko warnings should be logged for failed triples") +def step_warnings_logged_for_failed_triples(context: Any) -> None: + """Verify warnings were logged for failed triples.""" + logs: list[dict[str, Any]] = context.uko_captured_logs + warning_events = [ + e + for e in logs + if e.get("log_level") == "warning" and "triple_failed" in e.get("event", "") + ] + assert warning_events, ( + f"Expected warning logs with 'triple_failed' event, got: {logs}" + ) + + +# ================================================================= +# InMemoryPersistenceBackend.clear() method +# ================================================================= + + +@when('uko I clear the persistence backend for project "local/test"') +def step_clear_persistence_backend(context: Any) -> None: + """Clear the persistence backend for a project.""" + backend: InMemoryPersistenceBackend = context.uko_persistence_backend + backend.clear("local/test") + + +@then( + 'uko loading from the persistence backend for project "local/test" should return empty list' +) +def step_load_after_clear_returns_empty(context: Any) -> None: + """Verify loading after clear returns empty list.""" + backend: InMemoryPersistenceBackend = context.uko_persistence_backend + result = backend.load("local/test") + assert result == [], f"Expected empty list, got {result}" + + +@when('uko I clear the persistence backend for project "local/test1"') +def step_clear_persistence_backend_test1(context: Any) -> None: + """Clear the persistence backend for project test1.""" + backend: InMemoryPersistenceBackend = context.uko_persistence_backend + backend.clear("local/test1") + + +@then( + 'uko loading from the persistence backend for project "local/test1" should return empty list' +) +def step_load_test1_returns_empty(context: Any) -> None: + """Verify loading test1 after clear returns empty list.""" + backend: InMemoryPersistenceBackend = context.uko_persistence_backend + result = backend.load("local/test1") + assert result == [], f"Expected empty list, got {result}" + + +@then( + 'uko loading from the persistence backend for project "local/test2" should return the saved triples' +) +def step_load_test2_returns_saved(context: Any) -> None: + """Verify loading test2 returns the saved triples.""" + backend: InMemoryPersistenceBackend = context.uko_persistence_backend + result = backend.load("local/test2") + assert len(result) == 2, f"Expected 2 triples, got {len(result)}" + + +@when('uko I clear the persistence backend for project "local/nonexistent"') +def step_clear_nonexistent_project(context: Any) -> None: + """Clear a non-existent project.""" + backend: InMemoryPersistenceBackend = context.uko_persistence_backend + try: + backend.clear("local/nonexistent") + context.uko_clear_error = None + except Exception as e: + context.uko_clear_error = e + + +@then("uko no error should be raised") +def step_no_error_raised(context: Any) -> None: + """Verify no error was raised.""" + assert context.uko_clear_error is None, ( + f"Expected no error, got {context.uko_clear_error}" + ) + + +# ================================================================= +# UKOGraphPersistence.project property +# ================================================================= + + +@given('uko a UKOGraphPersistence service with project "local/my-app"') +def step_persistence_service_with_project(context: Any) -> None: + """Create a UKOGraphPersistence service with a specific project.""" + graph_backend = context.uko_graph_backend + persistence_backend = InMemoryPersistenceBackend() + context.uko_persistence_service = UKOGraphPersistence( + graph_backend=graph_backend, + project="local/my-app", + persistence_backend=persistence_backend, + ) + + +@when("uko I access the project property") +def step_access_project_property(context: Any) -> None: + """Access the project property.""" + service: UKOGraphPersistence = context.uko_persistence_service + context.uko_project_value = service.project + + +@then('uko the project property should return "local/my-app"') +def step_project_property_returns_value(context: Any) -> None: + """Verify the project property returns the correct value.""" + assert context.uko_project_value == "local/my-app", ( + f"Expected 'local/my-app', got {context.uko_project_value}" + ) + + +@then("uko attempting to set the project property should raise AttributeError") +def step_project_property_is_readonly(context: Any) -> None: + """Verify the project property is read-only.""" + service: UKOGraphPersistence = context.uko_persistence_service + try: + service.project = "local/other" # type: ignore[misc] + raise AssertionError("Expected AttributeError") + except AttributeError: + pass + + +# ================================================================= +# Integration: Full save/restore lifecycle with JSONFilePersistenceBackend +# ================================================================= + + +@given("uko a mock graph backend with two sample triples") +def step_graph_backend_with_triples_020(context: Any) -> None: + """Create a mock graph backend with two sample triples for lifecycle tests.""" + backend = create_mock_graph_backend() + backend.query.return_value = [ + { + "s": "uko://code/module/foo", + "p": "uko:type", + "o": "uko-py:Module", + }, + { + "s": "uko://code/class/Foo", + "p": "uko:type", + "o": "uko-py:Class", + }, + ] + context.uko_graph_backend = backend + + +@given("uko a UKOGraphPersistence service with the JSON file backend") +def step_persistence_service_with_json_backend(context: Any) -> None: + """Create a UKOGraphPersistence service with the JSON file backend.""" + graph_backend = context.uko_graph_backend + persistence_backend = context.uko_json_backend + context.uko_persistence_service = UKOGraphPersistence( + graph_backend=graph_backend, + project="local/test", + persistence_backend=persistence_backend, + ) + + +@when("uko I save the graph state via the persistence service") +def step_save_graph_state(context: Any) -> None: + """Save the graph state via the pre-configured persistence service.""" + service: UKOGraphPersistence = context.uko_persistence_service + context.uko_save_result = service.save() + + +@when( + "uko I restore the graph state into a fresh backend with the same JSON file backend" +) +def step_restore_graph_state_with_json_backend(context: Any) -> None: + """Restore the graph state into a fresh backend.""" + fresh_backend = create_mock_graph_backend() + persistence_backend = context.uko_json_backend + service = UKOGraphPersistence( + graph_backend=fresh_backend, + project="local/test", + persistence_backend=persistence_backend, + ) + context.uko_restore_result = service.restore() + context.uko_fresh_backend = fresh_backend + + +@then("uko the restored backend should have the same triples") +def step_restored_backend_has_same_triples(context: Any) -> None: + """Verify the restored backend has the same triples.""" + fresh_backend = context.uko_fresh_backend + assert fresh_backend.add_triple.call_count == 2, ( + f"Expected 2 add_triple calls, got {fresh_backend.add_triple.call_count}" + ) + + +@then("uko the base directory should be created") +def step_base_directory_created(context: Any) -> None: + """Verify the base directory was created.""" + temp_dir = Path(context.uko_temp_dir) + assert temp_dir.exists(), f"Expected directory to exist: {temp_dir}" + + +@then("uko the JSON file should exist") +def step_json_file_exists(context: Any) -> None: + """Verify the JSON file exists.""" + backend: JSONFilePersistenceBackend = context.uko_json_backend + path = backend._path_for("local/test") + assert path.exists(), f"Expected file to exist: {path}" + + +# ================================================================= +# Edge cases and validation +# ================================================================= + + +@then( + "uko creating UKOGraphPersistence with whitespace-only project should raise ValueError" +) +def step_persistence_service_whitespace_project(context: Any) -> None: + """Verify creating service with whitespace-only project raises ValueError.""" + graph_backend = context.uko_graph_backend + try: + UKOGraphPersistence( + graph_backend=graph_backend, + project=" ", + persistence_backend=InMemoryPersistenceBackend(), + ) + raise AssertionError("Expected ValueError") + except ValueError: + pass + + +@then( + "uko saving to JSON file backend with whitespace-only project should raise ValueError" +) +def step_save_json_whitespace_project(context: Any) -> None: + """Verify saving with whitespace-only project raises ValueError.""" + backend: JSONFilePersistenceBackend = context.uko_json_backend + try: + backend.save(" ", create_sample_triples()) + raise AssertionError("Expected ValueError") + except ValueError: + pass + + +@then( + "uko loading from JSON file backend with whitespace-only project should raise ValueError" +) +def step_load_json_whitespace_project(context: Any) -> None: + """Verify loading with whitespace-only project raises ValueError.""" + backend: JSONFilePersistenceBackend = context.uko_json_backend + try: + backend.load(" ") + raise AssertionError("Expected ValueError") + except ValueError: + pass + + +@then( + "uko saving to persistence backend with whitespace-only project should raise ValueError" +) +def step_save_memory_whitespace_project(context: Any) -> None: + """Verify saving with whitespace-only project raises ValueError.""" + backend: InMemoryPersistenceBackend = context.uko_persistence_backend + try: + backend.save(" ", create_sample_triples()) + raise AssertionError("Expected ValueError") + except ValueError: + pass + + +@then( + "uko loading from persistence backend with whitespace-only project should raise ValueError" +) +def step_load_memory_whitespace_project(context: Any) -> None: + """Verify loading with whitespace-only project raises ValueError.""" + backend: InMemoryPersistenceBackend = context.uko_persistence_backend + try: + backend.load(" ") + raise AssertionError("Expected ValueError") + except ValueError: + pass + + +@given("uko a graph backend that returns empty bindings on query") +def step_graph_backend_empty_bindings(context: Any) -> None: + """Create a graph backend that returns empty bindings.""" + backend = create_mock_graph_backend() + backend.query.return_value = [] + context.uko_graph_backend = backend + + +@given("uko a UKOGraphPersistence service with the backend") +def step_persistence_service_with_graph_backend(context: Any) -> None: + """Create a UKOGraphPersistence service with the graph backend.""" + graph_backend = context.uko_graph_backend + persistence_backend = InMemoryPersistenceBackend() + context.uko_persistence_service = UKOGraphPersistence( + graph_backend=graph_backend, + project="local/test", + persistence_backend=persistence_backend, + ) + + +@given("uko a graph backend that returns bindings with missing keys") +def step_graph_backend_missing_keys(context: Any) -> None: + """Create a graph backend that returns bindings with missing keys.""" + backend = create_mock_graph_backend() + backend.query.return_value = [ + { + "s": "uko://code/module/foo", + # missing "p" and "o" + }, + { + "p": "uko:type", + # missing "s" and "o" + }, + ] + context.uko_graph_backend = backend + + +@then("uko only complete triples should be persisted") +def step_only_complete_triples_persisted(context: Any) -> None: + """Verify only complete triples were persisted.""" + service: UKOGraphPersistence = context.uko_persistence_service + persistence_backend = service._persistence_backend + triples = persistence_backend.load("local/test") + assert len(triples) == 0, f"Expected 0 triples, got {len(triples)}" + + +@given("uko a persistence backend with no data") +def step_persistence_backend_no_data(context: Any) -> None: + """Create a persistence backend with no data.""" + context.uko_persistence_backend = InMemoryPersistenceBackend() + + +@then("uko an info log should be recorded for no data") +def step_info_log_no_data(context: Any) -> None: + """Verify an info log was recorded for no data.""" + logs: list[dict[str, Any]] = context.uko_captured_logs + info_events = [ + e + for e in logs + if e.get("log_level") == "info" and "no_data" in e.get("event", "") + ] + assert info_events, f"Expected an info log with 'no_data' event, got: {logs}" diff --git a/features/uko_persistence.feature b/features/uko_persistence.feature new file mode 100644 index 000000000..2906f5c5e --- /dev/null +++ b/features/uko_persistence.feature @@ -0,0 +1,206 @@ +Feature: UKO Graph Persistence — Full Coverage for Error Paths and Edge Cases + As a test infrastructure maintainer + I need comprehensive test coverage for all branches in uko_persistence.py + So that the module meets the 97% coverage threshold and all error paths are validated + + # ================================================================= + # JSONFilePersistenceBackend.load() — corrupted JSON file handling + # ================================================================= + + Scenario: JSONFilePersistenceBackend.load() returns empty list when JSON file is corrupted + Given uko a JSON file persistence backend in a temp directory + And uko the JSON file contains corrupted JSON data + When uko I load from the JSON file backend for project "local/test" + Then uko the result should be an empty list + And uko a warning should be logged for load failure + + Scenario: JSONFilePersistenceBackend.load() returns empty list when JSON file is missing required keys + Given uko a JSON file persistence backend in a temp directory + And uko the JSON file contains valid JSON but missing "triples" key + When uko I load from the JSON file backend for project "local/test" + Then uko the result should be an empty list + And uko a warning should be logged for load failure + + Scenario: JSONFilePersistenceBackend.load() returns empty list when file is unreadable + Given uko a JSON file persistence backend in a temp directory + And uko the JSON file exists but is unreadable (permission denied) + When uko I load from the JSON file backend for project "local/test" + Then uko the result should be an empty list + And uko a warning should be logged for load failure + + # ================================================================= + # UKOGraphPersistence.save() — query failure path + # ================================================================= + + Scenario: UKOGraphPersistence.save() returns 0 when graph_backend.query() raises an exception + Given uko a graph backend that raises an exception on query + And uko a UKOGraphPersistence service with the failing backend + When uko I call save on the persistence service + Then uko the save result should be 0 + And uko a warning should be logged for query failure + + # ================================================================= + # UKOGraphPersistence.restore() — incomplete triple handling + # ================================================================= + + Scenario: UKOGraphPersistence.restore() skips triples with missing subject key + Given uko a persistence backend with triples missing the "subject" key + And uko a UKOGraphPersistence service with the persistence backend + When uko I call restore on the persistence service + Then uko the restore result should be 0 + And uko no triples should be added to the graph backend + + Scenario: UKOGraphPersistence.restore() skips triples with missing predicate key + Given uko a persistence backend with triples missing the "predicate" key + And uko a UKOGraphPersistence service with the persistence backend + When uko I call restore on the persistence service + Then uko the restore result should be 0 + And uko no triples should be added to the graph backend + + Scenario: UKOGraphPersistence.restore() skips triples with missing object key + Given uko a persistence backend with triples missing the "object" key + And uko a UKOGraphPersistence service with the persistence backend + When uko I call restore on the persistence service + Then uko the restore result should be 0 + And uko no triples should be added to the graph backend + + Scenario: UKOGraphPersistence.restore() skips triples with empty subject value + Given uko a persistence backend with triples having empty subject value + And uko a UKOGraphPersistence service with the persistence backend + When uko I call restore on the persistence service + Then uko the restore result should be 0 + And uko no triples should be added to the graph backend + + Scenario: UKOGraphPersistence.restore() skips triples with empty predicate value + Given uko a persistence backend with triples having empty predicate value + And uko a UKOGraphPersistence service with the persistence backend + When uko I call restore on the persistence service + Then uko the restore result should be 0 + And uko no triples should be added to the graph backend + + Scenario: UKOGraphPersistence.restore() skips triples with empty object value + Given uko a persistence backend with triples having empty object value + And uko a UKOGraphPersistence service with the persistence backend + When uko I call restore on the persistence service + Then uko the restore result should be 0 + And uko no triples should be added to the graph backend + + # ================================================================= + # UKOGraphPersistence.restore() — add_triple failure path + # ================================================================= + + Scenario: UKOGraphPersistence.restore() continues and logs warning when add_triple() raises an exception + Given uko a persistence backend with valid triples + And uko a graph backend that raises an exception on add_triple + And uko a UKOGraphPersistence service with the failing backend + When uko I call restore on the persistence service + Then uko the restore result should be 0 + And uko a warning should be logged for triple failure + + Scenario: UKOGraphPersistence.restore() restores valid triples even when some fail + Given uko a persistence backend with mixed valid and invalid triples + And uko a graph backend that raises an exception only for specific triples + And uko a UKOGraphPersistence service with the mixed backend + When uko I call restore on the persistence service + Then uko the restore result should be greater than 0 + And uko warnings should be logged for failed triples + + # ================================================================= + # InMemoryPersistenceBackend.clear() method + # ================================================================= + + Scenario: InMemoryPersistenceBackend.clear() removes stored triples for a project + Given uko an in-memory persistence backend + And uko I save triples to the persistence backend for project "local/test" + When uko I clear the persistence backend for project "local/test" + Then uko loading from the persistence backend for project "local/test" should return empty list + + Scenario: InMemoryPersistenceBackend.clear() does not affect other projects + Given uko an in-memory persistence backend + And uko I save triples to the persistence backend for project "local/test1" + And uko I save triples to the persistence backend for project "local/test2" + When uko I clear the persistence backend for project "local/test1" + Then uko loading from the persistence backend for project "local/test1" should return empty list + And uko loading from the persistence backend for project "local/test2" should return the saved triples + + Scenario: InMemoryPersistenceBackend.clear() on non-existent project does not raise error + Given uko an in-memory persistence backend + When uko I clear the persistence backend for project "local/nonexistent" + Then uko no error should be raised + + # ================================================================= + # UKOGraphPersistence.project property + # ================================================================= + + Scenario: UKOGraphPersistence.project property returns the correct project name + Given uko a graph backend with no triples + And uko a UKOGraphPersistence service with project "local/my-app" + When uko I access the project property + Then uko the project property should return "local/my-app" + + Scenario: UKOGraphPersistence.project property is read-only + Given uko a graph backend with no triples + And uko a UKOGraphPersistence service with project "local/my-app" + Then uko attempting to set the project property should raise AttributeError + + # ================================================================= + # Integration: Full save/restore lifecycle with JSONFilePersistenceBackend + # ================================================================= + + Scenario: Full save/restore lifecycle with JSONFilePersistenceBackend + Given uko a mock graph backend with two sample triples + And uko a JSON file persistence backend in a temp directory + And uko a UKOGraphPersistence service with the JSON file backend + When uko I save the graph state via the persistence service + And uko I restore the graph state into a fresh backend with the same JSON file backend + Then uko the restored backend should have the same triples + + Scenario: JSONFilePersistenceBackend creates base directory if it does not exist + Given uko a JSON file persistence backend in a non-existent temp directory + When uko I save triples to the JSON file backend for project "local/test" + Then uko the base directory should be created + And uko the JSON file should exist + + # ================================================================= + # Edge cases and validation + # ================================================================= + + Scenario: UKOGraphPersistence rejects whitespace-only project name + Given uko a graph backend with no triples + Then uko creating UKOGraphPersistence with whitespace-only project should raise ValueError + + Scenario: JSONFilePersistenceBackend rejects whitespace-only project on save + Given uko a JSON file persistence backend in a temp directory + Then uko saving to JSON file backend with whitespace-only project should raise ValueError + + Scenario: JSONFilePersistenceBackend rejects whitespace-only project on load + Given uko a JSON file persistence backend in a temp directory + Then uko loading from JSON file backend with whitespace-only project should raise ValueError + + Scenario: InMemoryPersistenceBackend rejects whitespace-only project on save + Given uko an in-memory persistence backend + Then uko saving to persistence backend with whitespace-only project should raise ValueError + + Scenario: InMemoryPersistenceBackend rejects whitespace-only project on load + Given uko an in-memory persistence backend + Then uko loading from persistence backend with whitespace-only project should raise ValueError + + Scenario: UKOGraphPersistence.save() handles empty bindings from query + Given uko a graph backend that returns empty bindings on query + And uko a UKOGraphPersistence service with the backend + When uko I call save on the persistence service + Then uko the save result should be 0 + + Scenario: UKOGraphPersistence.save() filters out incomplete bindings + Given uko a graph backend that returns bindings with missing keys + And uko a UKOGraphPersistence service with the backend + When uko I call save on the persistence service + Then uko the save result should be 0 + And uko only complete triples should be persisted + + Scenario: UKOGraphPersistence.restore() handles empty persistence backend + Given uko a persistence backend with no data + And uko a UKOGraphPersistence service with the persistence backend + When uko I call restore on the persistence service + Then uko the restore result should be 0 + And uko an info log should be recorded for no data diff --git a/robot/helper_uko_persistence.py b/robot/helper_uko_persistence.py new file mode 100644 index 000000000..ac00bb465 --- /dev/null +++ b/robot/helper_uko_persistence.py @@ -0,0 +1,234 @@ +"""Robot Framework helper for UKO Graph Persistence integration tests. + +Provides a CLI-style interface for Robot to invoke persistence operations +and verify outcomes. Exit code 0 = success, 1 = failure. + +Usage: + python robot/helper_uko_persistence.py +""" + +from __future__ import annotations + +import shutil +import sys +import tempfile +from pathlib import Path +from unittest.mock import MagicMock + +# Ensure the src directory is on the import path. +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cleveragents.application.services.uko_persistence import ( # noqa: E402 + InMemoryPersistenceBackend, + JSONFilePersistenceBackend, + UKOGraphPersistence, +) +from cleveragents.domain.models.acms.index_backends import ( # noqa: E402 + GraphIndexBackend, +) + + +def _make_mock_graph_backend( + triples: list[dict[str, str]] | None = None, +) -> MagicMock: + """Create a mock GraphIndexBackend.""" + backend = MagicMock(spec=GraphIndexBackend) + if triples is not None: + backend.query.return_value = triples + return backend + + +def main() -> int: + """Entry point called by Robot Framework ``Run Process``.""" + if len(sys.argv) < 2: + print("Usage: helper_uko_persistence.py ") + return 1 + + command: str = sys.argv[1] + + if command == "save-restore-lifecycle": + try: + temp_dir = tempfile.mkdtemp() + persistence_backend = JSONFilePersistenceBackend(temp_dir) + + graph_backend = _make_mock_graph_backend( + triples=[ + { + "s": "uko://code/module/foo", + "p": "uko:type", + "o": "uko-py:Module", + }, + { + "s": "uko://code/class/Foo", + "p": "uko:type", + "o": "uko-py:Class", + }, + ] + ) + + service = UKOGraphPersistence( + graph_backend=graph_backend, + project="local/test", + persistence_backend=persistence_backend, + ) + saved_count = service.save() + assert saved_count == 2, f"Expected 2 triples saved, got {saved_count}" + + json_path = persistence_backend._path_for("local/test") + assert json_path.exists(), f"Expected JSON file at {json_path}" + + fresh_backend = _make_mock_graph_backend() + restore_service = UKOGraphPersistence( + graph_backend=fresh_backend, + project="local/test", + persistence_backend=persistence_backend, + ) + restored_count = restore_service.restore() + assert restored_count == 2, ( + f"Expected 2 triples restored, got {restored_count}" + ) + assert fresh_backend.add_triple.call_count == 2, ( + "Expected 2 add_triple calls, " + f"got {fresh_backend.add_triple.call_count}" + ) + + print("uko-persistence-save-restore-ok") + return 0 + except Exception as exc: + print(f"uko-persistence-save-restore-fail: {exc}") + return 1 + + if command == "directory-creation": + try: + temp_base = Path(tempfile.gettempdir()) / "uko_robot_test_nonexistent" + temp_dir = temp_base / "subdir" + if temp_dir.exists(): + shutil.rmtree(str(temp_dir)) + + persistence_backend = JSONFilePersistenceBackend(temp_dir) + persistence_backend.save( + "local/test", + [ + { + "subject": "uko://code/module/foo", + "predicate": "uko:type", + "object": "uko-py:Module", + }, + ], + ) + + assert temp_dir.exists(), f"Expected directory to be created: {temp_dir}" + json_path = persistence_backend._path_for("local/test") + assert json_path.exists(), f"Expected JSON file at {json_path}" + + shutil.rmtree(str(temp_base)) + + print("uko-persistence-directory-creation-ok") + return 0 + except Exception as exc: + print(f"uko-persistence-directory-creation-fail: {exc}") + return 1 + + if command == "corrupted-file-handling": + try: + temp_dir = tempfile.mkdtemp() + persistence_backend = JSONFilePersistenceBackend(temp_dir) + + json_path = persistence_backend._path_for("local/test") + json_path.parent.mkdir(parents=True, exist_ok=True) + json_path.write_text("{invalid json content", encoding="utf-8") + + result = persistence_backend.load("local/test") + assert result == [], f"Expected empty list for corrupted file, got {result}" + + print("uko-persistence-corrupted-file-ok") + return 0 + except Exception as exc: + print(f"uko-persistence-corrupted-file-fail: {exc}") + return 1 + + if command == "in-memory-clear": + try: + backend = InMemoryPersistenceBackend() + + triples = [ + { + "subject": "uko://code/module/foo", + "predicate": "uko:type", + "object": "uko-py:Module", + }, + ] + backend.save("local/project-a", triples) + backend.save("local/project-b", triples) + + backend.clear("local/project-a") + + result_a = backend.load("local/project-a") + assert result_a == [], ( + f"Expected empty list for cleared project, got {result_a}" + ) + + result_b = backend.load("local/project-b") + assert len(result_b) == 1, ( + f"Expected 1 triple for project-b, got {len(result_b)}" + ) + + backend.clear("local/nonexistent") + + print("uko-persistence-in-memory-clear-ok") + return 0 + except Exception as exc: + print(f"uko-persistence-in-memory-clear-fail: {exc}") + return 1 + + if command == "restore-skips-incomplete": + try: + backend = InMemoryPersistenceBackend() + backend.save( + "local/test", + [ + # missing subject + {"predicate": "uko:type", "object": "uko-py:Module"}, + # missing predicate + { + "subject": "uko://code/module/foo", + "object": "uko-py:Module", + }, + # missing object + { + "subject": "uko://code/module/foo", + "predicate": "uko:type", + }, + # empty subject + { + "subject": "", + "predicate": "uko:type", + "object": "uko-py:Module", + }, + ], + ) + + graph_backend = _make_mock_graph_backend() + service = UKOGraphPersistence( + graph_backend=graph_backend, + project="local/test", + persistence_backend=backend, + ) + restored = service.restore() + assert restored == 0, f"Expected 0 triples restored, got {restored}" + graph_backend.add_triple.assert_not_called() + + print("uko-persistence-restore-skips-incomplete-ok") + return 0 + except Exception as exc: + print(f"uko-persistence-restore-skips-incomplete-fail: {exc}") + return 1 + + print(f"Unknown command: {command}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/robot/uko_persistence.robot b/robot/uko_persistence.robot new file mode 100644 index 000000000..cc0ffd682 --- /dev/null +++ b/robot/uko_persistence.robot @@ -0,0 +1,54 @@ +*** Settings *** +Documentation Robot Framework integration tests for UKO Graph Persistence +... Tests the full save/restore lifecycle with real temporary directories +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_uko_persistence.py + +*** Test Cases *** +Full Save And Restore Lifecycle With JSON File Backend + [Documentation] Test the complete save/restore cycle with JSONFilePersistenceBackend + [Tags] integration persistence json-backend + ${result}= Run Process ${PYTHON} ${HELPER} save-restore-lifecycle cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} uko-persistence-save-restore-ok + +JSON File Backend Creates Directory If Missing + [Documentation] Test that JSONFilePersistenceBackend creates the base directory + [Tags] integration persistence json-backend + ${result}= Run Process ${PYTHON} ${HELPER} directory-creation cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} uko-persistence-directory-creation-ok + +JSON File Backend Handles Corrupted Files Gracefully + [Documentation] Test that JSONFilePersistenceBackend handles corrupted JSON files + [Tags] integration persistence json-backend error-handling + ${result}= Run Process ${PYTHON} ${HELPER} corrupted-file-handling cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} uko-persistence-corrupted-file-ok + +InMemoryPersistenceBackend Clear Method + [Documentation] Test InMemoryPersistenceBackend.clear() isolates projects correctly + [Tags] integration persistence in-memory + ${result}= Run Process ${PYTHON} ${HELPER} in-memory-clear cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} uko-persistence-in-memory-clear-ok + +Restore Skips Incomplete Triples + [Documentation] Test that restore() skips triples with missing or empty keys + [Tags] integration persistence restore + ${result}= Run Process ${PYTHON} ${HELPER} restore-skips-incomplete cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} uko-persistence-restore-skips-incomplete-ok