fe63f3985b
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 40s
Closes the last of milestone #232. The task said to settle the design before coding; here is what was settled and why. THE HAZARD. Restoring a merged-in source from the trash brought the record back but never stripped its locations off the survivor, so both claimed the same call sites and the reverse lookup read the duplicate claims as real. Subtracting blindly is not a fix: a location can arrive from a source AND genuinely be the survivor's own, and _normalize_locations dedups them into one, so blind subtraction would strip a call site the survivor owns. Same problem defeated partial un-merge — `merged_from` recorded ids, not which locations came from which source. THE ANSWER. Record per-source attribution AT MERGE TIME, where it is known exactly: each entry keeps only what that source ADDED, computed incrementally as sources fold in. Anything the survivor already had, or an earlier source already brought, is attributed to nobody. Both open questions fall out of that one change — partial un-merge is exact, and a survivor-owned location can never be stripped, because it was never attributed in the first place. The shape moved from [id] to [{id, locations, tags}]. Free to do: the corpus holds one snippet and zero merges, so there is no legacy data (rule #22). A bare int still normalizes to {"id": n} — not legacy tolerance, but because snippet_fields falls back to PARSING THE BODY when a row has no `data`, and the body's provenance line can only carry ids. Such an entry shows history and refuses un-merge with a reason rather than guessing. WHICH SURFACE. Neither option in the task, quite. Making trash-restore notice the merge would teach the generic trash path snippet semantics for one record type. Instead un-merge OWNS the restore: one operation, one authorization check, trash stays ignorant. Restoring by hand is still allowed and still leaves both records claiming the same places — so un-merge treats an already-alive source as the normal case and goes straight to the subtraction that repairs it. That is the state that motivated the feature, not an error. Adds trash.restore_entity(user_id, type, id) — the missing inverse of delete(), which returns a batch id callers don't keep. Restores the whole batch, since the batch is the entity plus its cascade. Refs #2165 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
234 lines
9.4 KiB
Python
234 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", "verify_snippet",
|
|
"find_duplicate_snippets", "unmerge_snippet",
|
|
}
|