Files
library-desk/tests/test_document_sync.py
T
jpmschweitzerandClaude Fable 5 9ceec1464a fix: WS5 hazards batch - CORS, scheduler auth, reranker dedupe, wiring, write txns
- CORS: drop allow_credentials (wildcard origin + credentials told
  browsers to attach credentials for any site); origins configurable via
  CORS_ALLOW_ORIGINS (default * is safe without credentials). Verified
  live: preflight no longer advertises access-control-allow-credentials.
- Scheduler tasks: auth moved from a plain Authorization header (which
  the Scheduler's rest_api_executor does NOT env-substitute) to its
  auth {type: bearer, token: ${LIBRARY_API_KEY}} block, substituted from
  the Scheduler's own environment at execution time. The registrar no
  longer resolves the real key client-side, so it can never be persisted
  into the scheduled_tasks.config JSONB column. Also fixed: JSON bodies
  moved from the ignored "body" key to "payload" (the executor only
  reads config["payload"], so the tasks would have POSTed empty bodies
  and failed required-user validation).
- Reranker: parsed ranking indices are deduplicated preserving first
  occurrence (an LLM answer like "3,3,1" duplicated a result).
- HybridRAG wiring consolidated into dependencies.get_hybrid_rag_service
  (now including volatile_service); the inline copies in /query/hybrid
  and /wiki/pages/smart-create are gone - smart-create previously ran
  without the volatile leg, and the singleton was unused.
- Remaining Neo4j writes (GraphService ingestion/deletes/purges/entity
  mentions, webhook rename+delete cleanup, document-sync _index_graph,
  consolidation mark-processed/add-entity) moved from auto-commit
  execute_query to execute_write managed transactions with retry.

Verified end-to-end on the local dev server as llm_tester: /query/hybrid
200 with all five legs ok (volatile now active), background persistence
landed as one transaction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbFZyDvYksazX6nYQYZ67L
2026-07-14 14:50:03 +02:00

137 lines
4.5 KiB
Python

"""
Offline unit tests for DocumentSyncService vector indexing fixes.
Pins the Phase D fixes:
- ensure_collection is actually awaited (it used to be a bare coroutine
that never ran, so fresh tenants had no collection at upsert time)
- None entries from embed_batch are filtered out instead of poisoning
the whole batch upsert (one failed chunk aborted the document)
- raw client.delete/client.upsert calls are routed through the async
wrapper methods (delete_by_filter / upsert_points)
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from src.services.document_sync_service import DocumentSyncService
TENANT = "llm_tester"
TENANT_COLLECTION = "library_desk_llm_tester"
@pytest.fixture
def mock_qdrant():
qdrant = MagicMock()
qdrant.ensure_collection = AsyncMock()
qdrant.delete_by_filter = AsyncMock(return_value=0)
qdrant.upsert_points = AsyncMock(side_effect=lambda collection_name, points: len(points))
return qdrant
@pytest.fixture
def mock_ollama():
ollama = MagicMock()
ollama.embed_batch = AsyncMock(
side_effect=lambda texts: [[0.1] * 768 for _ in texts]
)
return ollama
@pytest.fixture
def mock_neo4j():
neo4j = MagicMock()
neo4j.execute_query = AsyncMock(return_value=[])
neo4j.execute_write = AsyncMock(return_value=[])
return neo4j
@pytest.fixture
def mock_paperless():
paperless = MagicMock()
paperless.get_custom_field_by_name = AsyncMock(return_value=None)
return paperless
@pytest.fixture
def sync_service(mock_paperless, mock_qdrant, mock_ollama, mock_neo4j):
return DocumentSyncService(
paperless_client=mock_paperless,
qdrant_client=mock_qdrant,
ollama_client=mock_ollama,
neo4j_client=mock_neo4j,
wiki_client=MagicMock(),
settings=MagicMock(),
)
@pytest.mark.unit
class TestIndexVectors:
async def test_ensure_collection_is_awaited(self, sync_service, mock_qdrant):
result = await sync_service.index_document(
document_id=1, user=TENANT, content="hello world", title="Doc"
)
assert result.success is True
mock_qdrant.ensure_collection.assert_awaited_once_with(TENANT_COLLECTION)
async def test_delete_routed_through_wrapper(self, sync_service, mock_qdrant):
await sync_service.index_document(
document_id=7, user=TENANT, content="hello world", title="Doc"
)
mock_qdrant.delete_by_filter.assert_awaited_once_with(
collection_name=TENANT_COLLECTION,
filter_conditions={"doc_type": "document", "paperless_id": 7},
)
async def test_upsert_routed_through_wrapper(self, sync_service, mock_qdrant):
result = await sync_service.index_document(
document_id=1, user=TENANT, content="hello world", title="Doc"
)
assert result.chunks_created == 1
kwargs = mock_qdrant.upsert_points.await_args.kwargs
assert kwargs["collection_name"] == TENANT_COLLECTION
payload = kwargs["points"][0]["payload"]
assert payload["doc_type"] == "document"
assert payload["paperless_id"] == 1
async def test_failed_chunk_embedding_is_skipped_not_fatal(
self, sync_service, mock_qdrant, mock_ollama
):
# Three chunks; the middle embedding fails
long_content = " ".join(f"word{i}" for i in range(1200))
mock_ollama.embed_batch = AsyncMock(
side_effect=lambda texts: [
[0.1] * 768 if i != 1 else None for i in range(len(texts))
]
)
result = await sync_service.index_document(
document_id=2, user=TENANT, content=long_content, title="Doc"
)
assert result.success is True
points = mock_qdrant.upsert_points.await_args.kwargs["points"]
assert result.chunks_created == len(points)
# The failed chunk (index 1) is absent, the others kept their index
indices = [p["payload"]["chunk_index"] for p in points]
assert 1 not in indices
assert len(indices) >= 2
async def test_all_embeddings_failed_reports_failure(
self, sync_service, mock_qdrant, mock_ollama
):
mock_ollama.embed_batch = AsyncMock(
side_effect=lambda texts: [None for _ in texts]
)
result = await sync_service.index_document(
document_id=3, user=TENANT, content="hello world", title="Doc"
)
assert result.success is False
assert "embed" in (result.error or "").lower()
mock_qdrant.upsert_points.assert_not_awaited()