Files
library-desk/tests/test_maintenance.py
jpmschweitzerandClaude a687b770ef fix: clear ruff so the pre-push gate passes
105 findings to zero. Most were mechanical — 67 unused imports, and assorted
f-strings without placeholders. Three groups needed a decision.

The 15 F821 "undefined name" were forward references, not runtime errors. Each
annotation is quoted — `-> "WikiService"`, `Optional["IngestionService"]` — with
the real import inside the function body to break an import cycle. A quoted
annotation is never evaluated, so the code ran; the names were simply
unresolvable to any checker. They now have a TYPE_CHECKING block, which costs
nothing at import time and keeps the cycle broken.

The 6 E402 split two ways. `import secrets`, `Security`, `Request` and
`HTTPBearer` in dependencies.py had drifted below several hundred lines of
factory functions for no reason — stdlib and fastapi, no cycle to avoid — and
moved up. The other three are deliberate and now say so: the VectorService and
GraphService aliases import back into dependencies.py, and main.py's routers
expect a configured app, so both must stay put.

Bare `except:` narrowed to `except Exception:` in three places, which stops them
swallowing KeyboardInterrupt and SystemExit.

The 5 unused locals were all genuinely dead. One is worth naming rather than
fixing: qdrant_client.delete()'s return value was bound and never read, so a
failed delete is indistinguishable from a successful one — the assignment is
gone, but nothing checks the status either way and that has not changed here.
`timing = {}` in _retrieve_parallel looked like it might mean the reported
per-leg timings were always zero; traced, and they come from output["timing"],
so the local was only vestigial.

426 passed, 29 skipped, unchanged. The app imports and the service aliases still
resolve, which is the check that mattered after moving imports in
dependencies.py.

The gate still prints "not gated here yet: test (T-56)" — lint is green, tests
remain unwired, and that is left visible rather than silently absent.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 17:04:58 +02:00

487 lines
17 KiB
Python

"""
Tests for maintenance router and cleanup functionality.
Tests cleanup of:
- Orphan vector chunks
- Orphan entities in graph
- Stale document nodes
"""
import pytest
from unittest.mock import AsyncMock, MagicMock
from src.routers.maintenance import (
cleanup_vectors,
cleanup_graph,
cleanup_all,
maintenance_health,
reindex_page,
CleanupResult,
VectorCleanupResponse,
GraphCleanupResponse,
HealthCheckResponse
)
class TestCleanupResult:
"""Test CleanupResult model."""
def test_cleanup_result_defaults(self):
"""Test CleanupResult with default values."""
result = CleanupResult(duration_ms=100.0)
assert result.orphans_found == 0
assert result.orphans_purged == 0
assert result.duration_ms == 100.0
def test_cleanup_result_with_values(self):
"""Test CleanupResult with actual values."""
result = CleanupResult(
orphans_found=10,
orphans_purged=8,
duration_ms=250.5
)
assert result.orphans_found == 10
assert result.orphans_purged == 8
assert result.duration_ms == 250.5
class TestVectorCleanupResponse:
"""Test VectorCleanupResponse model."""
def test_vector_cleanup_response(self):
"""Test VectorCleanupResponse structure."""
response = VectorCleanupResponse(
success=True,
wiki_chunks=CleanupResult(orphans_found=5, orphans_purged=5, duration_ms=50),
document_chunks=CleanupResult(orphans_found=3, orphans_purged=3, duration_ms=50),
chunks_without_graph=CleanupResult(orphans_found=2, orphans_purged=2, duration_ms=50),
total_chunks_scanned=100,
total_orphans_purged=10,
duration_ms=100
)
assert response.success is True
assert response.wiki_chunks.orphans_found == 5
assert response.document_chunks.orphans_found == 3
assert response.chunks_without_graph.orphans_found == 2
assert response.total_orphans_purged == 10
class TestGraphCleanupResponse:
"""Test GraphCleanupResponse model."""
def test_graph_cleanup_response(self):
"""Test GraphCleanupResponse structure."""
response = GraphCleanupResponse(
success=True,
orphan_entities=CleanupResult(orphans_found=10, orphans_purged=10, duration_ms=25),
stale_wiki_documents=CleanupResult(orphans_found=2, orphans_purged=2, duration_ms=25),
stale_store_documents=CleanupResult(orphans_found=0, orphans_purged=0, duration_ms=25),
docs_without_vectors=CleanupResult(orphans_found=1, orphans_purged=1, duration_ms=25),
broken_relationships_cleaned=5,
duration_ms=100
)
assert response.success is True
assert response.orphan_entities.orphans_found == 10
assert response.docs_without_vectors.orphans_found == 1
assert response.broken_relationships_cleaned == 5
class TestHealthCheckResponse:
"""Test HealthCheckResponse model."""
def test_health_check_healthy(self):
"""Test healthy status."""
response = HealthCheckResponse(
status="healthy",
orphan_vector_count=0,
orphan_entity_count=0,
stale_document_count=0
)
assert response.status == "healthy"
assert response.recommendations == []
def test_health_check_degraded(self):
"""Test degraded status with recommendations."""
response = HealthCheckResponse(
status="degraded",
orphan_vector_count=15,
orphan_entity_count=3,
stale_document_count=0,
recommendations=[
"Found 15 orphan vector chunks. Consider running POST /maintenance/cleanup/vectors"
]
)
assert response.status == "degraded"
assert len(response.recommendations) == 1
@pytest.mark.asyncio
class TestVectorCleanup:
"""Test vector cleanup endpoint."""
async def test_cleanup_vectors_no_orphans(self):
"""Test cleanup when no orphans exist."""
# Mock services
vector_service = AsyncMock()
vector_service.get_all_chunk_references.return_value = [
{"chunk_id": "c1", "page_id": 1, "doc_type": "wiki"}
]
# find_chunks_without_graph_nodes is not async
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
graph_service = AsyncMock()
graph_service.get_all_document_references.return_value = [
{"page_id": 1, "doc_type": "wiki", "title": "Test"}
]
wiki_client = AsyncMock()
wiki_client.list_all_pages.return_value = [{"id": 1, "path": "test"}]
# Call cleanup
result = await cleanup_vectors(
user="testuser",
dry_run=False,
vector_service=vector_service,
graph_service=graph_service,
wiki_client=wiki_client,
api_key="test"
)
assert result.success is True
assert result.wiki_chunks.orphans_found == 0
assert result.chunks_without_graph.orphans_found == 0
assert result.total_orphans_purged == 0
async def test_cleanup_vectors_with_orphans(self):
"""Test cleanup when orphans exist."""
# Mock services
vector_service = AsyncMock()
vector_service.get_all_chunk_references.return_value = [
{"chunk_id": "c1", "page_id": 1, "doc_type": "wiki"},
{"chunk_id": "c2", "page_id": 999, "doc_type": "wiki"}, # Orphan
{"chunk_id": "c3", "page_id": 999, "doc_type": "wiki"}, # Orphan
]
vector_service.purge_chunks_by_ids.return_value = 2
# find_chunks_without_graph_nodes is not async
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
graph_service = AsyncMock()
graph_service.get_all_document_references.return_value = [
{"page_id": 1, "doc_type": "wiki", "title": "Test"}
]
wiki_client = AsyncMock()
wiki_client.list_all_pages.return_value = [{"id": 1, "path": "test"}]
# Call cleanup
result = await cleanup_vectors(
user="testuser",
dry_run=False,
vector_service=vector_service,
graph_service=graph_service,
wiki_client=wiki_client,
api_key="test"
)
assert result.success is True
assert result.wiki_chunks.orphans_found == 2
assert result.wiki_chunks.orphans_purged == 2
assert result.total_orphans_purged == 2
async def test_cleanup_vectors_dry_run(self):
"""Test cleanup dry run doesn't purge."""
# Mock services
vector_service = AsyncMock()
vector_service.get_all_chunk_references.return_value = [
{"chunk_id": "c1", "page_id": 999, "doc_type": "wiki"}, # Orphan
]
# find_chunks_without_graph_nodes is not async
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
graph_service = AsyncMock()
graph_service.get_all_document_references.return_value = []
wiki_client = AsyncMock()
wiki_client.list_all_pages.return_value = []
# Call cleanup in dry run mode
result = await cleanup_vectors(
user="testuser",
dry_run=True,
vector_service=vector_service,
graph_service=graph_service,
wiki_client=wiki_client,
api_key="test"
)
assert result.success is True
assert result.wiki_chunks.orphans_found == 1
assert result.wiki_chunks.orphans_purged == 0 # Not purged due to dry run
vector_service.purge_chunks_by_ids.assert_not_called()
@pytest.mark.asyncio
class TestGraphCleanup:
"""Test graph cleanup endpoint."""
async def test_cleanup_graph_no_orphans(self):
"""Test cleanup when no orphans exist."""
vector_service = AsyncMock()
vector_service.get_all_chunk_references.return_value = [
{"chunk_id": "c1", "page_id": 1, "doc_type": "wiki"}
]
graph_service = AsyncMock()
graph_service.find_orphan_entities.return_value = []
graph_service.get_all_document_references.return_value = [
{"page_id": 1, "doc_type": "wiki", "title": "Test"}
]
graph_service.find_documents_without_vectors.return_value = []
graph_service.cleanup_broken_relationships.return_value = 0
wiki_client = AsyncMock()
wiki_client.list_all_pages.return_value = [{"id": 1, "path": "test"}]
result = await cleanup_graph(
user="testuser",
dry_run=False,
vector_service=vector_service,
graph_service=graph_service,
wiki_client=wiki_client,
api_key="test"
)
assert result.success is True
assert result.orphan_entities.orphans_found == 0
assert result.stale_wiki_documents.orphans_found == 0
assert result.docs_without_vectors.orphans_found == 0
async def test_cleanup_graph_with_orphan_entities(self):
"""Test cleanup of orphan entities."""
vector_service = AsyncMock()
vector_service.get_all_chunk_references.return_value = []
graph_service = AsyncMock()
graph_service.find_orphan_entities.return_value = [
{"id": "e1", "name": "Orphan1", "type": "Person"},
{"id": "e2", "name": "Orphan2", "type": "Technology"},
]
graph_service.purge_orphan_entities.return_value = 2
graph_service.get_all_document_references.return_value = []
graph_service.find_documents_without_vectors.return_value = []
graph_service.cleanup_broken_relationships.return_value = 0
wiki_client = AsyncMock()
wiki_client.list_all_pages.return_value = []
result = await cleanup_graph(
user="testuser",
dry_run=False,
vector_service=vector_service,
graph_service=graph_service,
wiki_client=wiki_client,
api_key="test"
)
assert result.success is True
assert result.orphan_entities.orphans_found == 2
assert result.orphan_entities.orphans_purged == 2
async def test_cleanup_graph_with_stale_documents(self):
"""Test cleanup of stale document nodes."""
vector_service = AsyncMock()
vector_service.get_all_chunk_references.return_value = [
{"chunk_id": "c1", "page_id": 1, "doc_type": "wiki"}
]
graph_service = AsyncMock()
graph_service.find_orphan_entities.return_value = []
graph_service.get_all_document_references.return_value = [
{"page_id": 1, "doc_type": "wiki", "title": "Exists"},
{"page_id": 999, "doc_type": "wiki", "title": "Deleted"}, # Stale
]
graph_service.find_documents_without_vectors.return_value = []
graph_service.purge_stale_documents_by_ids.return_value = 1
graph_service.cleanup_broken_relationships.return_value = 0
wiki_client = AsyncMock()
wiki_client.list_all_pages.return_value = [{"id": 1, "path": "test"}]
result = await cleanup_graph(
user="testuser",
dry_run=False,
vector_service=vector_service,
graph_service=graph_service,
wiki_client=wiki_client,
api_key="test"
)
assert result.success is True
assert result.stale_wiki_documents.orphans_found == 1
assert result.stale_wiki_documents.orphans_purged == 1
@pytest.mark.asyncio
class TestFullCleanup:
"""Test full cleanup endpoint."""
async def test_full_cleanup(self):
"""Test full cleanup runs both vector and graph cleanup."""
vector_service = AsyncMock()
vector_service.get_all_chunk_references.return_value = []
# find_chunks_without_graph_nodes is not async
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
graph_service = AsyncMock()
graph_service.find_orphan_entities.return_value = []
graph_service.get_all_document_references.return_value = []
graph_service.find_documents_without_vectors.return_value = []
graph_service.cleanup_broken_relationships.return_value = 0
wiki_client = AsyncMock()
wiki_client.list_all_pages.return_value = []
result = await cleanup_all(
user="testuser",
dry_run=False,
vector_service=vector_service,
graph_service=graph_service,
wiki_client=wiki_client,
api_key="test"
)
assert result.success is True
assert result.vector_cleanup.success is True
assert result.graph_cleanup.success is True
@pytest.mark.asyncio
class TestMaintenanceHealth:
"""Test maintenance health endpoint."""
async def test_health_healthy(self):
"""Test healthy status when no orphans."""
vector_service = AsyncMock()
vector_service.get_all_chunk_references.return_value = []
# find_chunks_without_graph_nodes is not async
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
graph_service = AsyncMock()
graph_service.find_orphan_entities.return_value = []
graph_service.get_all_document_references.return_value = []
graph_service.find_documents_without_vectors.return_value = []
wiki_client = AsyncMock()
wiki_client.list_all_pages.return_value = []
result = await maintenance_health(
user="testuser",
vector_service=vector_service,
graph_service=graph_service,
wiki_client=wiki_client,
api_key="test"
)
assert result.status == "healthy"
assert result.orphan_vector_count == 0
assert result.orphan_entity_count == 0
assert result.vectors_without_graph == 0
assert result.docs_without_vectors == 0
async def test_health_degraded(self):
"""Test degraded status with orphans."""
vector_service = AsyncMock()
vector_service.get_all_chunk_references.return_value = [
{"chunk_id": f"c{i}", "page_id": 999, "doc_type": "wiki"}
for i in range(15)
]
# find_chunks_without_graph_nodes is not async
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
graph_service = AsyncMock()
graph_service.find_orphan_entities.return_value = [
{"id": f"e{i}", "name": f"Entity{i}", "type": "Entity"}
for i in range(3)
]
graph_service.get_all_document_references.return_value = []
graph_service.find_documents_without_vectors.return_value = []
wiki_client = AsyncMock()
wiki_client.list_all_pages.return_value = []
result = await maintenance_health(
user="testuser",
vector_service=vector_service,
graph_service=graph_service,
wiki_client=wiki_client,
api_key="test"
)
assert result.status == "degraded"
assert result.orphan_vector_count == 15
assert result.orphan_entity_count == 3
assert len(result.recommendations) >= 1
@pytest.mark.asyncio
class TestReindexPage:
"""Test reindex page endpoint."""
async def test_reindex_success(self):
"""Test successful page reindex."""
vector_service = AsyncMock()
vector_service.delete_page_chunks.return_value = 5
vector_service.update_from_page.return_value = MagicMock(
success=True,
chunks_created=6,
error_message=None
)
graph_service = AsyncMock()
graph_service.delete_page.return_value = 1
graph_service.update_from_page.return_value = MagicMock(
success=True,
error_message=None
)
result = await reindex_page(
page_id=123,
user="testuser",
vector_service=vector_service,
graph_service=graph_service,
api_key="test"
)
assert result.success is True
assert result.page_id == 123
assert result.vectors_deleted == 5
assert result.vectors_created == 6
assert result.graph_updated is True
async def test_reindex_failure(self):
"""Test reindex with failure."""
vector_service = AsyncMock()
vector_service.delete_page_chunks.return_value = 0
vector_service.update_from_page.return_value = MagicMock(
success=False,
chunks_created=0,
error_message="Page not found"
)
graph_service = AsyncMock()
graph_service.delete_page.return_value = 0
graph_service.update_from_page.return_value = MagicMock(
success=False,
error_message="Page not found"
)
result = await reindex_page(
page_id=999,
user="testuser",
vector_service=vector_service,
graph_service=graph_service,
api_key="test"
)
assert result.success is False
assert result.error == "Page not found"