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
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
106 lines
3.4 KiB
Python
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
|