Files
library-desk/tests/test_maintenance.py
T
jpmschweitzerandClaude Opus 4.5 f756ca9490 feat: add maintenance system with index reconciliation
Complete maintenance subsystem for index health and cleanup:

Endpoints:
- GET /maintenance/health - lightweight (or detailed) health check
- POST /maintenance/cleanup/all - full orphan cleanup
- POST /maintenance/cleanup/vectors - purge orphan vector chunks
- POST /maintenance/cleanup/graph - purge orphan graph nodes
- POST /maintenance/reconcile-index - cleanup + reindex missing pages

Bidirectional orphan detection:
- find_documents_without_vectors() in GraphService
- find_chunks_without_graph_nodes() in VectorService

Redis integration:
- Tracks last_cleanup timestamp for scheduler visibility

Config additions:
- Document store, volatile cache, and maintenance settings
- VectorServiceDep and GraphServiceDep type aliases

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 16:22:39 +01:00

489 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, patch
from src.routers.maintenance import (
cleanup_vectors,
cleanup_graph,
cleanup_all,
maintenance_health,
reindex_page,
CleanupResult,
VectorCleanupResponse,
GraphCleanupResponse,
FullCleanupResponse,
HealthCheckResponse,
ReindexResponse
)
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"