""" Offline unit tests for the nightly integrity-check endpoint (Phase C item 2). All external clients are mocked - no shared services are contacted. The endpoint must be strictly read-only: these tests assert that no delete/purge/update methods are ever invoked. """ import pytest from unittest.mock import AsyncMock from src.routers.maintenance import ( IntegrityCheckRequest, classify_collection, integrity_check, run_integrity_check, ) TEST_USER = "llm_tester" TENANT_PREFIX = f"users/{TEST_USER}" def _mock_clients( wiki_pages, chunk_refs, graph_docs, collections, ): wiki_client = AsyncMock() wiki_client.list_all_pages = AsyncMock(return_value=wiki_pages) vector_service = AsyncMock() vector_service.get_all_chunk_references = AsyncMock(return_value=chunk_refs) graph_service = AsyncMock() graph_service.get_all_document_references = AsyncMock(return_value=graph_docs) qdrant = AsyncMock() qdrant.list_collections = AsyncMock(return_value=collections) return wiki_client, vector_service, graph_service, qdrant class TestRunIntegrityCheck: @pytest.mark.asyncio async def test_full_report(self): wiki_pages = [ # tenant pages {"id": 1, "path": f"{TENANT_PREFIX}/a", "title": "A"}, {"id": 2, "path": f"{TENANT_PREFIX}/b", "title": "B"}, # another tenant's page (defines a known tenant, out of scope here) {"id": 50, "path": "users/jpmschweitzer/x", "title": "X"}, ] chunk_refs = [ # page 1 has vectors; page 2 has none (silent-skip victim) {"chunk_id": "c1", "page_id": 1, "doc_type": "wiki"}, # orphan: page 99 no longer exists in the wiki {"chunk_id": "c9", "page_id": 99, "doc_type": "wiki"}, # non-wiki chunk is ignored by the wiki-side checks {"chunk_id": "cd", "document_id": "d1", "doc_type": "document"}, ] graph_docs = [ {"page_id": 1, "path": f"{TENANT_PREFIX}/a", "title": "A", "doc_type": "wiki"}, # stale Document node: wiki page 77 is gone {"page_id": 77, "path": f"{TENANT_PREFIX}/old", "title": "Old", "doc_type": "wiki"}, ] collections = [ {"name": "library_desk_jpmschweitzer", "vectors_count": 10}, {"name": "library_desk_llm_tester", "vectors_count": 2}, {"name": "library_desk_ghost_tenant", "vectors_count": 1}, {"name": "open-webui_files", "vectors_count": 5}, ] wiki_client, vector_service, graph_service, qdrant = _mock_clients( wiki_pages, chunk_refs, graph_docs, collections ) report = await run_integrity_check( TEST_USER, vector_service, graph_service, wiki_client, qdrant ) assert report.success is True assert report.user == TEST_USER # Pages without vectors: page 2 only (page 1 has c1) assert [p["page_id"] for p in report.pages_without_vectors] == [2] # Orphaned vectors: c9 -> page 99 assert report.orphaned_vector_chunks == 1 assert report.orphaned_vector_page_ids == [99] # Collections: llm_tester is test residue, ghost_tenant unknown, # jpmschweitzer expected, open-webui foreign flagged = {c["name"]: c["category"] for c in report.unexpected_collections} assert flagged == { "library_desk_llm_tester": "test_residue", "library_desk_ghost_tenant": "unknown_tenant", } assert report.foreign_collections == 1 # Graph documents without wiki counterparts: page 77 assert [d["page_id"] for d in report.documents_without_wiki] == [77] assert report.counts["tenant_wiki_pages"] == 2 assert report.counts["pages_without_vectors"] == 1 assert report.counts["documents_without_wiki"] == 1 assert report.duration_ms >= 0 @pytest.mark.asyncio async def test_read_only_no_mutations(self): """The integrity check must never call any destructive method.""" wiki_client, vector_service, graph_service, qdrant = _mock_clients( [], [], [], [] ) await run_integrity_check( TEST_USER, vector_service, graph_service, wiki_client, qdrant ) for mock, destructive in ( (vector_service, ("purge_chunks_by_ids", "delete_page_chunks")), (graph_service, ("purge_orphan_entities", "purge_stale_documents_by_ids", "delete_page", "cleanup_broken_relationships")), (qdrant, ("delete_collection", "delete_by_ids", "delete_by_filter")), (wiki_client, ("delete_page", "update_page", "create_page")), ): for name in destructive: assert not getattr(mock, name).await_count, ( f"integrity check must be read-only but called {name}" ) class TestIntegrityEndpoint: @pytest.mark.asyncio async def test_caches_latest_report_in_redis(self): wiki_client, vector_service, graph_service, qdrant = _mock_clients( [{"id": 1, "path": f"{TENANT_PREFIX}/a", "title": "A"}], [{"chunk_id": "c1", "page_id": 1, "doc_type": "wiki"}], [], [], ) redis = AsyncMock() report = await integrity_check( request=IntegrityCheckRequest(user=TEST_USER), vector_service=vector_service, graph_service=graph_service, wiki_client=wiki_client, qdrant=qdrant, redis=redis, api_key="", ) assert report.success is True redis.setex.assert_awaited_once() key = redis.setex.await_args.args[0] assert key == f"library:integrity:latest:{TEST_USER}" def test_requires_user(self): with pytest.raises(Exception): IntegrityCheckRequest(user=" ") class TestClassifyCollection: def test_expected_tenant(self): assert classify_collection( "library_desk_jpmschweitzer", {"jpmschweitzer"} ) == "expected" def test_test_residue_beats_known_tenant(self): # Even if the test tenant has wiki pages during a run, its # collections are still flagged as residue. assert classify_collection( "library_desk_llm_tester", {"llm_tester"} ) == "test_residue" def test_unknown_tenant(self): assert classify_collection( "volatile_mystery", {"jpmschweitzer"} ) == "unknown_tenant" def test_foreign_and_foreign_residue(self): assert classify_collection("open-webui_files", set()) == "foreign" assert classify_collection("core_ai_user_test_at_example_com", set()) == \ "foreign_test_residue" assert classify_collection("test_user", set()) == "foreign_test_residue"