POST /maintenance/integrity-check {user} reports per tenant, without
ever fixing anything:
- wiki pages with ZERO vectors in Qdrant (silent-skip reindex victims)
- orphaned vectors whose wiki page no longer exists
- unexpected Qdrant collections vs known tenant patterns (test-tenant
residue and unknown namespaces flagged; foreign services counted)
- Neo4j Document nodes without wiki counterparts
- counts + duration_ms
The latest report is cached in Redis (library:integrity:latest:{user},
30-day TTL) so the weekly quality report can fold it in. Explicit user
required per Phase B. Offline tests assert the report contents, the
collection classification rules, and that no destructive client method
is ever invoked.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbFZyDvYksazX6nYQYZ67L
187 lines
6.8 KiB
Python
187 lines
6.8 KiB
Python
"""
|
|
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"
|