Files
FabledScribe/tests/test_mcp_tool_snippets.py
T
bvandeusen cca40affe4
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 25s
CI & Build / Python tests (push) Failing after 29s
CI & Build / Build & push image (push) Has been skipped
feat(snippets): add notes.data JSONB — the indexed mirror of snippet fields
Milestone #232 step 1 (task #2081). Takes the enabler first rather than the
write-path trigger: reverse lookup, drift checks and the duplicate finder all
need to QUERY structured fields, and building them on body-regex first means
writing them twice.

#227 deferred this bag "unless body-convention ergonomics prove insufficient" —
answering "which snippets live in this file?" by scanning every snippet and
regexing its body is that condition being met.

Migration 0070 adds `notes.data` (nullable JSONB) + a GIN index. The body is
UNCHANGED and still what gets embedded and read by humans; `data` mirrors the
same facts in a shape Postgres can index. Code is deliberately not copied into
it — the body holds it, and duplicating a blob into the column we index around
would be waste.

- compose_data() builds the mirror, omitting empties so the column stays sparse
- snippet_fields() prefers `data`, falling back to parsing the body. Rows written
  before 0070 have no `data` and are never backfilled, so a hand-edited body
  stays authoritative for them with no conversion deadline
- create / update / merge all write body and mirror from the same merged field
  set, so the two can't drift; merge in particular has to grow the mirror with
  the survivor's location set or a merged snippet would be unfindable at the very
  call sites the merge just recorded

Named `data`, not `metadata`, because that collides with SQLAlchemy's declarative
Base.metadata — which is why the pre-0069 model had to map an awkward
`entity_metadata` attribute. Not a revival of the column 0069 dropped: different
name, different purpose, nothing reads the old shape.

Two test fakes needed an explicit `data = None`: snippet_fields prefers `data`
when truthy and an auto-MagicMock attribute is truthy, so every parsed field
would have come back a MagicMock. Checked every fake reaching snippet code this
time rather than waiting for CI (note 2109).

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

233 lines
9.4 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(user_id: int = 7):
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"
# Real int, matching the bound caller by default. The tools compare it to
# decide whether to attach a shared/owner marker; an auto-MagicMock would read
# as another user's record and send them off to look up a username.
n.user_id = user_id
# Explicitly None, not an auto-attribute: snippet_fields prefers `data` when
# truthy, and a MagicMock is truthy — every parsed field would come back as a
# MagicMock instead of a string.
n.data = None
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_update_snippet_read_only_share_says_why():
"""A viewer grant must be refused with the REAL reason. "Not found" would be
a lie about a record the caller can plainly open, and would send an agent
hunting for a missing id instead of recording its own version."""
with patch("scribe.services.snippets.update_snippet",
AsyncMock(side_effect=PermissionError(
"snippet 1 is shared with you read-only — ask its owner for "
"edit access, or record your own version"))):
from scribe.mcp.tools.snippets import update_snippet
with pytest.raises(ValueError, match="read-only"):
await update_snippet(1, name="x")
@pytest.mark.asyncio
async def test_merge_snippets_read_only_target_says_why():
with patch("scribe.services.snippets.merge_snippets",
AsyncMock(side_effect=PermissionError(
"snippet 1 is shared with you read-only — you can't merge "
"into a record you can't edit"))):
from scribe.mcp.tools.snippets import merge_snippets
with pytest.raises(ValueError, match="read-only"):
await merge_snippets(target_id=1, source_ids=[2])
@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",
}