Files
library-desk/tests/test_integrity_check.py
T
jpmschweitzerandClaude Fable 5 8f56b78be7 feat: add read-only nightly integrity-check maintenance endpoint
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
2026-07-14 12:08:40 +02:00

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"