"""search tool — proves the tool pattern (context + service call + dict shape). Service call is mocked; no DB needed.""" from unittest.mock import AsyncMock, MagicMock, patch import pytest from scribe.mcp._context import _user_id_ctx from scribe.mcp.tools.search import search @pytest.fixture(autouse=True) def _reset_user_ctx(): """Each test starts with no MCP context. Tests set it explicitly.""" token = _user_id_ctx.set(None) yield _user_id_ctx.reset(token) def _fake_note(*, id: int, title: str, body: str = "", tags: list[str] | None = None, is_task: bool = False) -> MagicMock: note = MagicMock() note.id = id note.title = title note.body = body note.tags = tags or [] note.is_task = is_task return note @pytest.mark.asyncio async def test_fable_search_raises_without_context(): with pytest.raises(RuntimeError, match="no MCP user context"): await search(q="anything") @pytest.mark.asyncio async def test_fable_search_returns_repackaged_results(): _user_id_ctx.set(7) fake = _fake_note(id=1, title="kafka rebalance", body="HPA details", tags=["ops"], is_task=False) with patch( "scribe.mcp.tools.search.semantic_search_notes", AsyncMock(return_value=[(0.93, fake)]), ): out = await search(q="kafka") assert out["total"] == 1 assert len(out["results"]) == 1 r = out["results"][0] assert r["id"] == 1 assert r["title"] == "kafka rebalance" assert r["body"] == "HPA details" assert r["is_task"] is False assert r["tags"] == ["ops"] assert r["similarity"] == pytest.approx(0.93) @pytest.mark.asyncio async def test_fable_search_body_is_truncated_to_240_chars(): _user_id_ctx.set(7) long_body = "x" * 500 fake = _fake_note(id=1, title="t", body=long_body) with patch( "scribe.mcp.tools.search.semantic_search_notes", AsyncMock(return_value=[(0.5, fake)]), ): out = await search(q="x") assert len(out["results"][0]["body"]) == 240 @pytest.mark.asyncio async def test_fable_search_content_type_filters_at_service_layer(): """content_type maps to the is_task kwarg passed to the service.""" _user_id_ctx.set(7) mock_search = AsyncMock(return_value=[]) with patch("scribe.mcp.tools.search.semantic_search_notes", mock_search): await search(q="x", content_type="task") assert mock_search.call_args.kwargs["is_task"] is True mock_search.reset_mock() await search(q="x", content_type="note") assert mock_search.call_args.kwargs["is_task"] is False mock_search.reset_mock() await search(q="x", content_type="all") assert mock_search.call_args.kwargs["is_task"] is None @pytest.mark.asyncio async def test_fable_search_limit_is_clamped(): _user_id_ctx.set(7) mock_search = AsyncMock(return_value=[]) with patch("scribe.mcp.tools.search.semantic_search_notes", mock_search): await search(q="x", limit=999) assert mock_search.call_args.kwargs["limit"] == 50 mock_search.reset_mock() await search(q="x", limit=0) assert mock_search.call_args.kwargs["limit"] == 1