Files
FabledScribe/tests/test_mcp_tool_snippets.py
T
bvandeusen b33e2a79c6
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 1m7s
fix(snippets): close the recall-surface gaps found reviewing the Drafter
Four defects from the 2026-07-25 review of the recall (#227) and merge (#231)
milestones. The theme: a snippet could be recorded but not fully corrected, and
the agent and web surfaces had drifted apart.

- #2076 language was mis-derived from the first caller tag, so a snippet created
  with tags and no language read that tag back as its language — corrupting the
  tag set and the code fence on the next update. Only the FIRST tag can carry
  the language, since compose_tags emits [language, "snippet", *caller].
- #2077 MCP update_snippet mapped "" to "unchanged", so no field could ever be
  cleared and no snippet detached from its project. Now an omitted field is left
  alone, an empty string clears, and project_id follows the -1 = detach
  convention. A service-level UNSET sentinel keeps None available as the clear.
- #2078 surface parity: adds delete_snippet (MCP had none, so a wrong snippet
  could not be retired by the agent that recorded it), locations on MCP create
  and update, system_ids through the REST routes and the editor, and the
  near-duplicate gate on REST create with a "record it anyway" escape.
- #2079 project scoping: list_snippets takes project_id through the service, the
  MCP tool and the REST route, defaulting to every project — reaching across
  projects is the point when the helper you need was written elsewhere.

Sharing the list across owners is deliberately NOT in here: query_knowledge is
shared with the Knowledge browse surface, so widening it changes behaviour well
beyond snippets. Left open on #2079 for a scope decision.

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

200 lines
7.7 KiB
Python

"""Tests for MCP snippet tools — patches the service layer."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.mcp._context import _user_id_ctx
@pytest.fixture(autouse=True)
def _bind_user():
token = _user_id_ctx.set(7)
yield
_user_id_ctx.reset(token)
def _fake_snippet():
n = MagicMock()
n.id = 1
n.title = "debounce — rate-limit a callback"
n.body = "```js\nreturn 1\n```\n"
n.tags = ["js", "snippet"]
n.note_type = "snippet"
n.to_dict.return_value = {
"id": 1, "title": n.title, "note_type": "snippet", "tags": n.tags,
}
return n
@pytest.mark.asyncio
async def test_create_snippet_requires_name_and_code():
from scribe.mcp.tools.snippets import create_snippet
with pytest.raises(ValueError):
await create_snippet(name="", code="x")
with pytest.raises(ValueError):
await create_snippet(name="x", code=" ")
@pytest.mark.asyncio
async def test_create_snippet_records_and_returns_parsed():
created = _fake_snippet()
with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=None)), \
patch("scribe.services.snippets.create_snippet",
AsyncMock(return_value=created)) as mock_create:
from scribe.mcp.tools.snippets import create_snippet
out = await create_snippet(
name="debounce", code="return 1", language="js",
when_to_use="rate-limit a callback",
)
assert out["note_type"] == "snippet"
assert out["snippet"]["name"] == "debounce"
assert out["snippet"]["language"] == "js"
assert mock_create.await_args.kwargs["name"] == "debounce"
@pytest.mark.asyncio
async def test_create_snippet_dedup_blocks_and_labels_snippet():
dup = MagicMock(id=99)
with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=dup)), \
patch("scribe.services.dedup.duplicate_response",
MagicMock(return_value={"duplicate": True, "existing_id": 99})) as mock_resp, \
patch("scribe.services.snippets.create_snippet", AsyncMock()) as mock_create:
from scribe.mcp.tools.snippets import create_snippet
out = await create_snippet(name="debounce", code="return 1")
assert out["duplicate"] is True
mock_create.assert_not_awaited()
assert mock_resp.call_args.args[1] == "snippet"
@pytest.mark.asyncio
async def test_get_snippet_not_found_raises():
with patch("scribe.services.snippets.get_snippet", AsyncMock(return_value=None)):
from scribe.mcp.tools.snippets import get_snippet
with pytest.raises(ValueError):
await get_snippet(123)
@pytest.mark.asyncio
async def test_update_snippet_missing_raises():
with patch("scribe.services.snippets.update_snippet", AsyncMock(return_value=None)):
from scribe.mcp.tools.snippets import update_snippet
with pytest.raises(ValueError):
await update_snippet(123, name="x")
@pytest.mark.asyncio
async def test_update_snippet_empty_string_clears_a_field():
# An omitted field must stay None ("leave alone"), but an explicit empty
# string has to reach the service as "" so a stale field can be removed.
updated = _fake_snippet()
with patch("scribe.services.snippets.update_snippet",
AsyncMock(return_value=updated)) as mock_update:
from scribe.mcp.tools.snippets import update_snippet
await update_snippet(1, signature="")
kwargs = mock_update.await_args.kwargs
assert kwargs["signature"] == "" # cleared
assert kwargs["name"] is None # untouched
@pytest.mark.asyncio
async def test_update_snippet_project_id_conventions():
from scribe.services import snippets as snippets_svc
updated = _fake_snippet()
cases = {0: snippets_svc.UNSET, -1: None, 5: 5}
for given, expected in cases.items():
with patch("scribe.services.snippets.update_snippet",
AsyncMock(return_value=updated)) as mock_update:
from scribe.mcp.tools.snippets import update_snippet
await update_snippet(1, project_id=given)
assert mock_update.await_args.kwargs["project_id"] is expected
@pytest.mark.asyncio
async def test_create_and_update_pass_locations_through():
locs = [{"repo": "a", "path": "a.py", "symbol": "f"},
{"repo": "b", "path": "b.py", "symbol": "g"}]
created = _fake_snippet()
with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=None)), \
patch("scribe.services.snippets.create_snippet",
AsyncMock(return_value=created)) as mock_create:
from scribe.mcp.tools.snippets import create_snippet
await create_snippet(name="f", code="x", locations=locs)
assert mock_create.await_args.kwargs["locations"] == locs
with patch("scribe.services.snippets.update_snippet",
AsyncMock(return_value=created)) as mock_update:
from scribe.mcp.tools.snippets import update_snippet
await update_snippet(1, locations=locs)
assert mock_update.await_args.kwargs["locations"] == locs
@pytest.mark.asyncio
async def test_delete_snippet_retires_or_raises():
from scribe.mcp.tools.snippets import delete_snippet
with patch("scribe.services.snippets.delete_snippet", AsyncMock(return_value=True)):
assert await delete_snippet(1) == {"deleted": True, "id": 1}
with patch("scribe.services.snippets.delete_snippet", AsyncMock(return_value=False)):
with pytest.raises(ValueError):
await delete_snippet(404)
@pytest.mark.asyncio
async def test_list_snippets_defaults_to_every_project():
with patch("scribe.services.snippets.list_snippets",
AsyncMock(return_value=([], 0))) as mock_list:
from scribe.mcp.tools.snippets import list_snippets
await list_snippets(q="debounce")
assert mock_list.await_args.kwargs["project_id"] is None
await list_snippets(q="debounce", project_id=3)
assert mock_list.await_args.kwargs["project_id"] == 3
@pytest.mark.asyncio
async def test_merge_snippets_requires_a_source():
from scribe.mcp.tools.snippets import merge_snippets
with pytest.raises(ValueError):
await merge_snippets(target_id=1, source_ids=[])
with pytest.raises(ValueError):
await merge_snippets(target_id=1, source_ids=[1]) # only self → nothing to merge
@pytest.mark.asyncio
async def test_merge_snippets_returns_survivor_and_merged_ids():
survivor = _fake_snippet()
with patch("scribe.services.snippets.merge_snippets",
AsyncMock(return_value=(survivor, [2, 3]))) as mock_merge:
from scribe.mcp.tools.snippets import merge_snippets
out = await merge_snippets(target_id=1, source_ids=[2, 3, 1])
assert out["merged_ids"] == [2, 3]
assert out["snippet"]["name"] == "debounce"
# target_id passed through; self-id filtered out of the source list.
assert mock_merge.await_args.args[1] == 1
assert mock_merge.await_args.args[2] == [2, 3]
@pytest.mark.asyncio
async def test_merge_snippets_not_found_raises():
with patch("scribe.services.snippets.merge_snippets", AsyncMock(return_value=None)):
from scribe.mcp.tools.snippets import merge_snippets
with pytest.raises(ValueError):
await merge_snippets(target_id=1, source_ids=[2])
def test_register_attaches_all_tools():
from scribe.mcp.tools import snippets
names: list[str] = []
class FakeMcp:
def tool(self, name):
names.append(name)
def deco(fn):
return fn
return deco
snippets.register(FakeMcp())
assert set(names) == {
"list_snippets", "create_snippet", "get_snippet", "update_snippet",
"delete_snippet", "merge_snippets",
}