Files
FabledScribe/tests/test_mcp_tool_search.py
T
bvandeusen e9cd3435ad
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 1m4s
test(acl): give search/inject fixtures real owner ids; fail soft on name lookup
Run 2892: 3 pre-existing tests broke, 392 passed. Same shape as the last
breakage — a DB-touching call landed in a path unit tests exercise, and their
fixtures had auto-MagicMock user_id attributes that compare as "someone else's",
sending the code off to look up a username.

Fixed the fixtures rather than the assertion: _fake_note in the search tests and
_note in the plugin-context tests now take a real user_id defaulting to the
caller those tests bind. That makes "is this shared?" meaningful in both files
instead of accidental, which is what the new provenance behaviour actually needs
from them.

Separately, and not as cover for the above: owner_names_for now fails soft.
A lookup error yields no names and callers render "another user". The part that
matters — that the record is NOT the caller's — comes from comparing owner ids,
not from this query, so losing an attribution is cosmetic where failing the whole
search would not be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-25 23:10:12 -04:00

106 lines
3.4 KiB
Python

"""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,
user_id: int = 7) -> MagicMock:
note = MagicMock()
note.id = id
note.title = title
note.body = body
note.tags = tags or []
note.is_task = is_task
# A real int, matching the bound caller by default: results compare it to
# decide whether to attach a shared/owner marker, and an auto-MagicMock would
# read as "someone else's" and send the tool looking up a username.
note.user_id = user_id
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