""" Offline unit tests for the batched, delete-last vector reindex path. Pins the Phase D fixes in VectorService.update_from_page: - embeddings come from ONE batched embed_batch call, not per-chunk embed() - new points are upserted BEFORE stale points are deleted (deterministic uuid5 ids make the overwrite safe), so a failure can no longer leave the page with zero vectors - the summary reports partial/failed status instead of unconditional success=True when chunks are skipped """ import uuid from unittest.mock import AsyncMock, MagicMock import pytest from src.services.vector_service import VectorService TENANT = "llm_tester" TENANT_COLLECTION = "library_desk_llm_tester" PAGE = { "id": 44, "title": "T", "path": "users/llm_tester/page", "content": " ".join(f"word{i}" for i in range(1200)), # 3 chunks @ 500/50 "tags": [], } def _chunk_id(page_id: int, idx: int) -> str: return str(uuid.uuid5(uuid.NAMESPACE_DNS, f"page_{page_id}_chunk_{idx}")) @pytest.fixture def mock_qdrant(): qdrant = MagicMock() qdrant.collection_exists = AsyncMock(return_value=True) qdrant.ensure_collection = AsyncMock() qdrant.upsert_points = AsyncMock(side_effect=lambda collection_name, points: len(points)) qdrant.scroll_all_points = AsyncMock(return_value=[]) qdrant.delete_by_ids = AsyncMock(side_effect=lambda collection_name, point_ids: len(point_ids)) qdrant.delete_by_filter = AsyncMock(return_value=0) return qdrant @pytest.fixture def mock_ollama(): ollama = MagicMock() ollama.embed_batch = AsyncMock( side_effect=lambda texts, **kw: [[0.1] * 768 for _ in texts] ) return ollama @pytest.fixture def mock_wiki(): wiki = MagicMock() wiki.get_page = AsyncMock(return_value=dict(PAGE)) return wiki @pytest.fixture def service(mock_qdrant, mock_wiki, mock_ollama): return VectorService(mock_qdrant, mock_wiki, mock_ollama) @pytest.mark.unit class TestBatchedReindex: async def test_single_batched_embed_and_single_upsert( self, service, mock_qdrant, mock_ollama ): summary = await service.update_from_page(page_id=44, user=TENANT) assert summary.success is True assert summary.status == "success" assert summary.chunks_created == 3 mock_ollama.embed_batch.assert_awaited_once() mock_qdrant.upsert_points.assert_awaited_once() async def test_upsert_happens_before_stale_delete(self, service, mock_qdrant): order = [] mock_qdrant.upsert_points = AsyncMock( side_effect=lambda collection_name, points: order.append("upsert") or len(points) ) mock_qdrant.delete_by_ids = AsyncMock( side_effect=lambda collection_name, point_ids: order.append("delete") or len(point_ids) ) # One stale point from a previous, longer version of the page mock_qdrant.scroll_all_points = AsyncMock(return_value=[ {"id": _chunk_id(44, i), "payload": {}} for i in range(4) ]) summary = await service.update_from_page(page_id=44, user=TENANT) assert order == ["upsert", "delete"] assert summary.chunks_deleted == 1 # only the stale 4th chunk deleted = mock_qdrant.delete_by_ids.await_args.kwargs["point_ids"] assert deleted == [_chunk_id(44, 3)] async def test_partial_embedding_failure_marks_partial( self, service, mock_qdrant, mock_ollama ): mock_ollama.embed_batch = AsyncMock( side_effect=lambda texts, **kw: [ [0.1] * 768 if i != 1 else None for i in range(len(texts)) ] ) summary = await service.update_from_page(page_id=44, user=TENANT) assert summary.status == "partial" assert summary.success is True assert summary.chunks_created == 2 assert summary.chunks_skipped == 1 assert "failed" in (summary.error_message or "") async def test_total_embedding_failure_keeps_old_vectors( self, service, mock_qdrant, mock_ollama ): mock_ollama.embed_batch = AsyncMock( side_effect=lambda texts, **kw: [None for _ in texts] ) mock_qdrant.scroll_all_points = AsyncMock(return_value=[ {"id": _chunk_id(44, 0), "payload": {}} ]) summary = await service.update_from_page(page_id=44, user=TENANT) assert summary.status == "failed" assert summary.success is False # Old vectors are NOT wiped when nothing new was stored mock_qdrant.upsert_points.assert_not_awaited() mock_qdrant.delete_by_ids.assert_not_awaited()