85625de394
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 1m10s
Step 2 of the snippet-merge milestone (#231). The dedup gate only PREVENTS new near-duplicates; merge is the CURE for the ones already scattered. - services/snippets.py: merge_snippets(user_id, target_id, source_ids) — keep the target's scalar fields (name/when_to_use/signature/language/ code), union the sources' locations + extra tags onto it (so the survivor carries every call site as a location), trash the sources (recoverable), re-embed the survivor. Pure merge_snippet_fields() factored out for unit testing. Returns (survivor_note, merged_ids). - mcp/tools/snippets.py: merge_snippets(target_id, source_ids) tool (5th), and a create_snippet dedup-path nudge toward merge over a forced copy. - routes/snippets.py: POST /api/snippets/<id>/merge {source_ids} — share- aware (can_write target + every source, rule #78) with a same-owner guard (cross-owner merge is out of scope). - plugin reusing-code skill + MCP _INSTRUCTIONS: point found-duplicates at merge as the cure (rule #119 surfaces, not a Scribe rule). plugin.json 0.1.13 -> 0.1.14 in the same change (the #1040 marketplace-ship lesson). - Tests: pure merge-helper union/dedup; MCP tool (requires a source, survivor+merged_ids, not-found); route handler + 5-tool registration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t
133 lines
4.6 KiB
Python
133 lines
4.6 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_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",
|
|
"merge_snippets",
|
|
}
|