Files
FabledScribe/tests/test_mcp_tool_rulebooks.py
T
bvandeusenandClaude Opus 5 7985f8c7d7
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 38s
CI & Build / Python tests (push) Successful in 1m15s
CI & Build / Build & push image (push) Successful in 36s
fix(scribe): a delete must not depend on the lookup that names it (#3273)
CI caught the naming work reaching for Postgres from the unit lane, and the
connection error was the symptom of a real design fault rather than a test
gap: reading the title BEFORE the delete put a live query on the delete path,
so a lookup that failed would have stopped the delete happening.

That is a decoration breaking its payload — the same mistake just fixed in
rules_etag, made again two commits later. Every title lookup now fails open:
delete_task, delete_note, delete_milestone, delete_snippet and rule_history
lose the name, never the operation.

The five unit tests mock the lookup rather than reaching for a database, and
delete_note gains one asserting the delete still happens when the lookup
raises — the behaviour, not just the absence of a crash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:17:52 -04:00

564 lines
23 KiB
Python

"""Tests for MCP rulebook tools — patches the service layer."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from tests.helpers import FakeMCP, fake_rule, fake_rulebook, fake_topic
pytestmark = pytest.mark.usefixtures("_bind_user")
@pytest.mark.asyncio
async def test_list_rulebooks_wraps_in_dict():
rows = [fake_rulebook(id=1, title="t"), fake_rulebook(id=2, title="t")]
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_rulebooks",
AsyncMock(return_value=rows),
):
from scribe.mcp.tools.rulebooks import list_rulebooks
out = await list_rulebooks()
assert len(out["rulebooks"]) == 2
@pytest.mark.asyncio
async def test_get_rulebook_includes_topics():
rb = fake_rulebook(id=1, title="t")
topics = [fake_topic(id=10, title="git"), fake_topic(id=11, title="git")]
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rulebook",
AsyncMock(return_value=rb),
), patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_topics",
AsyncMock(return_value=topics),
):
from scribe.mcp.tools.rulebooks import get_rulebook
out = await get_rulebook(rulebook_id=1)
assert out["id"] == 1
assert len(out["topics"]) == 2
@pytest.mark.asyncio
async def test_get_rulebook_raises_when_not_found():
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rulebook",
AsyncMock(return_value=None),
):
from scribe.mcp.tools.rulebooks import get_rulebook
with pytest.raises(ValueError, match="rulebook 999 not found"):
await get_rulebook(rulebook_id=999)
def _plain_detail():
"""Stub the rule_detail seam these tool tests are not about.
create/update/get_rule now return through services.rulebooks.rule_detail,
which reads the rule's areas and edges from the database. These are unit
tests with no database, and what they assert is that the TOOL forwards the
right arguments — so the seam is stubbed to the plain record, the same way
they already stub the create/update calls themselves.
"""
async def _detail(_uid, rule, _system_ids=None):
return rule.to_dict()
return patch("scribe.mcp.tools.rulebooks.rulebooks_svc.rule_detail", _detail)
@pytest.mark.asyncio
async def test_create_rule_passes_required_fields():
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", mock), _plain_detail():
from scribe.mcp.tools.rulebooks import create_rule
await create_rule(
topic_id=10, title="dev is home", statement="Work directly on dev",
)
kwargs = mock.call_args.kwargs
assert kwargs["user_id"] == 7
assert kwargs["topic_id"] == 10
assert kwargs["statement"] == "Work directly on dev"
@pytest.mark.asyncio
async def test_create_rule_blocked_by_duplicate_gate():
from scribe.services.dedup import DuplicateMatch
dup = DuplicateMatch(id=47, title="dev is home", similarity=1.0, reason="title")
create_mock = AsyncMock()
with patch("scribe.mcp.tools.rulebooks.dedup_svc.find_duplicate_rule",
AsyncMock(return_value=dup)), \
patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", create_mock):
from scribe.mcp.tools.rulebooks import create_rule
out = await create_rule(topic_id=10, title="dev is home", statement="x")
assert out["duplicate"] is True
assert out["existing_id"] == 47
assert "update_rule" in out["message"]
create_mock.assert_not_called()
@pytest.mark.asyncio
async def test_create_rule_force_bypasses_duplicate_gate():
find_mock = AsyncMock()
with patch("scribe.mcp.tools.rulebooks.dedup_svc.find_duplicate_rule", find_mock), \
patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule",
AsyncMock(return_value=fake_rule(id=5, title="r", statement="s", topic_id=10))), \
_plain_detail():
from scribe.mcp.tools.rulebooks import create_rule
out = await create_rule(topic_id=10, title="dev is home", statement="x", force=True)
assert out["id"] == 5
find_mock.assert_not_called()
@pytest.mark.asyncio
async def test_update_rule_only_sends_non_default_fields():
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock), _plain_detail():
from scribe.mcp.tools.rulebooks import update_rule
await update_rule(rule_id=1, statement="new statement")
args, kwargs = mock.call_args
assert args == (1, 7)
# `clear` is always forwarded — an empty tuple is "clear nothing", which is
# a value, not an absent argument. Everything the caller left at its
# default stays out: that is the property this test pins.
assert kwargs == {"statement": "new statement", "clear": ()}
@pytest.mark.asyncio
async def test_update_rule_forwards_the_fields_named_for_clearing():
"""Naming a field is the only way to empty it through this door.
"" means "leave unchanged" here, so a caller has no value that means
"remove it" — which is what makes an explicit list necessary and what
stops a partial update from wiping the fields it did not mention.
"""
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock), _plain_detail():
from scribe.mcp.tools.rulebooks import update_rule
await update_rule(rule_id=1, clear_fields=["verify_with"])
_args, kwargs = mock.call_args
assert kwargs == {"clear": ["verify_with"]}
@pytest.mark.asyncio
async def test_update_rule_sends_the_check_fields_when_given():
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock), _plain_detail():
from scribe.mcp.tools.rulebooks import update_rule
await update_rule(
rule_id=1,
verify_with="cat CI-runner/renovate/config.js",
expires_when="approval is turned off",
)
_args, kwargs = mock.call_args
assert kwargs == {
"verify_with": "cat CI-runner/renovate/config.js",
"expires_when": "approval is turned off",
"clear": (),
}
@pytest.mark.asyncio
async def test_delete_rule_without_confirmed_returns_warning():
"""delete_rule with confirmed=False returns a preview, not an action."""
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=rule),
), patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.delete_rule",
AsyncMock(),
) as mock_delete:
from scribe.mcp.tools.rulebooks import delete_rule
out = await delete_rule(rule_id=1, confirmed=False)
assert out.get("confirmed_required") is True
assert "confirmed=True" in out.get("warning", "")
assert not mock_delete.called # service NOT called
@pytest.mark.asyncio
async def test_delete_rule_with_confirmed_soft_deletes():
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock_delete = AsyncMock(return_value="batch-1")
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=rule),
), patch(
"scribe.mcp.tools.rulebooks.trash_svc.delete",
mock_delete,
):
from scribe.mcp.tools.rulebooks import delete_rule
out = await delete_rule(rule_id=1, confirmed=True)
assert out["deleted"] == 1
assert out["deleted_batch_id"] == "batch-1"
assert mock_delete.called
@pytest.mark.asyncio
async def test_subscribe_project_to_rulebook_calls_service():
mock = AsyncMock()
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.subscribe_project", mock,
):
from scribe.mcp.tools.rulebooks import subscribe_project_to_rulebook
out = await subscribe_project_to_rulebook(project_id=3, rulebook_id=1)
assert out["subscribed"] is True
assert mock.called
@pytest.mark.asyncio
async def test_unsubscribe_project_from_rulebook_calls_service():
mock = AsyncMock()
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.unsubscribe_project", mock,
):
from scribe.mcp.tools.rulebooks import unsubscribe_project_from_rulebook
out = await unsubscribe_project_from_rulebook(project_id=3, rulebook_id=1)
assert out["subscribed"] is False
assert mock.called
def test_register_attaches_every_tool():
"""Every tool in the module reaches the server.
The count is the guard: a function added to the module but left out of
register()'s tuple is invisible to callers and raises nothing. The name
said "sixteen" for ten tools' worth of growth — the number lives in the
assertion, not the title, so it cannot drift again.
"""
from scribe.mcp.tools.rulebooks import register
mcp = FakeMCP()
register(mcp)
# 26 through milestone 307, +2 for the staleness sweep (milestone 312),
# +1 for a rule's edit history (milestone 323).
assert len(mcp.names) == 29
# spot-check a few names
assert "list_rulebooks" in mcp.names
assert "create_rule" in mcp.names
assert "subscribe_project_to_rulebook" in mcp.names
assert "list_always_on_rules" in mcp.names
# milestone 297: a project's opt-out of a whole always-on rulebook
assert "exclude_always_on_rulebook" in mcp.names
assert "include_always_on_rulebook" in mcp.names
assert "create_project_rule" in mcp.names
assert "suppress_rule_for_project" in mcp.names
# milestone 312: the sweep, and the stamp that answers it
assert "rules_due_for_verification" in mcp.names
assert "mark_rule_verified" in mcp.names
# milestone 323: what a rule used to say
assert "rule_history" in mcp.names
assert "unsuppress_rule_for_project" in mcp.names
assert "suppress_topic_for_project" in mcp.names
assert "unsuppress_topic_for_project" in mcp.names
@pytest.mark.asyncio
async def test_list_always_on_rules_returns_empty_when_no_always_on_rulebooks():
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_always_on_rules",
AsyncMock(return_value=[]),
):
from scribe.mcp.tools.rulebooks import list_always_on_rules
out = await list_always_on_rules()
# An install with no always-on rulebooks still gets a marker (milestone
# 323): "no rules" is a STATE, and a payload that omitted the key would
# make the write path read every session on a fresh install as a change.
assert out == {"rules": [], "total": 0, "rules_etag": "empty|0"}
@pytest.mark.asyncio
async def test_list_always_on_rules_projects_each_rule():
rules = [fake_rule(id=100, title="r", statement="s", topic_id=10), fake_rule(id=101, title="r", statement="s", topic_id=10)]
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_always_on_rules",
AsyncMock(return_value=rules),
):
from scribe.mcp.tools.rulebooks import list_always_on_rules
out = await list_always_on_rules()
assert out["total"] == 2
assert {r["id"] for r in out["rules"]} == {100, 101}
assert all("topic_id" in r for r in out["rules"])
@pytest.mark.asyncio
async def test_update_rulebook_forwards_always_on_when_set():
rb = fake_rulebook(id=1, title="t")
mock = AsyncMock(return_value=rb)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock):
from scribe.mcp.tools.rulebooks import update_rulebook
await update_rulebook(rulebook_id=1, always_on=True)
kwargs = mock.call_args.kwargs
assert kwargs.get("always_on") is True
assert "title" not in kwargs
assert "description" not in kwargs
@pytest.mark.asyncio
async def test_update_rulebook_omits_always_on_when_none():
rb = fake_rulebook(id=1, title="t")
mock = AsyncMock(return_value=rb)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock):
from scribe.mcp.tools.rulebooks import update_rulebook
await update_rulebook(rulebook_id=1, title="new title")
kwargs = mock.call_args.kwargs
assert "always_on" not in kwargs
assert kwargs["title"] == "new title"
@pytest.mark.asyncio
async def test_create_project_rule_passes_required_fields():
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail():
from scribe.mcp.tools.rulebooks import create_project_rule
await create_project_rule(
project_id=42,
statement="Always run migrations through alembic, not raw SQL.",
why="audit trail",
)
kwargs = mock.call_args.kwargs
assert kwargs["user_id"] == 7
assert kwargs["project_id"] == 42
assert kwargs["statement"].startswith("Always run migrations")
assert kwargs["why"] == "audit trail"
@pytest.mark.asyncio
async def test_create_project_rule_derives_title_from_statement():
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail():
from scribe.mcp.tools.rulebooks import create_project_rule
await create_project_rule(
project_id=42,
statement="Avoid auto-generated docstrings. Reviewers find them noise.",
)
kwargs = mock.call_args.kwargs
# Title should be derived from the first sentence, capped at 50 chars
assert kwargs["title"] == "Avoid auto-generated docstrings"
@pytest.mark.asyncio
async def test_create_project_rule_uses_explicit_title_when_given():
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail():
from scribe.mcp.tools.rulebooks import create_project_rule
await create_project_rule(
project_id=42,
statement="anything",
title="no auto-docstrings",
)
kwargs = mock.call_args.kwargs
assert kwargs["title"] == "no auto-docstrings"
@pytest.mark.asyncio
async def test_suppress_rule_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.suppress_rule_for_project", mock):
from scribe.mcp.tools.rulebooks import suppress_rule_for_project
out = await suppress_rule_for_project(project_id=3, rule_id=17)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "rule_id": 17, "user_id": 7}
assert out == {"project_id": 3, "rule_id": 17, "suppressed": True}
@pytest.mark.asyncio
async def test_unsuppress_rule_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.unsuppress_rule_for_project", mock):
from scribe.mcp.tools.rulebooks import unsuppress_rule_for_project
out = await unsuppress_rule_for_project(project_id=3, rule_id=17)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "rule_id": 17, "user_id": 7}
assert out == {"project_id": 3, "rule_id": 17, "suppressed": False}
@pytest.mark.asyncio
async def test_suppress_topic_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.suppress_topic_for_project", mock):
from scribe.mcp.tools.rulebooks import suppress_topic_for_project
out = await suppress_topic_for_project(project_id=3, topic_id=22)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "topic_id": 22, "user_id": 7}
assert out == {"project_id": 3, "topic_id": 22, "suppressed": True}
@pytest.mark.asyncio
async def test_unsuppress_topic_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.unsuppress_topic_for_project", mock):
from scribe.mcp.tools.rulebooks import unsuppress_topic_for_project
out = await unsuppress_topic_for_project(project_id=3, topic_id=22)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "topic_id": 22, "user_id": 7}
assert out == {"project_id": 3, "topic_id": 22, "suppressed": False}
# ── Typed edges between rules (milestone 307) ───────────────────────────
@pytest.mark.asyncio
async def test_relate_rules_forwards_the_kind_and_the_why():
"""The edge exists so a shape stops being merged into one row. The `note`
travels with it for the same reason a rule carries `why`: whoever later
decides whether the edge still holds needs the reasoning."""
relation = MagicMock()
relation.to_dict.return_value = {"id": 9, "kind": "co_surfaces"}
mock = AsyncMock(return_value=relation)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.add_rule_relation", mock):
from scribe.mcp.tools.rulebooks import relate_rules
out = await relate_rules(
from_rule_id=46, to_rule_id=144, kind="co_surfaces",
note="a stale channel tag and an unparseable version both read as "
"no update available",
)
assert out["id"] == 9
args = mock.call_args.args
assert args[0] == 7 and args[1] == 46 and args[2] == 144
assert args[3] == "co_surfaces"
assert "no update available" in args[4]
@pytest.mark.asyncio
async def test_relate_rules_raises_when_either_end_is_not_yours():
"""The service returns None when it cannot see both rules — a one-sided
edge would surface a rule the caller has no business reading."""
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.add_rule_relation",
AsyncMock(return_value=None)):
from scribe.mcp.tools.rulebooks import relate_rules
with pytest.raises(ValueError, match="not found"):
await relate_rules(from_rule_id=1, to_rule_id=2, kind="overrides")
@pytest.mark.asyncio
async def test_unrelate_rules_raises_when_the_edge_is_gone():
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.remove_rule_relation",
AsyncMock(return_value=False)):
from scribe.mcp.tools.rulebooks import unrelate_rules
with pytest.raises(ValueError, match="not found"):
await unrelate_rules(relation_id=99)
# ── rule_history (milestone 323 step 3) ────────────────────────────────
def _fake_version(**over):
"""A RuleVersion-shaped stand-in. A real model instance rather than a
MagicMock, because the tool calls `to_dict` and a mock would hand back
another mock instead of failing."""
from scribe.models.rule_version import RuleVersion
from datetime import datetime, timezone
defaults = {
"id": 5, "rule_id": 100, "user_id": 1,
"title": "The runner has no bash", "statement": "Use sh.",
"why": "the image ships no bash", "how_to_apply": None,
"when_to_apply": None, "tier": "always_on",
"verify_with": "read the workflow's shell setting",
"expires_when": None,
"created_at": datetime(2026, 8, 29, tzinfo=timezone.utc),
}
return RuleVersion(**{**defaults, **over})
@pytest.mark.asyncio
async def test_rule_history_lists_without_the_heavy_text():
"""The listing answers "when, and by whom". A rule's statement runs to
thousands of characters, so a history carrying every field would cost
more to read than the answer is worth."""
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_rule_versions",
AsyncMock(return_value=[_fake_version(), _fake_version(id=4)]),
), patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=fake_rule(
id=100, title="The runner has no bash", statement="s", topic_id=10,
)),
):
from scribe.mcp.tools.rulebooks import rule_history
out = await rule_history(rule_id=100)
assert out["total"] == 2
assert out["versions"][0]["title"] == "The runner has no bash"
assert "statement" not in out["versions"][0]
assert "why" not in out["versions"][0]
@pytest.mark.asyncio
async def test_rule_history_opens_one_version_in_full():
"""Passing a version id switches from the index to the text — which is
the whole reason the listing can afford to omit it."""
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule_version",
AsyncMock(return_value=_fake_version()),
):
from scribe.mcp.tools.rulebooks import rule_history
out = await rule_history(rule_id=100, version_id=5)
assert out["statement"] == "Use sh."
assert out["verify_with"] == "read the workflow's shell setting"
@pytest.mark.asyncio
async def test_rule_history_says_an_empty_history_is_ordinary():
"""Most rules have never been reworded, and nothing was written before
milestone 323. Without this line an empty list reads as a lost history or
a broken tool."""
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_rule_versions",
AsyncMock(return_value=[]),
), patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=fake_rule(
id=100, title="The runner has no bash", statement="s", topic_id=10,
)),
):
from scribe.mcp.tools.rulebooks import rule_history
out = await rule_history(rule_id=100)
assert out["total"] == 0
assert "never been reworded" in out["note"]
@pytest.mark.asyncio
async def test_rule_history_raises_when_the_rule_is_not_yours():
"""None from the service means "not readable", and the tool must not
turn that into an empty history — which would read as "this rule has no
past" rather than "this is not your rule"."""
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_rule_versions",
AsyncMock(return_value=None),
):
from scribe.mcp.tools.rulebooks import rule_history
with pytest.raises(ValueError, match="rule 100 not found"):
await rule_history(rule_id=100)
@pytest.mark.asyncio
async def test_rule_history_raises_for_a_version_on_another_rule():
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule_version",
AsyncMock(return_value=None),
):
from scribe.mcp.tools.rulebooks import rule_history
with pytest.raises(ValueError, match="version 5 not found"):
await rule_history(rule_id=100, version_id=5)
def test_rule_history_docstring_says_what_a_version_HOLDS():
"""The one thing a reader gets wrong unaided: an entry is the text the
edit REPLACED, not the text it introduced. Read the other way, every
diff comes out backwards — so the docstring has to say it, and this is
the guard against a later tidy-up dropping the line."""
from scribe.mcp.tools.rulebooks import rule_history
doc = rule_history.__doc__ or ""
assert "REPLACED" in doc
assert "no restore" in doc.lower(), (
"the docstring no longer explains that a rule version cannot be "
"restored. A caller who assumes a revert exists will look for one "
"and, not finding it, is likely to hand-copy the old text back with "
"no record of why."
)