diff --git a/.gitignore b/.gitignore index 01d8778..ae676be 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ dist/ build/ __pycache__/ +.hypothesis/ *.pyc .pytest_cache/ mempal.yaml diff --git a/tests/test_cli_source_adapters.py b/tests/test_cli_source_adapters.py index 0d51b1c..221238b 100644 --- a/tests/test_cli_source_adapters.py +++ b/tests/test_cli_source_adapters.py @@ -120,6 +120,16 @@ class _MetadataAdapter(BaseSourceAdapter): return AdapterSchema(version="1.0", fields={}) +class _InvalidResultAdapter(BaseSourceAdapter): + name = "invalid-result" + + def ingest(self, *, source, palace): + yield object() + + def describe_schema(self): + return AdapterSchema(version="1.0", fields={}) + + def _hold_palace_lock(palace_path, ready_flag, release_flag): """Hold a writer lease in a separate process for contention coverage.""" from mempalace.palace import mine_palace_lock @@ -236,12 +246,11 @@ def test_mine_source_dry_run_prevents_direct_collection_and_kg_mutations(monkeyp register("direct-mutation", _DirectMutationAdapter) monkeypatch.setattr(cli, "MempalaceConfig", _FakeConfig) - - def read_only_collection(_palace_path, *, create): - assert create is False - raise FileNotFoundError - - monkeypatch.setattr(palace, "get_collection", read_only_collection) + # Dry runs must never reach the backend at all (see + # test_mine_source_dry_run_never_opens_existing_collection for the + # dedicated assertion); failing here catches a regression that + # reintroduces a real `get_collection` call in the dry-run path. + monkeypatch.setattr(palace, "get_collection", lambda *_a, **_k: pytest.fail("must not open")) monkeypatch.setattr(knowledge_graph, "KnowledgeGraph", _FakeKnowledgeGraph) drawers_written = cli.mine_source_adapter( @@ -323,6 +332,25 @@ def test_mine_source_dry_run_existing_uninitialized_palace_creates_no_chroma_art assert not (palace / "chroma.sqlite3").exists() +def test_mine_source_dry_run_fresh_palace_creates_no_backend_artifacts(tmp_path, monkeypatch): + """Dry runs must not materialize a nonexistent Chroma palace.""" + register("fixture", _FixtureAdapter) + palace = tmp_path / "fresh-palace" + monkeypatch.setenv("MEMPALACE_BACKEND", "chroma") + + assert ( + cli.mine_source_adapter( + source_name="fixture", + source_path="/source", + palace_path=str(palace), + dry_run=True, + ) + == 1 + ) + + assert not palace.exists() + + def test_mine_source_dry_run_preserves_initialized_sqlite_exact_artifacts(tmp_path, monkeypatch): """Dry runs must not open or alter an existing sqlite_exact backend.""" from mempalace.backends.base import PalaceRef @@ -387,6 +415,19 @@ def test_mine_source_accepts_non_incremental_metadata(monkeypatch, recwarn): assert "non-incremental item metadata" in str(recwarn.pop(RuntimeWarning).message) +def test_mine_source_rejects_unsupported_adapter_results(): + """Unexpected adapter yields must fail rather than being silently dropped.""" + register("invalid-result", _InvalidResultAdapter) + + with pytest.raises(TypeError, match="unsupported result type object"): + cli.mine_source_adapter( + source_name="invalid-result", + source_path="/source", + palace_path="/fake/palace", + dry_run=True, + ) + + def test_mine_source_holds_writer_lease_before_opening_handles(monkeypatch): from mempalace import knowledge_graph, palace diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 957e2eb..bbf80cf 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -1182,6 +1182,42 @@ def test_run_mine_invalid_mode_returns_structured_error(tmp_path): assert out["exit_code"] == 2 +def test_run_mine_source_adapter_dispatches_through_adapter_runner(tmp_path, monkeypatch): + """Daemon mine jobs preserve the CLI's explicit source-adapter dispatch.""" + from mempalace import cli, service + + received = {} + + def fake_mine_source_adapter(**kwargs): + received.update(kwargs) + return 3 + + monkeypatch.setattr(cli, "mine_source_adapter", fake_mine_source_adapter) + palace = tmp_path / "palace" + out = service.run_mine( + { + "palace_path": str(palace), + "source": "/source", + "source_adapter": "fixture", + "dry_run": True, + } + ) + + assert received == { + "source_name": "fixture", + "source_path": "/source", + "palace_path": str(palace.resolve()), + "dry_run": True, + } + assert out == { + "success": True, + "kind": "mine", + "mode": "source", + "dry_run": True, + "exit_code": 0, + } + + def test_run_mcp_tool_rejects_non_dict_arguments(): from mempalace import service