"""Tests for services.retrieval_telemetry. _build_payload is pure (no DB, no loop) and gets unit coverage. The persistence path (_insert_retrieval_log + the RetrievalLog model / JSONB roundtrip) is an integration test against real Postgres. """ from types import SimpleNamespace import pytest import pytest_asyncio from scribe.services.retrieval_telemetry import ( _build_payload, record_retrieval, ) def _note(nid): """Minimal stand-in — _build_payload only reads .id.""" return SimpleNamespace(id=nid) # ─── _build_payload (pure) ─────────────────────────────────────────────────── def test_build_payload_ranks_and_score_bounds(): results = [(0.91, _note(11)), (0.72, _note(22)), (0.55, _note(33))] p = _build_payload( user_id=7, source="mcp_search", query="hello", threshold=0.45, limit=10, project_id=3, is_task=None, results=results, duration_ms=12.345, ) assert p["result_count"] == 3 assert p["top_score"] == 0.91 assert p["min_score"] == 0.55 assert [it["rank"] for it in p["result_ids"]] == [0, 1, 2] assert [it["id"] for it in p["result_ids"]] == [11, 22, 33] assert p["duration_ms"] == 12.35 # rounded to 2dp assert p["user_id"] == 7 and p["project_id"] == 3 and p["threshold"] == 0.45 def test_build_payload_empty_results(): p = _build_payload( user_id=1, source="rest_search", query="x", threshold=0.3, limit=5, project_id=None, is_task=False, results=[], duration_ms=None, ) assert p["result_count"] == 0 assert p["top_score"] is None and p["min_score"] is None assert p["result_ids"] == [] assert p["duration_ms"] is None def test_build_payload_rounds_scores_to_5dp(): p = _build_payload( user_id=1, source="mcp_search", query="q", threshold=0.45, limit=1, project_id=None, is_task=None, results=[(0.123456789, _note(1))], duration_ms=0.0, ) assert p["result_ids"][0]["score"] == 0.12346 def test_record_retrieval_without_event_loop_is_safe(): """Called from a sync context (no running loop) it must swallow and return, never raise — telemetry can't be allowed to break a caller.""" # No event loop running in this plain sync test. assert record_retrieval( user_id=1, source="mcp_search", query="q", threshold=0.45, limit=10, project_id=None, is_task=None, results=[(0.9, _note(1))], ) is None # ─── persistence (integration) ─────────────────────────────────────────────── @pytest_asyncio.fixture async def _dispose_engine(): from scribe.models import engine yield await engine.dispose() @pytest.mark.integration @pytest.mark.asyncio async def test_insert_retrieval_log_roundtrip(_dispose_engine): from sqlalchemy import delete, select from scribe.models import async_session from scribe.models.retrieval_log import RetrievalLog from scribe.services.retrieval_telemetry import _insert_retrieval_log payload = _build_payload( user_id=990001, source="mcp_search", query="pgvector tuning", threshold=0.45, limit=10, project_id=None, is_task=None, results=[(0.88, _note(501)), (0.61, _note(502))], duration_ms=9.9, ) await _insert_retrieval_log(payload) async with async_session() as s: row = ( await s.execute( select(RetrievalLog).where(RetrievalLog.user_id == 990001) ) ).scalars().first() assert row is not None assert row.source == "mcp_search" assert row.result_count == 2 assert row.top_score == 0.88 # JSONB roundtrips as a list of dicts with the expected shape. assert row.result_ids[0] == {"id": 501, "score": 0.88, "rank": 0} assert row.created_at is not None # server_default now() await s.execute(delete(RetrievalLog).where(RetrievalLog.user_id == 990001)) await s.commit()