mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
159 lines
5.9 KiB
Python
159 lines
5.9 KiB
Python
"""Unit tests for OptimalService de-index helpers.
|
|||
|
|
|
||
|
|
C3: ``unindex_journal_entry`` must remove a journal entry's embedded chunks
|
||
|
|
from the vector store AND its ``indexed_documents`` tracking row, so a
|
||
|
|
deleted (or private) entry stops surfacing in RAG answers and claim-time
|
||
|
|
briefings. Idempotent — a never-indexed entry is a clean no-op. Best-effort
|
||
|
|
— a failure never raises.
|
||
|
|
|
||
|
|
Mirrors the mock-based pattern in ``test_optimal_doc_source.py`` and
|
||
|
|
``test_playbook_unindex.py``: the JOURNALS plugin and ``get_db_context`` are
|
||
|
|
stubbed so the test exercises only the de-index wiring (no real pgvector
|
||
|
|
round-trip in unit tests).
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import hashlib
|
||
|
|
from contextlib import asynccontextmanager
|
||
|
|
from typing import Any
|
||
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
|
from uuid import uuid4
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from roboco.models.optimal import IndexType
|
||
|
|
from roboco.services.optimal import OptimalService
|
||
|
|
|
||
|
|
|
||
|
|
def _service_with_stub_journal_plugin() -> OptimalService:
|
||
|
|
"""Build an OptimalService with a mocked JOURNALS plugin + store.
|
||
|
|
|
||
|
|
The plugin's ``_require_store.delete_by_source`` is an AsyncMock the test
|
||
|
|
asserts on; it stands in for the vector-store chunk removal.
|
||
|
|
"""
|
||
|
|
svc = OptimalService()
|
||
|
|
store = MagicMock()
|
||
|
|
store.delete_by_source = AsyncMock(return_value=None)
|
||
|
|
plugin = MagicMock()
|
||
|
|
plugin._require_store = store
|
||
|
|
svc._plugins = {IndexType.JOURNALS: plugin}
|
||
|
|
svc._initialized = True
|
||
|
|
return svc
|
||
|
|
|
||
|
|
|
||
|
|
def _fake_session() -> Any:
|
||
|
|
"""Async session mock whose execute returns a result with rowcount=1."""
|
||
|
|
session = MagicMock()
|
||
|
|
result = MagicMock()
|
||
|
|
result.rowcount = 1
|
||
|
|
session.execute = AsyncMock(return_value=result)
|
||
|
|
session.flush = AsyncMock()
|
||
|
|
return session
|
||
|
|
|
||
|
|
|
||
|
|
@asynccontextmanager
|
||
|
|
async def _fake_db_context(session: Any) -> Any:
|
||
|
|
yield session
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_unindex_journal_entry_removes_chunks_and_tracking_row() -> None:
|
||
|
|
"""De-index calls the vector-store delete_by_source with the journal
|
||
|
|
source URI, then drops the tracking row via the repository. Both calls
|
||
|
|
receive ``roboco://journals/{entry_id}`` and the JOURNALS index type."""
|
||
|
|
entry_id = uuid4()
|
||
|
|
svc = _service_with_stub_journal_plugin()
|
||
|
|
plugin: Any = svc._plugins[IndexType.JOURNALS]
|
||
|
|
session = _fake_session()
|
||
|
|
|
||
|
|
with patch(
|
||
|
|
"roboco.db.get_db_context",
|
||
|
|
lambda: _fake_db_context(session),
|
||
|
|
):
|
||
|
|
await svc.unindex_journal_entry(entry_id)
|
||
|
|
|
||
|
|
expected_source = f"roboco://journals/{entry_id}"
|
||
|
|
plugin._require_store.delete_by_source.assert_awaited_once_with(expected_source)
|
||
|
|
# The tracking-row delete ran against the JOURNALS index + journal source.
|
||
|
|
session.execute.assert_awaited_once()
|
||
|
|
session.flush.assert_awaited_once()
|
||
|
|
# The DELETE statement filters indexed_documents by index_type + source_hash;
|
||
|
|
# the repo computes the hash from the source URI (sha256), so assert the
|
||
|
|
# bind param matches the journal source's hash.
|
||
|
|
call = session.execute.await_args
|
||
|
|
stmt = call.args[0]
|
||
|
|
compiled = stmt.compile(compile_kwargs={"literal_binds": True})
|
||
|
|
sql_text = str(compiled)
|
||
|
|
assert "indexed_documents" in sql_text
|
||
|
|
expected_hash = hashlib.sha256(expected_source.encode()).hexdigest()
|
||
|
|
assert expected_hash in sql_text
|
||
|
|
assert IndexType.JOURNALS.value in sql_text
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_unindex_journal_entry_is_idempotent() -> None:
|
||
|
|
"""A second call is a clean no-op: the vector store + repo deletes are
|
||
|
|
called again (idempotent on a missing source) and no exception raised."""
|
||
|
|
entry_id = uuid4()
|
||
|
|
svc = _service_with_stub_journal_plugin()
|
||
|
|
session = _fake_session()
|
||
|
|
|
||
|
|
with patch(
|
||
|
|
"roboco.db.get_db_context",
|
||
|
|
lambda: _fake_db_context(session),
|
||
|
|
):
|
||
|
|
await svc.unindex_journal_entry(entry_id)
|
||
|
|
# Second call must not raise even though the source is already gone.
|
||
|
|
await svc.unindex_journal_entry(entry_id)
|
||
|
|
|
||
|
|
expected_calls = 2 # one per de-index invocation
|
||
|
|
journal_plugin: Any = svc._plugins[IndexType.JOURNALS]
|
||
|
|
assert journal_plugin._require_store.delete_by_source.await_count == expected_calls
|
||
|
|
assert session.execute.await_count == expected_calls
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_unindex_journal_entry_swallows_vector_store_failure() -> None:
|
||
|
|
"""A vector-store failure is logged + swallowed and the tracking-row
|
||
|
|
delete is skipped (the chunks may still be present, so dropping the
|
||
|
|
tracking row would lie about what's indexed). No exception escapes."""
|
||
|
|
entry_id = uuid4()
|
||
|
|
svc = _service_with_stub_journal_plugin()
|
||
|
|
plugin: Any = svc._plugins[IndexType.JOURNALS]
|
||
|
|
plugin._require_store.delete_by_source = AsyncMock(
|
||
|
|
side_effect=RuntimeError("pgvector blew up")
|
||
|
|
)
|
||
|
|
session = _fake_session()
|
||
|
|
|
||
|
|
with patch(
|
||
|
|
"roboco.db.get_db_context",
|
||
|
|
lambda: _fake_db_context(session),
|
||
|
|
):
|
||
|
|
# Must not raise.
|
||
|
|
await svc.unindex_journal_entry(entry_id)
|
||
|
|
|
||
|
|
# Tracking row NOT dropped when chunks remain.
|
||
|
|
session.execute.assert_not_awaited()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_unindex_journal_entry_swallows_tracking_row_failure() -> None:
|
||
|
|
"""A tracking-row delete failure is logged + swallowed; the chunks were
|
||
|
|
already removed so the orphaned tracking row is a stale cosmetic, not a
|
||
|
|
leak. No exception escapes."""
|
||
|
|
entry_id = uuid4()
|
||
|
|
svc = _service_with_stub_journal_plugin()
|
||
|
|
plugin: Any = svc._plugins[IndexType.JOURNALS]
|
||
|
|
plugin._require_store.delete_by_source = AsyncMock(return_value=None)
|
||
|
|
|
||
|
|
# Session whose execute raises — the real repo propagates this.
|
||
|
|
session = MagicMock()
|
||
|
|
session.execute = AsyncMock(side_effect=RuntimeError("db connection lost"))
|
||
|
|
session.flush = AsyncMock()
|
||
|
|
|
||
|
|
with patch("roboco.db.get_db_context", lambda: _fake_db_context(session)):
|
||
|
|
# Must not raise.
|
||
|
|
await svc.unindex_journal_entry(entry_id)
|
||
|
|
|
||
|
|
plugin._require_store.delete_by_source.assert_awaited_once()
|