refactor(tests): per-model fakes, FakeMCP and session mocks come from tests/helpers (#2825, milestone 296 area 1, batch 2)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 36s
CI & Build / Build & push image (push) Skipped

Second pass over the tests/ ledger after bbee0d0. fake_record(**attrs) is the
one MagicMock-with-real-attributes builder (to_dict mirrors them; the
note-2109 hazard documented once); fake_note/fake_task/fake_snippet/
fake_project/fake_milestone/fake_system/fake_rulebook/fake_topic/fake_rule
carry each model's ordinary defaults on top of it, replacing 14 per-file
factories (two rulebook trios in tool-vs-service wordings, _fake_task, _fake_ms,
_fake_project, _plan_note, _fake_snippet, two _snippet adapters now one-liners
over fake_snippet). FakeMCP replaces the five closure-over-a-list registrar
fakes (+ _Recorder); loc() and design_token_stub() replace the paired _loc /
_token / _T stand-ins; every hand-built async_session mock (9 helper defs and
14 inline copies) now starts from make_mock_session(). Call sites rewritten by
AST with each file's former defaults made explicit, so behaviour is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 11:13:17 -04:00
co-authored by Claude Fable 5
parent bbee0d0db1
commit 77bb3729a3
32 changed files with 375 additions and 549 deletions
+128 -21
View File
@@ -6,6 +6,8 @@ them; a module imports what it needs with ``from tests.helpers import ...``.
""" """
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
@@ -48,30 +50,135 @@ async def ensure_user(session, username: str, role: str = "user"):
return user return user
def fake_note(**attrs) -> MagicMock: def fake_record(**attrs) -> MagicMock:
"""A MagicMock note with REAL values on every attribute the product reads """A MagicMock record with REAL values on the attributes named, and a
to label, scope, or render a record. ``to_dict()`` that mirrors them.
The hazard this exists for (note 2109): an auto-created MagicMock attribute The hazard this exists for (note 2109): an auto-created MagicMock attribute
is truthy and has a repr. The injected menu reads ``is_task`` / is truthy and has a repr — so a bare MagicMock handed to the product reads
``task_kind`` / ``note_type`` for its kind marker, ``user_id`` to decide as trashed, shared, a task, and owned by a MagicMock. Name every attribute
whether a line needs a "shared by …" attribution, ``data`` for a snippet's the code under test will read; the per-model ``fake_*`` builders below
language tag, and ``deleted_at`` to spot trash — on a bare MagicMock every carry the ordinary defaults so a call site states only what the test is
record renders as another user's trashed task with a mock repr for a about. ``created_at`` / ``updated_at`` are set as attributes but kept out
language. Defaults below are the ORDINARY state (own note, live, no of ``to_dict()`` (no test serialises them, and the real models isoformat
structured data); override what the test is about. them).
``to_dict()`` returns the same values as a plain dict, so a tool that
repackages ``note.to_dict()`` sees keys that agree with the attributes.
""" """
values = { n = MagicMock()
for key, value in attrs.items():
setattr(n, key, value)
n.to_dict.return_value = {
k: v for k, v in attrs.items() if k not in ("created_at", "updated_at")
}
return n
def _with_defaults(defaults: dict, attrs: dict) -> MagicMock:
values = dict(defaults)
values.update(attrs)
return fake_record(**values)
def _now():
return datetime.now(timezone.utc)
def fake_note(**attrs) -> MagicMock:
"""A stand-in Note: own (user_id=7, the caller `_bind_user` binds), live,
not a task, no structured data. The injected menu reads is_task /
task_kind / note_type for its kind marker, user_id for the "shared by …"
attribution, data for a snippet's language, deleted_at for trash."""
return _with_defaults({
"id": 1, "title": "t", "body": "", "tags": [], "user_id": 7, "id": 1, "title": "t", "body": "", "tags": [], "user_id": 7,
"note_type": "note", "is_task": False, "task_kind": "work", "note_type": "note", "is_task": False, "task_kind": "work",
"data": None, "deleted_at": None, "data": None, "deleted_at": None,
} }, attrs)
values.update(attrs)
n = MagicMock()
for key, value in values.items(): def fake_task(**attrs) -> MagicMock:
setattr(n, key, value) """A stand-in task note — get_task reads parent_id, deleted_at, user_id."""
n.to_dict.return_value = dict(values) return _with_defaults({
return n "id": 1, "title": "t", "body": "", "status": "todo", "priority": "none",
"tags": [], "parent_id": None, "project_id": None, "is_task": True,
"task_kind": "work", "user_id": 7, "deleted_at": None,
}, attrs)
def fake_snippet(**attrs) -> MagicMock:
"""A stand-in snippet note. ``data`` is explicitly None: snippet_fields
prefers `data` when truthy, and a MagicMock is truthy."""
return _with_defaults({
"id": 1, "title": "debounce — rate-limit a callback",
"body": "```js\nreturn 1\n```\n", "tags": ["js", "snippet"],
"note_type": "snippet", "is_task": False, "task_kind": "work",
"user_id": 7, "data": None, "deleted_at": None,
}, attrs)
def fake_project(**attrs) -> MagicMock:
"""design_system_id is explicit: a truthy auto-attribute would route every
project through the design-system branch and out to a real database."""
return _with_defaults({
"id": 1, "title": "P", "description": "", "goal": "", "status": "active",
"color": None, "design_system_id": None, "user_id": 7,
}, attrs)
def fake_milestone(**attrs) -> MagicMock:
return _with_defaults({
"id": 1, "project_id": 1, "title": "MS", "description": None,
"status": "active", "order_index": 0,
}, attrs)
def fake_system(**attrs) -> MagicMock:
return _with_defaults({"id": 1, "name": "Reader", "project_id": 5}, attrs)
def fake_rulebook(**attrs) -> MagicMock:
return _with_defaults({
"id": 1, "owner_user_id": 7, "title": "FabledSword family",
"description": "", "created_at": _now(), "updated_at": _now(),
}, attrs)
def fake_topic(**attrs) -> MagicMock:
return _with_defaults({
"id": 10, "rulebook_id": 1, "title": "git-workflow", "description": "",
"order_index": 0, "created_at": _now(), "updated_at": _now(),
}, attrs)
def fake_rule(**attrs) -> MagicMock:
return _with_defaults({
"id": 1, "topic_id": 10, "title": "dev is home",
"statement": "Work directly on dev", "why": "", "how_to_apply": "",
"order_index": 0, "created_at": _now(), "updated_at": _now(),
}, attrs)
class FakeMCP:
"""Stand-in for the FastMCP server a tool module's ``register(mcp)`` is
handed: records the ``name=`` of every ``@mcp.tool(...)`` registration in
``names`` and leaves the function untouched, so a test can assert which
tools a module exposes."""
def __init__(self) -> None:
self.names: list[str] = []
def tool(self, name=None):
self.names.append(name)
return lambda fn: fn
def loc(path: str = "", repo: str = "", symbol: str = "") -> dict:
"""One snippet location, in the shape the record stores."""
return {"repo": repo, "path": path, "symbol": symbol}
def design_token_stub(name, value_by_mode, group_name=None, purpose=None,
order_index=0, supersedes=None) -> SimpleNamespace:
"""A design-token row as the cascade / stylesheet code reads it."""
return SimpleNamespace(
name=name, value_by_mode=value_by_mode, group_name=group_name,
purpose=purpose, order_index=order_index, supersedes=supersedes or [],
)
+3 -6
View File
@@ -13,6 +13,7 @@ from scribe.services.api_keys import (
revoke_api_key, revoke_api_key,
lookup_key, lookup_key,
) )
from tests.helpers import make_mock_session
def test_generate_key_format(): def test_generate_key_format():
@@ -45,9 +46,7 @@ async def test_create_api_key_returns_full_key():
mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"} mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"}
with patch("scribe.services.api_keys.async_session") as mock_session_ctx: with patch("scribe.services.api_keys.async_session") as mock_session_ctx:
mock_session = AsyncMock() mock_session = make_mock_session()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
mock_session.add = MagicMock() mock_session.add = MagicMock()
mock_session.commit = AsyncMock() mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock(side_effect=lambda obj: None) mock_session.refresh = AsyncMock(side_effect=lambda obj: None)
@@ -67,9 +66,7 @@ async def test_create_api_key_returns_full_key():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_lookup_key_returns_none_for_unknown(): async def test_lookup_key_returns_none_for_unknown():
with patch("scribe.services.api_keys.async_session") as mock_session_ctx: with patch("scribe.services.api_keys.async_session") as mock_session_ctx:
mock_session = AsyncMock() mock_session = make_mock_session()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
mock_result = MagicMock() mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = None mock_result.scalars.return_value.first.return_value = None
mock_session.execute = AsyncMock(return_value=mock_result) mock_session.execute = AsyncMock(return_value=mock_result)
+36 -46
View File
@@ -7,6 +7,7 @@ own import-free module — see services/design_cascade.py.
from types import SimpleNamespace from types import SimpleNamespace
from scribe.services.design_cascade import ancestry, resolve_tokens, would_cycle from scribe.services.design_cascade import ancestry, resolve_tokens, would_cycle
from tests.helpers import design_token_stub
# --- ancestry --------------------------------------------------------------- # --- ancestry ---------------------------------------------------------------
@@ -102,14 +103,6 @@ def test_the_guard_survives_a_hierarchy_that_is_already_corrupt():
# because resolve_tokens is pure and duck-typed — which is the whole reason it # because resolve_tokens is pure and duck-typed — which is the whole reason it
# lives here rather than inside the service. # lives here rather than inside the service.
def _token(name, value_by_mode, group_name=None, purpose=None, order_index=0,
supersedes=None):
return SimpleNamespace(
name=name, value_by_mode=value_by_mode, group_name=group_name,
purpose=purpose, order_index=order_index, supersedes=supersedes or [],
)
# A family (1) and an app inheriting from it (2) — the shape the model exists for. # A family (1) and an app inheriting from it (2) — the shape the model exists for.
FAMILY, APP = 1, 2 FAMILY, APP = 1, 2
PARENTS = {FAMILY: None, APP: FAMILY} PARENTS = {FAMILY: None, APP: FAMILY}
@@ -124,7 +117,7 @@ def test_a_system_with_no_tokens_of_its_own_inherits_the_whole_family_set():
and the state every app system starts in.""" and the state every app system starts in."""
resolved = resolve_tokens( resolved = resolve_tokens(
APP, PARENTS, APP, PARENTS,
{FAMILY: [_token("--fs-obsidian", {"base": "#14171a"})], APP: []}, {FAMILY: [design_token_stub(name="--fs-obsidian", value_by_mode={"base": "#14171a"})], APP: []},
) )
assert [t.name for t in resolved] == ["--fs-obsidian"] assert [t.name for t in resolved] == ["--fs-obsidian"]
assert resolved[0].value_by_mode == {"base": "#14171a"} assert resolved[0].value_by_mode == {"base": "#14171a"}
@@ -138,8 +131,8 @@ def test_the_deepest_system_wins_and_says_what_it_overrode():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
APP, PARENTS, APP, PARENTS,
{ {
FAMILY: [_token("--fs-accent", {"base": "#6b2118"})], FAMILY: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#6b2118"})],
APP: [_token("--fs-accent", {"base": "#5b4a8a"})], APP: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#5b4a8a"})],
}, },
)) ))
accent = resolved["--fs-accent"] accent = resolved["--fs-accent"]
@@ -158,8 +151,8 @@ def test_overriding_one_mode_leaves_the_others_inherited():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
APP, PARENTS, APP, PARENTS,
{ {
FAMILY: [_token("--fs-accent", {"base": "#34a877", "dark": "#34a877"})], FAMILY: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#34a877", "dark": "#34a877"})],
APP: [_token("--fs-accent", {"base": "#15803d"})], APP: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#15803d"})],
}, },
)) ))
accent = resolved["--fs-accent"] accent = resolved["--fs-accent"]
@@ -172,7 +165,7 @@ def test_a_token_only_the_app_defines_is_not_an_override():
labelled both "overridden here" would misdescribe the first.""" labelled both "overridden here" would misdescribe the first."""
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
APP, PARENTS, APP, PARENTS,
{FAMILY: [], APP: [_token("--fs-editor-caret", {"base": "#5b4a8a"})]}, {FAMILY: [], APP: [design_token_stub(name="--fs-editor-caret", value_by_mode={"base": "#5b4a8a"})]},
)) ))
caret = resolved["--fs-editor-caret"] caret = resolved["--fs-editor-caret"]
assert caret.origin_by_mode == {"base": APP} assert caret.origin_by_mode == {"base": APP}
@@ -183,8 +176,8 @@ def test_is_overridden_in_is_true_only_for_the_system_that_shadowed():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
APP, PARENTS, APP, PARENTS,
{ {
FAMILY: [_token("--fs-accent", {"base": "#6b2118"})], FAMILY: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#6b2118"})],
APP: [_token("--fs-accent", {"base": "#5b4a8a"})], APP: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#5b4a8a"})],
}, },
)) ))
accent = resolved["--fs-accent"] accent = resolved["--fs-accent"]
@@ -198,9 +191,9 @@ def test_three_levels_stack_nearest_first():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
3, parents, 3, parents,
{ {
1: [_token("--fs-bg", {"base": "a"})], 1: [design_token_stub(name="--fs-bg", value_by_mode={"base": "a"})],
2: [_token("--fs-bg", {"base": "b"})], 2: [design_token_stub(name="--fs-bg", value_by_mode={"base": "b"})],
3: [_token("--fs-bg", {"base": "c"})], 3: [design_token_stub(name="--fs-bg", value_by_mode={"base": "c"})],
}, },
)) ))
bg = resolved["--fs-bg"] bg = resolved["--fs-bg"]
@@ -214,8 +207,8 @@ def test_resolving_the_family_itself_ignores_its_children():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
FAMILY, PARENTS, FAMILY, PARENTS,
{ {
FAMILY: [_token("--fs-accent", {"base": "#6b2118"})], FAMILY: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#6b2118"})],
APP: [_token("--fs-accent", {"base": "#5b4a8a"})], APP: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#5b4a8a"})],
}, },
)) ))
assert resolved["--fs-accent"].value_by_mode == {"base": "#6b2118"} assert resolved["--fs-accent"].value_by_mode == {"base": "#6b2118"}
@@ -226,7 +219,7 @@ def test_value_for_falls_back_to_the_base_mode():
"dark" must yield that rather than nothing — the read rule the storage shape "dark" must yield that rather than nothing — the read rule the storage shape
implies.""" implies."""
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
FAMILY, PARENTS, {FAMILY: [_token("--fs-radius-md", {"base": "8px"})]}, FAMILY, PARENTS, {FAMILY: [design_token_stub(name="--fs-radius-md", value_by_mode={"base": "8px"})]},
)) ))
radius = resolved["--fs-radius-md"] radius = resolved["--fs-radius-md"]
assert radius.value_for("dark") == "8px" assert radius.value_for("dark") == "8px"
@@ -236,7 +229,7 @@ def test_value_for_falls_back_to_the_base_mode():
def test_value_for_prefers_an_explicit_mode_over_the_fallback(): def test_value_for_prefers_an_explicit_mode_over_the_fallback():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
FAMILY, PARENTS, FAMILY, PARENTS,
{FAMILY: [_token("--fs-bg", {"base": "#f7f5ef", "dark": "#14171a"})]}, {FAMILY: [design_token_stub(name="--fs-bg", value_by_mode={"base": "#f7f5ef", "dark": "#14171a"})]},
)) ))
assert resolved["--fs-bg"].value_for("dark") == "#14171a" assert resolved["--fs-bg"].value_for("dark") == "#14171a"
@@ -248,11 +241,8 @@ def test_metadata_is_inherited_when_the_override_leaves_it_blank():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
APP, PARENTS, APP, PARENTS,
{ {
FAMILY: [_token( FAMILY: [design_token_stub(name="--fs-obsidian", value_by_mode={"base": "#14171a"}, group_name="surface", purpose="page bg, deepest surface")],
"--fs-obsidian", {"base": "#14171a"}, APP: [design_token_stub(name="--fs-obsidian", value_by_mode={"base": "#101317"})],
group_name="surface", purpose="page bg, deepest surface",
)],
APP: [_token("--fs-obsidian", {"base": "#101317"})],
}, },
)) ))
obsidian = resolved["--fs-obsidian"] obsidian = resolved["--fs-obsidian"]
@@ -265,8 +255,8 @@ def test_an_override_that_states_metadata_wins_it_too():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
APP, PARENTS, APP, PARENTS,
{ {
FAMILY: [_token("--fs-x", {"base": "a"}, purpose="family says")], FAMILY: [design_token_stub(name="--fs-x", value_by_mode={"base": "a"}, purpose="family says")],
APP: [_token("--fs-x", {"base": "b"}, purpose="app says")], APP: [design_token_stub(name="--fs-x", value_by_mode={"base": "b"}, purpose="app says")],
}, },
)) ))
assert resolved["--fs-x"].purpose == "app says" assert resolved["--fs-x"].purpose == "app says"
@@ -279,8 +269,8 @@ def test_an_override_at_default_order_keeps_the_familys_position():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
APP, PARENTS, APP, PARENTS,
{ {
FAMILY: [_token("--fs-x", {"base": "a"}, order_index=7)], FAMILY: [design_token_stub(name="--fs-x", value_by_mode={"base": "a"}, order_index=7)],
APP: [_token("--fs-x", {"base": "b"})], APP: [design_token_stub(name="--fs-x", value_by_mode={"base": "b"})],
}, },
)) ))
assert resolved["--fs-x"].order_index == 7 assert resolved["--fs-x"].order_index == 7
@@ -290,10 +280,10 @@ def test_the_effective_set_is_ordered_by_group_then_position_with_ungrouped_last
resolved = resolve_tokens( resolved = resolve_tokens(
FAMILY, PARENTS, FAMILY, PARENTS,
{FAMILY: [ {FAMILY: [
_token("--fs-z", {"base": "1"}), # ungrouped design_token_stub(name="--fs-z", value_by_mode={"base": "1"}), # ungrouped
_token("--fs-b", {"base": "2"}, group_name="text", order_index=1), design_token_stub(name="--fs-b", value_by_mode={"base": "2"}, group_name="text", order_index=1),
_token("--fs-a", {"base": "3"}, group_name="surface", order_index=2), design_token_stub(name="--fs-a", value_by_mode={"base": "3"}, group_name="surface", order_index=2),
_token("--fs-c", {"base": "4"}, group_name="surface", order_index=1), design_token_stub(name="--fs-c", value_by_mode={"base": "4"}, group_name="surface", order_index=1),
]}, ]},
) )
assert [t.name for t in resolved] == ["--fs-c", "--fs-a", "--fs-b", "--fs-z"] assert [t.name for t in resolved] == ["--fs-c", "--fs-a", "--fs-b", "--fs-z"]
@@ -306,7 +296,7 @@ def test_resolution_terminates_on_a_corrupt_hierarchy():
parents = {1: 2, 2: 1} parents = {1: 2, 2: 1}
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
1, parents, 1, parents,
{1: [_token("--fs-a", {"base": "one"})], 2: [_token("--fs-b", {"base": "two"})]}, {1: [design_token_stub(name="--fs-a", value_by_mode={"base": "one"})], 2: [design_token_stub(name="--fs-b", value_by_mode={"base": "two"})]},
)) ))
assert set(resolved) == {"--fs-a", "--fs-b"} assert set(resolved) == {"--fs-a", "--fs-b"}
# Each system contributes exactly once, not endlessly. # Each system contributes exactly once, not endlessly.
@@ -326,8 +316,8 @@ def test_supersedes_is_inherited_when_the_override_is_silent_about_it():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
APP, PARENTS, APP, PARENTS,
{ {
FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff", "#ffffff"])], FAMILY: [design_token_stub(name="--fs-text", value_by_mode={"base": "#e8e4d8"}, supersedes=["#fff", "#ffffff"])],
APP: [_token("--fs-text", {"base": "#f0ece0"})], APP: [design_token_stub(name="--fs-text", value_by_mode={"base": "#f0ece0"})],
}, },
)) ))
text = resolved["--fs-text"] text = resolved["--fs-text"]
@@ -341,8 +331,8 @@ def test_an_override_that_states_its_own_supersedes_replaces_the_list():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
APP, PARENTS, APP, PARENTS,
{ {
FAMILY: [_token("--fs-text", {"base": "a"}, supersedes=["#fff", "#ffffff"])], FAMILY: [design_token_stub(name="--fs-text", value_by_mode={"base": "a"}, supersedes=["#fff", "#ffffff"])],
APP: [_token("--fs-text", {"base": "b"}, supersedes=["#fff"])], APP: [design_token_stub(name="--fs-text", value_by_mode={"base": "b"}, supersedes=["#fff"])],
}, },
)) ))
assert resolved["--fs-text"].supersedes == ("#fff",) assert resolved["--fs-text"].supersedes == ("#fff",)
@@ -352,7 +342,7 @@ def test_a_token_that_supersedes_nothing_resolves_to_an_empty_tuple():
"""Most tokens replace nothing. That has to be an empty sequence rather than """Most tokens replace nothing. That has to be an empty sequence rather than
None, so no caller has to test for two kinds of nothing.""" None, so no caller has to test for two kinds of nothing."""
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
FAMILY, PARENTS, {FAMILY: [_token("--fs-radius-md", {"base": "8px"})]}, FAMILY, PARENTS, {FAMILY: [design_token_stub(name="--fs-radius-md", value_by_mode={"base": "8px"})]},
)) ))
assert resolved["--fs-radius-md"].supersedes == () assert resolved["--fs-radius-md"].supersedes == ()
@@ -360,7 +350,7 @@ def test_a_token_that_supersedes_nothing_resolves_to_an_empty_tuple():
def test_supersedes_survives_serialisation_as_a_list(): def test_supersedes_survives_serialisation_as_a_list():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
FAMILY, PARENTS, FAMILY, PARENTS,
{FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff"])]}, {FAMILY: [design_token_stub(name="--fs-text", value_by_mode={"base": "#e8e4d8"}, supersedes=["#fff"])]},
)) ))
assert resolved["--fs-text"].to_dict()["supersedes"] == ["#fff"] assert resolved["--fs-text"].to_dict()["supersedes"] == ["#fff"]
@@ -372,7 +362,7 @@ def test_the_superseded_literal_need_not_match_the_tokens_own_value():
it was turned around.""" it was turned around."""
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
FAMILY, PARENTS, FAMILY, PARENTS,
{FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff"])]}, {FAMILY: [design_token_stub(name="--fs-text", value_by_mode={"base": "#e8e4d8"}, supersedes=["#fff"])]},
)) ))
text = resolved["--fs-text"] text = resolved["--fs-text"]
assert text.value_by_mode["base"] not in text.supersedes assert text.value_by_mode["base"] not in text.supersedes
@@ -404,7 +394,7 @@ def test_rationale_cascades_like_purpose_and_is_a_different_question():
rationale="equals Moss, aligned by design", rationale="equals Moss, aligned by design",
order_index=0, supersedes=[], order_index=0, supersedes=[],
)], )],
APP: [_token("--fs-success", {"base": "#3f5236"})], APP: [design_token_stub(name="--fs-success", value_by_mode={"base": "#3f5236"})],
}, },
)) ))
token = resolved["--fs-success"] token = resolved["--fs-success"]
@@ -415,6 +405,6 @@ def test_rationale_cascades_like_purpose_and_is_a_different_question():
def test_a_token_without_a_rationale_resolves_to_none(): def test_a_token_without_a_rationale_resolves_to_none():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
FAMILY, PARENTS, {FAMILY: [_token("--fs-x", {"base": "1px"})]}, FAMILY, PARENTS, {FAMILY: [design_token_stub(name="--fs-x", value_by_mode={"base": "1px"})]},
)) ))
assert resolved["--fs-x"].rationale is None assert resolved["--fs-x"].rationale is None
+21 -28
View File
@@ -17,13 +17,7 @@ from scribe.services.design_stylesheet import (
safe_value, safe_value,
selector_for_mode, selector_for_mode,
) )
from tests.helpers import design_token_stub
def _token(name, value_by_mode, group_name=None, purpose=None):
return SimpleNamespace(
name=name, value_by_mode=value_by_mode,
group_name=group_name, purpose=purpose,
)
# --- safety ----------------------------------------------------------------- # --- safety -----------------------------------------------------------------
@@ -66,7 +60,7 @@ def test_a_rejected_value_is_dropped_not_cleaned_up():
"""Rejecting beats stripping. A partially-sanitised value is one the operator """Rejecting beats stripping. A partially-sanitised value is one the operator
never wrote, and the sheet's entire claim is that it IS the record — quietly never wrote, and the sheet's entire claim is that it IS the record — quietly
rendering a different colour would break that claim invisibly.""" rendering a different colour would break that claim invisibly."""
css = render_stylesheet([_token("--fs-x", {"base": "red; } body { color: blue"})]) css = render_stylesheet([design_token_stub(name="--fs-x", value_by_mode={"base": "red; } body { color: blue"})])
assert "body" not in css assert "body" not in css
assert "value rejected" in css assert "value rejected" in css
@@ -85,7 +79,7 @@ def test_a_malformed_token_name_is_dropped():
assert not is_valid_token_name("color: red") assert not is_valid_token_name("color: red")
assert not is_valid_token_name("fs-obsidian") # no leading -- assert not is_valid_token_name("fs-obsidian") # no leading --
css = render_stylesheet([_token("--bad name", {"base": "red"})]) css = render_stylesheet([design_token_stub(name="--bad name", value_by_mode={"base": "red"})])
assert "bad name" not in css assert "bad name" not in css
@@ -100,8 +94,8 @@ def test_the_sheet_declares_properties_and_styles_no_elements():
handful of values once per element and grow with the UI. Purpose tokens are handful of values once per element and grow with the UI. Purpose tokens are
stated once and reused; components are snippets that reference them.""" stated once and reused; components are snippets that reference them."""
css = render_stylesheet([ css = render_stylesheet([
_token("--fs-obsidian", {"base": "#14171a"}, group_name="surface"), design_token_stub(name="--fs-obsidian", value_by_mode={"base": "#14171a"}, group_name="surface"),
_token("--fs-moss", {"base": "#4a5d3f"}, group_name="action"), design_token_stub(name="--fs-moss", value_by_mode={"base": "#4a5d3f"}, group_name="action"),
]) ])
assert "--fs-obsidian: #14171a;" in css assert "--fs-obsidian: #14171a;" in css
# No element or class rules — the sheet has exactly one block here, and # No element or class rules — the sheet has exactly one block here, and
@@ -117,7 +111,7 @@ def test_the_sheet_declares_properties_and_styles_no_elements():
def test_the_header_says_what_the_sheet_is_for(): def test_the_header_says_what_the_sheet_is_for():
"""A generated file with no explanation gets hand-edited, and then it has """A generated file with no explanation gets hand-edited, and then it has
diverged from the record it claims to be.""" diverged from the record it claims to be."""
css = render_stylesheet([_token("--fs-x", {"base": "1px"})], title="FabledSword") css = render_stylesheet([design_token_stub(name="--fs-x", value_by_mode={"base": "1px"})], title="FabledSword")
assert "FabledSword" in css assert "FabledSword" in css
assert "Generated" in css assert "Generated" in css
assert "snippets" in css assert "snippets" in css
@@ -129,7 +123,7 @@ def test_base_goes_on_the_root_selector_and_other_modes_layer_over_it():
"""Matches the convention already in the codebase, and the one-way scoping """Matches the convention already in the codebase, and the one-way scoping
#251 recorded: light on `:root`, dark layered on an attribute selector.""" #251 recorded: light on `:root`, dark layered on an attribute selector."""
css = render_stylesheet([ css = render_stylesheet([
_token("--fs-bg", {"base": "#f5f1e8", "dark": "#14171a"}), design_token_stub(name="--fs-bg", value_by_mode={"base": "#f5f1e8", "dark": "#14171a"}),
]) ])
assert ":root {" in css assert ":root {" in css
assert '[data-theme="dark"] {' in css assert '[data-theme="dark"] {' in css
@@ -141,8 +135,8 @@ def test_a_mode_block_contains_only_what_that_mode_declares():
Repeating every token in every block would make the sheet claim each mode Repeating every token in every block would make the sheet claim each mode
redefines the whole system.""" redefines the whole system."""
css = render_stylesheet([ css = render_stylesheet([
_token("--fs-bg", {"base": "#f5f1e8", "dark": "#14171a"}), design_token_stub(name="--fs-bg", value_by_mode={"base": "#f5f1e8", "dark": "#14171a"}),
_token("--fs-radius-md", {"base": "8px"}), design_token_stub(name="--fs-radius-md", value_by_mode={"base": "8px"}),
]) ])
dark_block = css.split('[data-theme="dark"] {')[1] dark_block = css.split('[data-theme="dark"] {')[1]
assert "--fs-bg" in dark_block assert "--fs-bg" in dark_block
@@ -153,7 +147,7 @@ def test_the_root_selector_is_caller_chosen():
"""A container-scoped preview cannot use `:root`. A generator that hardcoded """A container-scoped preview cannot use `:root`. A generator that hardcoded
it could not serve the preview surface at all.""" it could not serve the preview surface at all."""
css = render_stylesheet( css = render_stylesheet(
[_token("--fs-x", {"base": "1px"})], root_selector="[data-preview]" [design_token_stub(name="--fs-x", value_by_mode={"base": "1px"})], root_selector="[data-preview]"
) )
assert "[data-preview] {" in css assert "[data-preview] {" in css
assert ":root {" not in css assert ":root {" not in css
@@ -163,8 +157,8 @@ def test_the_root_selector_is_caller_chosen():
def test_tokens_are_grouped_by_purpose_with_the_group_named(): def test_tokens_are_grouped_by_purpose_with_the_group_named():
css = render_stylesheet([ css = render_stylesheet([
_token("--fs-obsidian", {"base": "#14171a"}, group_name="surface"), design_token_stub(name="--fs-obsidian", value_by_mode={"base": "#14171a"}, group_name="surface"),
_token("--fs-radius-md", {"base": "8px"}, group_name="radius"), design_token_stub(name="--fs-radius-md", value_by_mode={"base": "8px"}, group_name="radius"),
]) ])
assert "/* surface */" in css assert "/* surface */" in css
assert "/* radius */" in css assert "/* radius */" in css
@@ -174,8 +168,7 @@ def test_a_purpose_becomes_an_inline_comment_on_the_base_layer_only():
"""Repeating the same prose in every mode block is noise: the token means """Repeating the same prose in every mode block is noise: the token means
the same thing in dark mode.""" the same thing in dark mode."""
css = render_stylesheet([ css = render_stylesheet([
_token("--fs-obsidian", {"base": "#14171a", "dark": "#000000"}, design_token_stub(name="--fs-obsidian", value_by_mode={"base": "#14171a", "dark": "#000000"}, purpose="page bg, deepest surface"),
purpose="page bg, deepest surface"),
]) ])
assert css.count("page bg, deepest surface") == 1 assert css.count("page bg, deepest surface") == 1
@@ -185,8 +178,8 @@ def test_a_declared_token_with_no_value_appears_as_a_comment_not_a_silence():
that finding where the reader is already looking; dropping it would make the that finding where the reader is already looking; dropping it would make the
sheet look complete.""" sheet look complete."""
css = render_stylesheet([ css = render_stylesheet([
_token("--fs-obsidian", {"base": "#14171a"}), design_token_stub(name="--fs-obsidian", value_by_mode={"base": "#14171a"}),
_token("--fs-radius-sm", {}), design_token_stub(name="--fs-radius-sm", value_by_mode={}),
]) ])
assert "--fs-radius-sm" in css assert "--fs-radius-sm" in css
assert "no value set yet" in css assert "no value set yet" in css
@@ -210,9 +203,9 @@ def test_two_tokens_sharing_a_value_are_reported_not_refused():
purpose — "Success = Moss, by design" — so this reports and lets a human purpose — "Success = Moss, by design" — so this reports and lets a human
decide which it is.""" decide which it is."""
dupes = duplicate_values([ dupes = duplicate_values([
_token("--fs-moss", {"base": "#4A5D3F"}), design_token_stub(name="--fs-moss", value_by_mode={"base": "#4A5D3F"}),
_token("--fs-success", {"base": "#4a5d3f"}), design_token_stub(name="--fs-success", value_by_mode={"base": "#4a5d3f"}),
_token("--fs-obsidian", {"base": "#14171a"}), design_token_stub(name="--fs-obsidian", value_by_mode={"base": "#14171a"}),
]) ])
assert dupes == {"#4a5d3f": ["--fs-moss", "--fs-success"]} assert dupes == {"#4a5d3f": ["--fs-moss", "--fs-success"]}
@@ -221,15 +214,15 @@ def test_tokens_that_agree_in_one_mode_but_differ_in_another_are_not_duplicates(
"""A near-miss is a different, weaker finding, and calling it a duplicate """A near-miss is a different, weaker finding, and calling it a duplicate
would send someone to merge two tokens that genuinely diverge.""" would send someone to merge two tokens that genuinely diverge."""
assert duplicate_values([ assert duplicate_values([
_token("--fs-a", {"base": "#fff", "dark": "#000"}), design_token_stub(name="--fs-a", value_by_mode={"base": "#fff", "dark": "#000"}),
_token("--fs-b", {"base": "#fff", "dark": "#111"}), design_token_stub(name="--fs-b", value_by_mode={"base": "#fff", "dark": "#111"}),
]) == {"#fff": ["--fs-a", "--fs-b"]} ]) == {"#fff": ["--fs-a", "--fs-b"]}
def test_valueless_tokens_never_count_as_duplicates_of_each_other(): def test_valueless_tokens_never_count_as_duplicates_of_each_other():
"""Otherwise every unfilled token would collide with every other one and the """Otherwise every unfilled token would collide with every other one and the
report would be nothing but noise on a fresh import.""" report would be nothing but noise on a fresh import."""
assert duplicate_values([_token("--fs-a", {}), _token("--fs-b", {})]) == {} assert duplicate_values([design_token_stub(name="--fs-a", value_by_mode={}), design_token_stub(name="--fs-b", value_by_mode={})]) == {}
# --- reading the sheet from the other side ---------------------------------- # --- reading the sheet from the other side ----------------------------------
+5 -8
View File
@@ -31,14 +31,11 @@ from scribe.services.snippets import (
list_snippets, list_snippets,
snippet_fields, snippet_fields,
) )
from tests.helpers import loc
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
def _loc(repo="", path="", symbol=""):
return {"repo": repo, "path": path, "symbol": symbol}
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def seeded(): async def seeded():
"""A user with four snippets covering the cases the filter has to separate. """A user with four snippets covering the cases the filter has to separate.
@@ -60,13 +57,13 @@ async def seeded():
data=compose_data(name=name, language="python", locations=locations), data=compose_data(name=name, language="python", locations=locations),
) )
nested = _snippet("nested", [_loc("Scribe", "frontend/src/lib/x.ts", "helper")]) nested = _snippet("nested", [loc(repo="Scribe", path="frontend/src/lib/x.ts", symbol="helper")])
sibling = _snippet("sibling", [_loc("Scribe", "frontend/srcmap.ts", "other")]) sibling = _snippet("sibling", [loc(repo="Scribe", path="frontend/srcmap.ts", symbol="other")])
# Two locations, deliberately crossed: repo Scribe at src/a.py and repo # Two locations, deliberately crossed: repo Scribe at src/a.py and repo
# Portal at src/b.py. repo=Scribe + path=src/b.py must NOT match it. # Portal at src/b.py. repo=Scribe + path=src/b.py must NOT match it.
multi = _snippet( multi = _snippet(
"multi", "multi",
[_loc("Scribe", "src/a.py", "alpha"), _loc("Portal", "src/b.py", "beta")], [loc(repo="Scribe", path="src/a.py", symbol="alpha"), loc(repo="Portal", path="src/b.py", symbol="beta")],
) )
# No structured location at all — must never satisfy a location filter, # No structured location at all — must never satisfy a location filter,
# and must not error the query either. # and must not error the query either.
@@ -242,7 +239,7 @@ async def test_backfill_makes_a_pre_0070_snippet_findable_by_location(seeded):
defaults to none_as_null=False) — a different state, covered by the next test. defaults to none_as_null=False) — a different state, covered by the next test.
""" """
user_id, _ids = seeded user_id, _ids = seeded
locations = [_loc("Legacy", "old/path/y.py", "legacy_helper")] locations = [loc(repo="Legacy", path="old/path/y.py", symbol="legacy_helper")]
async with async_session() as s: async with async_session() as s:
old = Note( old = Note(
user_id=user_id, user_id=user_id,
+12 -20
View File
@@ -5,26 +5,23 @@ calling convention meets the service's: an agent cannot omit an argument, so
"leave unchanged", "clear" and "set" have to be encoded in the value. Getting "leave unchanged", "clear" and "set" have to be encoded in the value. Getting
that mapping wrong is silent — the call succeeds and changes the wrong thing. that mapping wrong is silent — the call succeeds and changes the wrong thing.
""" """
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from scribe.services.design_systems import DesignSystemCycle from scribe.services.design_systems import DesignSystemCycle
from tests.helpers import design_token_stub, fake_record
pytestmark = pytest.mark.usefixtures("_bind_user") pytestmark = pytest.mark.usefixtures("_bind_user")
def _fake_system(): def _fake_design_system():
s = MagicMock() return fake_record(id=1, title="FabledSword", parent_id=None)
s.to_dict.return_value = {"id": 1, "title": "FabledSword", "parent_id": None}
return s
def _fake_token(): def _fake_token():
t = MagicMock() return fake_record(id=9, name="--fs-obsidian")
t.to_dict.return_value = {"id": 9, "name": "--fs-obsidian"}
return t
# --- create ----------------------------------------------------------------- # --- create -----------------------------------------------------------------
@@ -35,7 +32,7 @@ async def test_creating_without_a_parent_passes_none_not_zero():
system id — there is no system 0, so the create would fail an ACL check for system id — there is no system 0, so the create would fail an ACL check for
a record that cannot exist.""" a record that cannot exist."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.create_design_system = AsyncMock(return_value=_fake_system()) svc.create_design_system = AsyncMock(return_value=_fake_design_system())
from scribe.mcp.tools.design_systems import create_design_system from scribe.mcp.tools.design_systems import create_design_system
await create_design_system(title="FabledSword") await create_design_system(title="FabledSword")
assert svc.create_design_system.await_args.kwargs["parent_id"] is None assert svc.create_design_system.await_args.kwargs["parent_id"] is None
@@ -44,7 +41,7 @@ async def test_creating_without_a_parent_passes_none_not_zero():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_creating_with_a_parent_passes_it_through(): async def test_creating_with_a_parent_passes_it_through():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.create_design_system = AsyncMock(return_value=_fake_system()) svc.create_design_system = AsyncMock(return_value=_fake_design_system())
from scribe.mcp.tools.design_systems import create_design_system from scribe.mcp.tools.design_systems import create_design_system
await create_design_system(title="Scribe", parent_id=4) await create_design_system(title="Scribe", parent_id=4)
assert svc.create_design_system.await_args.kwargs["parent_id"] == 4 assert svc.create_design_system.await_args.kwargs["parent_id"] == 4
@@ -65,7 +62,7 @@ async def test_create_raises_when_the_parent_is_not_writable():
async def test_update_with_parent_id_zero_leaves_the_parent_alone(): async def test_update_with_parent_id_zero_leaves_the_parent_alone():
"""The common case — renaming a system must not silently re-root it.""" """The common case — renaming a system must not silently re-root it."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(return_value=_fake_system()) svc.update_design_system = AsyncMock(return_value=_fake_design_system())
from scribe.mcp.tools.design_systems import update_design_system from scribe.mcp.tools.design_systems import update_design_system
await update_design_system(design_system_id=1, title="Renamed") await update_design_system(design_system_id=1, title="Renamed")
fields = svc.update_design_system.await_args.kwargs fields = svc.update_design_system.await_args.kwargs
@@ -79,7 +76,7 @@ async def test_update_with_parent_id_minus_one_clears_it():
None, which is the value the service reads as "become a root" — where None, which is the value the service reads as "become a root" — where
omitting the key means "leave alone".""" omitting the key means "leave alone"."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(return_value=_fake_system()) svc.update_design_system = AsyncMock(return_value=_fake_design_system())
from scribe.mcp.tools.design_systems import update_design_system from scribe.mcp.tools.design_systems import update_design_system
await update_design_system(design_system_id=1, parent_id=-1) await update_design_system(design_system_id=1, parent_id=-1)
assert svc.update_design_system.await_args.kwargs["parent_id"] is None assert svc.update_design_system.await_args.kwargs["parent_id"] is None
@@ -88,7 +85,7 @@ async def test_update_with_parent_id_minus_one_clears_it():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_with_a_positive_parent_id_sets_it(): async def test_update_with_a_positive_parent_id_sets_it():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(return_value=_fake_system()) svc.update_design_system = AsyncMock(return_value=_fake_design_system())
from scribe.mcp.tools.design_systems import update_design_system from scribe.mcp.tools.design_systems import update_design_system
await update_design_system(design_system_id=1, parent_id=4) await update_design_system(design_system_id=1, parent_id=4)
assert svc.update_design_system.await_args.kwargs["parent_id"] == 4 assert svc.update_design_system.await_args.kwargs["parent_id"] == 4
@@ -161,16 +158,11 @@ async def test_resolve_returns_serialised_tokens_with_their_provenance():
resolution was built to preserve.""" resolution was built to preserve."""
from scribe.services.design_cascade import resolve_tokens from scribe.services.design_cascade import resolve_tokens
class _T:
def __init__(self, name, value_by_mode):
self.name, self.value_by_mode = name, value_by_mode
self.group_name = self.purpose = None
self.order_index = 0
resolved = resolve_tokens( resolved = resolve_tokens(
2, {1: None, 2: 1}, 2, {1: None, 2: 1},
{1: [_T("--fs-accent", {"base": "#6b2118"})], {1: [design_token_stub("--fs-accent", {"base": "#6b2118"})],
2: [_T("--fs-accent", {"base": "#5b4a8a"})]}, 2: [design_token_stub("--fs-accent", {"base": "#5b4a8a"})]},
) )
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.resolve_design_system = AsyncMock(return_value=resolved) svc.resolve_design_system = AsyncMock(return_value=resolved)
+10 -18
View File
@@ -6,20 +6,12 @@ import pytest
from scribe.mcp.tools.milestones import ( from scribe.mcp.tools.milestones import (
list_milestones, get_milestone, create_milestone, update_milestone, list_milestones, get_milestone, create_milestone, update_milestone,
) )
from tests.helpers import fake_milestone
pytestmark = pytest.mark.usefixtures("_bind_user") pytestmark = pytest.mark.usefixtures("_bind_user")
def _fake_ms(**overrides) -> MagicMock:
m = MagicMock()
base = {"id": 1, "project_id": 1, "title": "MS", "description": None,
"status": "active", "order_index": 0}
base.update(overrides)
m.to_dict.return_value = base
return m
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_milestones_returns_dict_with_progress(): async def test_list_milestones_returns_dict_with_progress():
rows = [{"id": 1, "title": "MS1", "status": "active", "task_count": 2}] rows = [{"id": 1, "title": "MS1", "status": "active", "task_count": 2}]
@@ -33,7 +25,7 @@ async def test_list_milestones_returns_dict_with_progress():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_milestone_passes_through(): async def test_create_milestone_passes_through():
m = _fake_ms(id=5) m = fake_milestone(id=5)
mock = AsyncMock(return_value=m) mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock): with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock):
out = await create_milestone(project_id=1, title="new", description="d") out = await create_milestone(project_id=1, title="new", description="d")
@@ -45,7 +37,7 @@ async def test_create_milestone_passes_through():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_milestone_empty_description_becomes_none(): async def test_create_milestone_empty_description_becomes_none():
m = _fake_ms() m = fake_milestone()
mock = AsyncMock(return_value=m) mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock): with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock):
await create_milestone(project_id=1, title="t", description="") await create_milestone(project_id=1, title="t", description="")
@@ -55,7 +47,7 @@ async def test_create_milestone_empty_description_becomes_none():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_milestone_passes_body_through(): async def test_create_milestone_passes_body_through():
"""The milestone-as-plan body is forwarded to the service.""" """The milestone-as-plan body is forwarded to the service."""
m = _fake_ms(id=5) m = fake_milestone(id=5)
mock = AsyncMock(return_value=m) mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock): with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock):
await create_milestone(project_id=1, title="t", body="## Goal\n\nship") await create_milestone(project_id=1, title="t", body="## Goal\n\nship")
@@ -64,7 +56,7 @@ async def test_create_milestone_passes_body_through():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_milestone_empty_body_becomes_none(): async def test_create_milestone_empty_body_becomes_none():
m = _fake_ms() m = fake_milestone()
mock = AsyncMock(return_value=m) mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock): with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock):
await create_milestone(project_id=1, title="t", body="") await create_milestone(project_id=1, title="t", body="")
@@ -73,7 +65,7 @@ async def test_create_milestone_empty_body_becomes_none():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_milestone_sends_body(): async def test_update_milestone_sends_body():
m = _fake_ms() m = fake_milestone()
mock = AsyncMock(return_value=m) mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock): with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock):
await update_milestone(project_id=1, milestone_id=5, body="new plan") await update_milestone(project_id=1, milestone_id=5, body="new plan")
@@ -82,7 +74,7 @@ async def test_update_milestone_sends_body():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_milestone_returns_body_steps_and_rules(): async def test_get_milestone_returns_body_steps_and_rules():
m = _fake_ms(id=5, project_id=3, body="## Goal") m = fake_milestone(id=5, project_id=3, body="## Goal")
step = MagicMock() step = MagicMock()
step.to_dict.return_value = {"id": 9, "title": "step 1", "status": "todo"} step.to_dict.return_value = {"id": 9, "title": "step 1", "status": "todo"}
applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False, applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False,
@@ -112,7 +104,7 @@ async def test_get_milestone_raises_when_not_found():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_milestone_only_sends_non_default_fields(): async def test_update_milestone_only_sends_non_default_fields():
m = _fake_ms() m = fake_milestone()
mock = AsyncMock(return_value=m) mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock): with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock):
await update_milestone(project_id=1, milestone_id=5, status="done") await update_milestone(project_id=1, milestone_id=5, status="done")
@@ -124,7 +116,7 @@ async def test_update_milestone_only_sends_non_default_fields():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_milestone_order_index_negative_is_omitted(): async def test_update_milestone_order_index_negative_is_omitted():
"""order_index=-1 sentinel means leave unchanged.""" """order_index=-1 sentinel means leave unchanged."""
m = _fake_ms() m = fake_milestone()
mock = AsyncMock(return_value=m) mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock): with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock):
await update_milestone(project_id=1, milestone_id=5, order_index=-1) await update_milestone(project_id=1, milestone_id=5, order_index=-1)
@@ -134,7 +126,7 @@ async def test_update_milestone_order_index_negative_is_omitted():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_milestone_order_index_zero_is_explicit(): async def test_update_milestone_order_index_zero_is_explicit():
"""order_index=0 is a real value (top of list), not a sentinel.""" """order_index=0 is a real value (top of list), not a sentinel."""
m = _fake_ms() m = fake_milestone()
mock = AsyncMock(return_value=m) mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock): with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock):
await update_milestone(project_id=1, milestone_id=5, order_index=0) await update_milestone(project_id=1, milestone_id=5, order_index=0)
+4 -16
View File
@@ -1,6 +1,7 @@
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from tests.helpers import fake_task
pytestmark = pytest.mark.usefixtures("_bind_user") pytestmark = pytest.mark.usefixtures("_bind_user")
@@ -18,25 +19,12 @@ async def test_start_planning_tool_delegates_to_service():
assert mock.call_args.kwargs == {"user_id": 7, "project_id": 3, "title": "Plan it"} assert mock.call_args.kwargs == {"user_id": 7, "project_id": 3, "title": "Plan it"}
def _plan_note(task_kind: str):
note = MagicMock()
note.parent_id = None
note.project_id = 3
note.id = 9
# Real values — get_task reads deleted_at and compares user_id to the bound
# caller, and a MagicMock is truthy on both (note 2109).
note.user_id = 7
note.deleted_at = None
note.to_dict.return_value = {"id": 9, "task_kind": task_kind, "project_id": 3}
return note
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_task_augments_plan_with_rules(): async def test_get_task_augments_plan_with_rules():
applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False, applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False,
"subscribed_rulebooks": [{"id": 2, "title": "rb"}]} "subscribed_rulebooks": [{"id": 2, "title": "rb"}]}
with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user", with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user",
AsyncMock(return_value=(_plan_note("plan"), "owner"))), \ AsyncMock(return_value=(fake_task(task_kind="plan", id=9, project_id=3), "owner"))), \
patch("scribe.mcp.tools.tasks.rulebooks_svc.get_applicable_rules", patch("scribe.mcp.tools.tasks.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value=applicable)): AsyncMock(return_value=applicable)):
from scribe.mcp.tools.tasks import get_task from scribe.mcp.tools.tasks import get_task
@@ -49,7 +37,7 @@ async def test_get_task_augments_plan_with_rules():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_task_work_kind_has_no_rules(): async def test_get_task_work_kind_has_no_rules():
with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user", with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user",
AsyncMock(return_value=(_plan_note("work"), "owner"))), \ AsyncMock(return_value=(fake_task(task_kind="work", id=9, project_id=3), "owner"))), \
patch("scribe.mcp.tools.tasks.rulebooks_svc.get_applicable_rules", patch("scribe.mcp.tools.tasks.rulebooks_svc.get_applicable_rules",
AsyncMock()) as mock_rules: AsyncMock()) as mock_rules:
from scribe.mcp.tools.tasks import get_task from scribe.mcp.tools.tasks import get_task
+4 -11
View File
@@ -2,7 +2,7 @@
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from tests.helpers import fake_note from tests.helpers import FakeMCP, fake_note
pytestmark = pytest.mark.usefixtures("_bind_user") pytestmark = pytest.mark.usefixtures("_bind_user")
@@ -172,18 +172,11 @@ def test_register_attaches_every_tool_in_the_module():
import inspect import inspect
from scribe.mcp.tools import processes from scribe.mcp.tools import processes
names: list[str] = [] mcp = FakeMCP()
class FakeMcp: processes.register(mcp)
def tool(self, name):
names.append(name)
def deco(fn):
return fn
return deco
processes.register(FakeMcp())
public = { public = {
name for name, obj in vars(processes).items() name for name, obj in vars(processes).items()
if inspect.iscoroutinefunction(obj) and not name.startswith("_") if inspect.iscoroutinefunction(obj) and not name.startswith("_")
} }
assert set(names) == public assert set(mcp.names) == public
+15 -34
View File
@@ -7,6 +7,7 @@ from scribe.mcp.tools.projects import (
list_projects, get_project, create_project, list_projects, get_project, create_project,
update_project, enter_project, update_project, enter_project,
) )
from tests.helpers import FakeMCP, fake_project
pytestmark = pytest.mark.usefixtures("_bind_user") pytestmark = pytest.mark.usefixtures("_bind_user")
@@ -56,22 +57,9 @@ def _no_bootstrap():
yield mock yield mock
def _fake_project(design_system_id=None, **overrides) -> MagicMock:
p = MagicMock()
base = {"id": 1, "title": "P", "description": "", "goal": "",
"status": "active", "color": None}
base.update(overrides)
p.to_dict.return_value = base
# Explicit, because a bare MagicMock hands back a truthy auto-attribute —
# which would route every project in this file through the design-system
# branch and out to a real database.
p.design_system_id = design_system_id
return p
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_projects_wraps_in_dict(): async def test_list_projects_wraps_in_dict():
rows = [_fake_project(id=1), _fake_project(id=2)] rows = [fake_project(id=1), fake_project(id=2)]
with patch( with patch(
"scribe.mcp.tools.projects.projects_svc.list_projects", "scribe.mcp.tools.projects.projects_svc.list_projects",
AsyncMock(return_value=rows), AsyncMock(return_value=rows),
@@ -82,7 +70,7 @@ async def test_list_projects_wraps_in_dict():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_project_enriches_with_milestone_summary(): async def test_get_project_enriches_with_milestone_summary():
p = _fake_project(id=5, title="found") p = fake_project(id=5, title="found")
milestone_summary = [{"id": 10, "title": "MS", "task_count": 3}] milestone_summary = [{"id": 10, "title": "MS", "task_count": 3}]
applicable_payload = { applicable_payload = {
"rules": [], "truncated": False, "subscribed_rulebooks": [], "rules": [], "truncated": False, "subscribed_rulebooks": [],
@@ -107,7 +95,7 @@ async def test_get_project_includes_applicable_rules_and_subscribed_rulebooks():
"""The augmented get_project response includes applicable_rules and """The augmented get_project response includes applicable_rules and
subscribed_rulebooks pulled from services/rulebooks.get_applicable_rules. subscribed_rulebooks pulled from services/rulebooks.get_applicable_rules.
""" """
p = _fake_project(id=3, title="Fabled Assistant") p = fake_project(id=3, title="Fabled Assistant")
milestone_summary = [] milestone_summary = []
applicable_payload = { applicable_payload = {
"rules": [ "rules": [
@@ -147,7 +135,7 @@ async def test_get_project_raises_when_not_found():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_project_passes_color_empty_as_none(): async def test_create_project_passes_color_empty_as_none():
p = _fake_project() p = fake_project()
mock = AsyncMock(return_value=p) mock = AsyncMock(return_value=p)
with patch("scribe.mcp.tools.projects.projects_svc.create_project", mock): with patch("scribe.mcp.tools.projects.projects_svc.create_project", mock):
await create_project(title="P", color="") await create_project(title="P", color="")
@@ -156,7 +144,7 @@ async def test_create_project_passes_color_empty_as_none():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_project_only_sends_non_default_fields(): async def test_update_project_only_sends_non_default_fields():
p = _fake_project() p = fake_project()
mock = AsyncMock(return_value=p) mock = AsyncMock(return_value=p)
with patch("scribe.mcp.tools.projects.projects_svc.update_project", mock): with patch("scribe.mcp.tools.projects.projects_svc.update_project", mock):
await update_project(project_id=1, status="archived") await update_project(project_id=1, status="archived")
@@ -179,7 +167,7 @@ async def test_update_project_raises_when_not_found():
async def test_enter_project_composes_full_context(): async def test_enter_project_composes_full_context():
"""enter_project pulls project + rules + milestone summary + open tasks + """enter_project pulls project + rules + milestone summary + open tasks +
recent notes in one composed call.""" recent notes in one composed call."""
p = _fake_project(id=5, title="P") p = fake_project(id=5, title="P")
applicable_payload = { applicable_payload = {
"rules": [{"id": 1, "title": "r1", "statement": "s", "rules": [{"id": 1, "title": "r1", "statement": "s",
"topic_title": "t", "rulebook_title": "rb"}], "topic_title": "t", "rulebook_title": "rb"}],
@@ -239,7 +227,7 @@ async def test_enter_project_surfaces_the_systems_vocabulary():
days after the feature landed — one System, nothing tagged since July 28 days after the feature landed — one System, nothing tagged since July 28
(#2546's audit). Trimmed to id/name/first-line: it rides on every session (#2546's audit). Trimmed to id/name/first-line: it rides on every session
start, and the full charter is get_system's job.""" start, and the full charter is get_system's job."""
p = _fake_project(id=5) p = fake_project(id=5)
sys1 = MagicMock() sys1 = MagicMock()
sys1.id = 3 sys1.id = 3
sys1.name = "retrieval" sys1.name = "retrieval"
@@ -294,7 +282,7 @@ async def test_enter_project_carries_the_bootstrap_ask_when_it_fires():
ask = "This project has 282 records and NO Systems modelled — ..." ask = "This project has 282 records and NO Systems modelled — ..."
with contextlib.ExitStack() as stack: with contextlib.ExitStack() as stack:
for cm in _enter_project_stubs(_fake_project(id=5)): for cm in _enter_project_stubs(fake_project(id=5)):
stack.enter_context(cm) stack.enter_context(cm)
stack.enter_context(patch( stack.enter_context(patch(
"scribe.mcp.tools.projects.systems_tools.bootstrap_systems_ask", "scribe.mcp.tools.projects.systems_tools.bootstrap_systems_ask",
@@ -315,7 +303,7 @@ async def test_enter_project_never_asks_bootstrap_once_a_vocabulary_exists(
sys1 = MagicMock() sys1 = MagicMock()
sys1.id = 3; sys1.name = "retrieval"; sys1.description = "" sys1.id = 3; sys1.name = "retrieval"; sys1.description = ""
with contextlib.ExitStack() as stack: with contextlib.ExitStack() as stack:
for cm in _enter_project_stubs(_fake_project(id=5)): for cm in _enter_project_stubs(fake_project(id=5)):
stack.enter_context(cm) stack.enter_context(cm)
stack.enter_context(patch( stack.enter_context(patch(
"scribe.mcp.tools.projects.systems_svc.list_systems", "scribe.mcp.tools.projects.systems_svc.list_systems",
@@ -336,7 +324,7 @@ async def test_enter_project_fires_the_coverage_seed_on_the_owner(
enter stays fast.""" enter stays fast."""
import contextlib import contextlib
project = _fake_project(id=5) project = fake_project(id=5)
project.user_id = 42 # explicit: the OWNER, not the caller (ctx uid=7) project.user_id = 42 # explicit: the OWNER, not the caller (ctx uid=7)
with contextlib.ExitStack() as stack: with contextlib.ExitStack() as stack:
for cm in _enter_project_stubs(project): for cm in _enter_project_stubs(project):
@@ -357,7 +345,7 @@ async def test_enter_project_hands_back_the_design_system_when_the_project_has_o
system binds the same way a rule does. Before this it was reachable only by system binds the same way a rule does. Before this it was reachable only by
an agent that already knew to call resolve_design_system — so the standards an agent that already knew to call resolve_design_system — so the standards
were present in the store and absent from the work.""" were present in the store and absent from the work."""
p = _fake_project(id=5, design_system_id=9) p = fake_project(id=5, design_system_id=9)
design = {"id": 9, "title": "App kit", "guidance": [{"title": "House"}], design = {"id": 9, "title": "App kit", "guidance": [{"title": "House"}],
"token_count": 95, "token_groups": ["surface"], "token_count": 95, "token_groups": ["surface"],
"inherits_from": ["House"], "description": ""} "inherits_from": ["House"], "description": ""}
@@ -399,14 +387,7 @@ async def test_enter_project_raises_when_project_not_found():
def test_enter_project_registered_in_register(): def test_enter_project_registered_in_register():
"""register(mcp) registers enter_project alongside the existing tools.""" """register(mcp) registers enter_project alongside the existing tools."""
from scribe.mcp.tools.projects import register from scribe.mcp.tools.projects import register
registered: list[str] = [] mcp = FakeMCP()
class FakeMCP: register(mcp)
def tool(self, name=None): assert "enter_project" in mcp.names
def decorator(fn):
registered.append(name)
return fn
return decorator
register(FakeMCP())
assert "enter_project" in registered
+28 -60
View File
@@ -1,41 +1,16 @@
"""Tests for MCP rulebook tools — patches the service layer.""" """Tests for MCP rulebook tools — patches the service layer."""
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from tests.helpers import FakeMCP, fake_rule, fake_rulebook, fake_topic
pytestmark = pytest.mark.usefixtures("_bind_user") pytestmark = pytest.mark.usefixtures("_bind_user")
def _fake_rulebook(id=1, title="t"):
rb = MagicMock()
rb.id = id
rb.title = title
rb.to_dict.return_value = {"id": id, "title": title}
return rb
def _fake_topic(id=10, title="git"):
t = MagicMock()
t.id = id
t.title = title
t.to_dict.return_value = {"id": id, "title": title}
return t
def _fake_rule(id=100, title="r", statement="s"):
r = MagicMock()
r.id = id
r.title = title
r.topic_id = 10
r.statement = statement
r.to_dict.return_value = {"id": id, "title": title, "statement": statement}
return r
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_rulebooks_wraps_in_dict(): async def test_list_rulebooks_wraps_in_dict():
rows = [_fake_rulebook(id=1), _fake_rulebook(id=2)] rows = [fake_rulebook(id=1, title="t"), fake_rulebook(id=2, title="t")]
with patch( with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_rulebooks", "scribe.mcp.tools.rulebooks.rulebooks_svc.list_rulebooks",
AsyncMock(return_value=rows), AsyncMock(return_value=rows),
@@ -47,8 +22,8 @@ async def test_list_rulebooks_wraps_in_dict():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_rulebook_includes_topics(): async def test_get_rulebook_includes_topics():
rb = _fake_rulebook(id=1) rb = fake_rulebook(id=1, title="t")
topics = [_fake_topic(id=10), _fake_topic(id=11)] topics = [fake_topic(id=10, title="git"), fake_topic(id=11, title="git")]
with patch( with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rulebook", "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rulebook",
AsyncMock(return_value=rb), AsyncMock(return_value=rb),
@@ -75,7 +50,7 @@ async def test_get_rulebook_raises_when_not_found():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_rule_passes_required_fields(): async def test_create_rule_passes_required_fields():
rule = _fake_rule() rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule) mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", mock): with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", mock):
from scribe.mcp.tools.rulebooks import create_rule from scribe.mcp.tools.rulebooks import create_rule
@@ -109,7 +84,7 @@ async def test_create_rule_force_bypasses_duplicate_gate():
find_mock = AsyncMock() find_mock = AsyncMock()
with patch("scribe.mcp.tools.rulebooks.dedup_svc.find_duplicate_rule", find_mock), \ with patch("scribe.mcp.tools.rulebooks.dedup_svc.find_duplicate_rule", find_mock), \
patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule",
AsyncMock(return_value=_fake_rule(id=5))): AsyncMock(return_value=fake_rule(id=5, title="r", statement="s", topic_id=10))):
from scribe.mcp.tools.rulebooks import create_rule from scribe.mcp.tools.rulebooks import create_rule
out = await create_rule(topic_id=10, title="dev is home", statement="x", force=True) out = await create_rule(topic_id=10, title="dev is home", statement="x", force=True)
assert out["id"] == 5 assert out["id"] == 5
@@ -118,7 +93,7 @@ async def test_create_rule_force_bypasses_duplicate_gate():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_rule_only_sends_non_default_fields(): async def test_update_rule_only_sends_non_default_fields():
rule = _fake_rule() rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule) mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock): with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock):
from scribe.mcp.tools.rulebooks import update_rule from scribe.mcp.tools.rulebooks import update_rule
@@ -131,7 +106,7 @@ async def test_update_rule_only_sends_non_default_fields():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_rule_without_confirmed_returns_warning(): async def test_delete_rule_without_confirmed_returns_warning():
"""delete_rule with confirmed=False returns a preview, not an action.""" """delete_rule with confirmed=False returns a preview, not an action."""
rule = _fake_rule() rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
with patch( with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule", "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=rule), AsyncMock(return_value=rule),
@@ -148,7 +123,7 @@ async def test_delete_rule_without_confirmed_returns_warning():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_rule_with_confirmed_soft_deletes(): async def test_delete_rule_with_confirmed_soft_deletes():
rule = _fake_rule() rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock_delete = AsyncMock(return_value="batch-1") mock_delete = AsyncMock(return_value="batch-1")
with patch( with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule", "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
@@ -191,27 +166,20 @@ async def test_unsubscribe_project_from_rulebook_calls_service():
def test_register_attaches_all_sixteen_tools(): def test_register_attaches_all_sixteen_tools():
"""register(mcp) should call mcp.tool(name=...) for all 16 tools.""" """register(mcp) should call mcp.tool(name=...) for all 16 tools."""
from scribe.mcp.tools.rulebooks import register from scribe.mcp.tools.rulebooks import register
registered: list[str] = [] mcp = FakeMCP()
class FakeMCP: register(mcp)
def tool(self, name=None): assert len(mcp.names) == 22
def decorator(fn):
registered.append(name)
return fn
return decorator
register(FakeMCP())
assert len(registered) == 22
# spot-check a few names # spot-check a few names
assert "list_rulebooks" in registered assert "list_rulebooks" in mcp.names
assert "create_rule" in registered assert "create_rule" in mcp.names
assert "subscribe_project_to_rulebook" in registered assert "subscribe_project_to_rulebook" in mcp.names
assert "list_always_on_rules" in registered assert "list_always_on_rules" in mcp.names
assert "create_project_rule" in registered assert "create_project_rule" in mcp.names
assert "suppress_rule_for_project" in registered assert "suppress_rule_for_project" in mcp.names
assert "unsuppress_rule_for_project" in registered assert "unsuppress_rule_for_project" in mcp.names
assert "suppress_topic_for_project" in registered assert "suppress_topic_for_project" in mcp.names
assert "unsuppress_topic_for_project" in registered assert "unsuppress_topic_for_project" in mcp.names
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -227,7 +195,7 @@ async def test_list_always_on_rules_returns_empty_when_no_always_on_rulebooks():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_always_on_rules_projects_each_rule(): async def test_list_always_on_rules_projects_each_rule():
rules = [_fake_rule(id=100), _fake_rule(id=101)] 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( with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_always_on_rules", "scribe.mcp.tools.rulebooks.rulebooks_svc.list_always_on_rules",
AsyncMock(return_value=rules), AsyncMock(return_value=rules),
@@ -241,7 +209,7 @@ async def test_list_always_on_rules_projects_each_rule():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_rulebook_forwards_always_on_when_set(): async def test_update_rulebook_forwards_always_on_when_set():
rb = _fake_rulebook(id=1, title="t") rb = fake_rulebook(id=1, title="t")
mock = AsyncMock(return_value=rb) mock = AsyncMock(return_value=rb)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock): with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock):
from scribe.mcp.tools.rulebooks import update_rulebook from scribe.mcp.tools.rulebooks import update_rulebook
@@ -254,7 +222,7 @@ async def test_update_rulebook_forwards_always_on_when_set():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_rulebook_omits_always_on_when_none(): async def test_update_rulebook_omits_always_on_when_none():
rb = _fake_rulebook(id=1, title="t") rb = fake_rulebook(id=1, title="t")
mock = AsyncMock(return_value=rb) mock = AsyncMock(return_value=rb)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock): with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock):
from scribe.mcp.tools.rulebooks import update_rulebook from scribe.mcp.tools.rulebooks import update_rulebook
@@ -266,7 +234,7 @@ async def test_update_rulebook_omits_always_on_when_none():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_project_rule_passes_required_fields(): async def test_create_project_rule_passes_required_fields():
rule = _fake_rule() rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule) mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock): with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock):
from scribe.mcp.tools.rulebooks import create_project_rule from scribe.mcp.tools.rulebooks import create_project_rule
@@ -284,7 +252,7 @@ async def test_create_project_rule_passes_required_fields():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_project_rule_derives_title_from_statement(): async def test_create_project_rule_derives_title_from_statement():
rule = _fake_rule() rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule) mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock): with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock):
from scribe.mcp.tools.rulebooks import create_project_rule from scribe.mcp.tools.rulebooks import create_project_rule
@@ -299,7 +267,7 @@ async def test_create_project_rule_derives_title_from_statement():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_project_rule_uses_explicit_title_when_given(): async def test_create_project_rule_uses_explicit_title_when_given():
rule = _fake_rule() rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule) mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock): with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock):
from scribe.mcp.tools.rulebooks import create_project_rule from scribe.mcp.tools.rulebooks import create_project_rule
+9 -37
View File
@@ -2,32 +2,12 @@
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from tests.helpers import FakeMCP, fake_snippet
pytestmark = pytest.mark.usefixtures("_bind_user") pytestmark = pytest.mark.usefixtures("_bind_user")
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 @pytest.mark.asyncio
async def test_create_snippet_requires_name_and_code(): async def test_create_snippet_requires_name_and_code():
from scribe.mcp.tools.snippets import create_snippet from scribe.mcp.tools.snippets import create_snippet
@@ -39,7 +19,7 @@ async def test_create_snippet_requires_name_and_code():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_snippet_records_and_returns_parsed(): async def test_create_snippet_records_and_returns_parsed():
created = _fake_snippet() created = fake_snippet()
with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=None)), \ with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=None)), \
patch("scribe.services.snippets.create_snippet", patch("scribe.services.snippets.create_snippet",
AsyncMock(return_value=created)) as mock_create: AsyncMock(return_value=created)) as mock_create:
@@ -88,7 +68,7 @@ async def test_update_snippet_missing_raises():
async def test_update_snippet_empty_string_clears_a_field(): async def test_update_snippet_empty_string_clears_a_field():
# An omitted field must stay None ("leave alone"), but an explicit empty # 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. # string has to reach the service as "" so a stale field can be removed.
updated = _fake_snippet() updated = fake_snippet()
with patch("scribe.services.snippets.update_snippet", with patch("scribe.services.snippets.update_snippet",
AsyncMock(return_value=updated)) as mock_update: AsyncMock(return_value=updated)) as mock_update:
from scribe.mcp.tools.snippets import update_snippet from scribe.mcp.tools.snippets import update_snippet
@@ -101,7 +81,7 @@ async def test_update_snippet_empty_string_clears_a_field():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_snippet_project_id_conventions(): async def test_update_snippet_project_id_conventions():
from scribe.services import snippets as snippets_svc from scribe.services import snippets as snippets_svc
updated = _fake_snippet() updated = fake_snippet()
cases = {0: snippets_svc.UNSET, -1: None, 5: 5} cases = {0: snippets_svc.UNSET, -1: None, 5: 5}
for given, expected in cases.items(): for given, expected in cases.items():
with patch("scribe.services.snippets.update_snippet", with patch("scribe.services.snippets.update_snippet",
@@ -115,7 +95,7 @@ async def test_update_snippet_project_id_conventions():
async def test_create_and_update_pass_locations_through(): async def test_create_and_update_pass_locations_through():
locs = [{"repo": "a", "path": "a.py", "symbol": "f"}, locs = [{"repo": "a", "path": "a.py", "symbol": "f"},
{"repo": "b", "path": "b.py", "symbol": "g"}] {"repo": "b", "path": "b.py", "symbol": "g"}]
created = _fake_snippet() created = fake_snippet()
with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=None)), \ with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=None)), \
patch("scribe.services.snippets.create_snippet", patch("scribe.services.snippets.create_snippet",
AsyncMock(return_value=created)) as mock_create: AsyncMock(return_value=created)) as mock_create:
@@ -187,7 +167,7 @@ async def test_merge_snippets_requires_a_source():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_merge_snippets_returns_survivor_and_merged_ids(): async def test_merge_snippets_returns_survivor_and_merged_ids():
survivor = _fake_snippet() survivor = fake_snippet()
with patch("scribe.services.snippets.merge_snippets", with patch("scribe.services.snippets.merge_snippets",
AsyncMock(return_value=(survivor, [2, 3]))) as mock_merge: AsyncMock(return_value=(survivor, [2, 3]))) as mock_merge:
from scribe.mcp.tools.snippets import merge_snippets from scribe.mcp.tools.snippets import merge_snippets
@@ -209,18 +189,10 @@ async def test_merge_snippets_not_found_raises():
def test_register_attaches_all_tools(): def test_register_attaches_all_tools():
from scribe.mcp.tools import snippets from scribe.mcp.tools import snippets
names: list[str] = [] mcp = FakeMCP()
class FakeMcp: snippets.register(mcp)
def tool(self, name): assert set(mcp.names) == {
names.append(name)
def deco(fn):
return fn
return deco
snippets.register(FakeMcp())
assert set(names) == {
"list_snippets", "create_snippet", "get_snippet", "update_snippet", "list_snippets", "create_snippet", "get_snippet", "update_snippet",
"delete_snippet", "merge_snippets", "verify_snippet", "delete_snippet", "merge_snippets", "verify_snippet",
"find_duplicate_snippets", "unmerge_snippet", "find_duplicate_snippets", "unmerge_snippet",
+6 -13
View File
@@ -2,21 +2,14 @@
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from tests.helpers import fake_note from tests.helpers import fake_note, fake_system
def _fake_system(sid=1, name="Reader", project_id=5):
s = MagicMock()
s.to_dict.return_value = {"id": sid, "name": name, "project_id": project_id}
s.project_id = project_id
return s
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_system_returns_dict(): async def test_create_system_returns_dict():
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \ with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc: patch("scribe.mcp.tools.systems.systems_svc") as svc:
svc.create_system = AsyncMock(return_value=_fake_system(name="Reader")) svc.create_system = AsyncMock(return_value=fake_system(name="Reader"))
from scribe.mcp.tools.systems import create_system from scribe.mcp.tools.systems import create_system
result = await create_system(project_id=5, name="Reader", description="pdf reader") result = await create_system(project_id=5, name="Reader", description="pdf reader")
assert result["name"] == "Reader" assert result["name"] == "Reader"
@@ -39,7 +32,7 @@ async def test_get_system_splits_records_by_kind():
note = MagicMock(); note.to_dict.return_value = {"id": 12}; note.task_kind = "work"; note.status = None note = MagicMock(); note.to_dict.return_value = {"id": 12}; note.task_kind = "work"; note.status = None
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \ with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc: patch("scribe.mcp.tools.systems.systems_svc") as svc:
svc.get_system = AsyncMock(return_value=_fake_system(sid=3)) svc.get_system = AsyncMock(return_value=fake_system(id=3))
svc.list_records_for_system = AsyncMock(return_value=[issue, work, note]) svc.list_records_for_system = AsyncMock(return_value=[issue, work, note])
from scribe.mcp.tools.systems import get_system from scribe.mcp.tools.systems import get_system
result = await get_system(system_id=3) result = await get_system(system_id=3)
@@ -164,7 +157,7 @@ async def test_populated_vocabulary_never_counts_records():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_system_same_normalized_name_is_duplicate_gated(): async def test_create_system_same_normalized_name_is_duplicate_gated():
existing = _fake_system(sid=7, name="Scrape Pipeline") existing = fake_system(id=7, name="Scrape Pipeline")
existing.id = 7 existing.id = 7
existing.name = "Scrape Pipeline" existing.name = "Scrape Pipeline"
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \ with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
@@ -180,12 +173,12 @@ async def test_create_system_same_normalized_name_is_duplicate_gated():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_system_distinct_name_passes_the_gate(): async def test_create_system_distinct_name_passes_the_gate():
other = _fake_system(sid=7, name="Workers") other = fake_system(id=7, name="Workers")
other.name = "Workers" other.name = "Workers"
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \ with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc: patch("scribe.mcp.tools.systems.systems_svc") as svc:
svc.list_systems = AsyncMock(return_value=[other]) svc.list_systems = AsyncMock(return_value=[other])
svc.create_system = AsyncMock(return_value=_fake_system(sid=8, name="Exporter")) svc.create_system = AsyncMock(return_value=fake_system(id=8, name="Exporter"))
from scribe.mcp.tools.systems import create_system from scribe.mcp.tools.systems import create_system
result = await create_system(project_id=5, name="Exporter") result = await create_system(project_id=5, name="Exporter")
assert result["name"] == "Exporter" assert result["name"] == "Exporter"
+3 -6
View File
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from scribe.mcp.tools.tags import list_tags, _aggregate_tag_counts from scribe.mcp.tools.tags import list_tags, _aggregate_tag_counts
from tests.helpers import make_mock_session
pytestmark = pytest.mark.usefixtures("_bind_user") pytestmark = pytest.mark.usefixtures("_bind_user")
@@ -31,9 +32,7 @@ async def test_fable_list_tags_returns_sorted_by_count_desc():
"""End-to-end: query returns three rows, top tag wins.""" """End-to-end: query returns three rows, top tag wins."""
mock_result = MagicMock() mock_result = MagicMock()
mock_result.all.return_value = [(["a"],), (["a", "b"],), (["a"],)] mock_result.all.return_value = [(["a"],), (["a", "b"],), (["a"],)]
mock_session = AsyncMock() mock_session = make_mock_session()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
mock_session.execute = AsyncMock(return_value=mock_result) mock_session.execute = AsyncMock(return_value=mock_result)
mock_ctx = MagicMock(return_value=mock_session) mock_ctx = MagicMock(return_value=mock_session)
with patch("scribe.mcp.tools.tags.async_session", mock_ctx): with patch("scribe.mcp.tools.tags.async_session", mock_ctx):
@@ -47,9 +46,7 @@ async def test_fable_list_tags_returns_sorted_by_count_desc():
async def test_fable_list_tags_clamps_limit(): async def test_fable_list_tags_clamps_limit():
mock_result = MagicMock() mock_result = MagicMock()
mock_result.all.return_value = [] mock_result.all.return_value = []
mock_session = AsyncMock() mock_session = make_mock_session()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
mock_session.execute = AsyncMock(return_value=mock_result) mock_session.execute = AsyncMock(return_value=mock_result)
mock_ctx = MagicMock(return_value=mock_session) mock_ctx = MagicMock(return_value=mock_session)
with patch("scribe.mcp.tools.tags.async_session", mock_ctx): with patch("scribe.mcp.tools.tags.async_session", mock_ctx):
+17 -37
View File
@@ -8,35 +8,15 @@ from scribe.mcp.tools.tasks import (
list_tasks, get_task, create_task, list_tasks, get_task, create_task,
update_task, add_task_log, update_task, add_task_log,
) )
from tests.helpers import fake_task
pytestmark = pytest.mark.usefixtures("_bind_user") pytestmark = pytest.mark.usefixtures("_bind_user")
def _fake_task(*, parent_id: int | None = None, user_id: int = 7,
**overrides) -> MagicMock:
n = MagicMock()
n.parent_id = parent_id
base = {
"id": 1, "title": "t", "body": "", "status": "todo",
"priority": "none", "tags": [], "parent_id": parent_id,
"is_task": True,
}
base.update(overrides)
n.to_dict.return_value = base
n.title = base["title"]
n.id = base["id"]
# Real values, not auto-attributes: get_task reads deleted_at and compares
# user_id against the bound caller for the shared/owner marker — a MagicMock
# is truthy on both (note 2109).
n.user_id = user_id
n.deleted_at = None
return n
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_tasks_passes_is_task_true_and_repackages(): async def test_list_tasks_passes_is_task_true_and_repackages():
rows = [_fake_task(id=1), _fake_task(id=2)] rows = [fake_task(id=1), fake_task(id=2)]
mock = AsyncMock(return_value=(rows, 2)) mock = AsyncMock(return_value=(rows, 2))
with patch("scribe.mcp.tools.tasks.notes_svc.list_notes", mock): with patch("scribe.mcp.tools.tasks.notes_svc.list_notes", mock):
out = await list_tasks() out = await list_tasks()
@@ -63,7 +43,7 @@ async def test_list_tasks_empty_status_means_no_filter():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_task_with_no_parent_returns_null_parent_title(): async def test_get_task_with_no_parent_returns_null_parent_title():
fake = _fake_task(id=5, title="solo", parent_id=None) fake = fake_task(id=5, title="solo", parent_id=None)
with patch( with patch(
"scribe.mcp.tools.tasks.notes_svc.get_note_for_user", "scribe.mcp.tools.tasks.notes_svc.get_note_for_user",
AsyncMock(return_value=(fake, "owner")), AsyncMock(return_value=(fake, "owner")),
@@ -77,8 +57,8 @@ async def test_get_task_with_no_parent_returns_null_parent_title():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_task_enriches_with_parent_title(): async def test_get_task_enriches_with_parent_title():
"""When parent_id is set, get_task fetches the parent and adds parent_title.""" """When parent_id is set, get_task fetches the parent and adds parent_title."""
child = _fake_task(id=10, title="child", parent_id=5) child = fake_task(id=10, title="child", parent_id=5)
parent = _fake_task(id=5, title="parent of 10", parent_id=None) parent = fake_task(id=5, title="parent of 10", parent_id=None)
# fetched twice: once for the child, once for the parent # fetched twice: once for the child, once for the parent
mock_get = AsyncMock(side_effect=[(child, "owner"), (parent, "owner")]) mock_get = AsyncMock(side_effect=[(child, "owner"), (parent, "owner")])
with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user", mock_get): with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user", mock_get):
@@ -90,7 +70,7 @@ async def test_get_task_enriches_with_parent_title():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_task_parent_missing_returns_null(): async def test_get_task_parent_missing_returns_null():
"""If parent_id is set but the parent is gone (orphaned), parent_title is None.""" """If parent_id is set but the parent is gone (orphaned), parent_title is None."""
child = _fake_task(id=10, parent_id=5) child = fake_task(id=10, parent_id=5)
mock_get = AsyncMock(side_effect=[(child, "owner"), None]) mock_get = AsyncMock(side_effect=[(child, "owner"), None])
with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user", mock_get): with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user", mock_get):
out = await get_task(task_id=10) out = await get_task(task_id=10)
@@ -101,7 +81,7 @@ async def test_get_task_parent_missing_returns_null():
async def test_get_task_opens_a_shared_task_and_says_whose_it_is(): async def test_get_task_opens_a_shared_task_and_says_whose_it_is():
"""A task in a shared project opens in the web UI, so the agent path must not """A task in a shared project opens in the web UI, so the agent path must not
answer "not found" for the same id — and must say it isn't the caller's.""" answer "not found" for the same id — and must say it isn't the caller's."""
theirs = _fake_task(id=5, title="Their task", user_id=9) theirs = fake_task(id=5, title="Their task", user_id=9)
with patch( with patch(
"scribe.mcp.tools.tasks.notes_svc.get_note_for_user", "scribe.mcp.tools.tasks.notes_svc.get_note_for_user",
AsyncMock(return_value=(theirs, "viewer")), AsyncMock(return_value=(theirs, "viewer")),
@@ -127,7 +107,7 @@ async def test_get_task_raises_when_not_found():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_task_passes_status(): async def test_create_task_passes_status():
fake = _fake_task() fake = fake_task()
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("scribe.mcp.tools.tasks.notes_svc.create_note", mock): with patch("scribe.mcp.tools.tasks.notes_svc.create_note", mock):
await create_task(title="do x", status="todo") await create_task(title="do x", status="todo")
@@ -155,7 +135,7 @@ async def test_create_task_force_bypasses_duplicate_gate():
find_mock = AsyncMock() find_mock = AsyncMock()
with patch("scribe.mcp.tools.tasks.dedup_svc.find_duplicate_note", find_mock), \ with patch("scribe.mcp.tools.tasks.dedup_svc.find_duplicate_note", find_mock), \
patch("scribe.mcp.tools.tasks.notes_svc.create_note", patch("scribe.mcp.tools.tasks.notes_svc.create_note",
AsyncMock(return_value=_fake_task(id=9))): AsyncMock(return_value=fake_task(id=9))):
out = await create_task(title="dup", force=True) out = await create_task(title="dup", force=True)
assert out["id"] == 9 assert out["id"] == 9
find_mock.assert_not_called() # gate not even consulted find_mock.assert_not_called() # gate not even consulted
@@ -173,7 +153,7 @@ async def test_create_task_rejects_retired_plan_kind():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_task_priority_empty_becomes_none(): async def test_create_task_priority_empty_becomes_none():
fake = _fake_task() fake = fake_task()
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("scribe.mcp.tools.tasks.notes_svc.create_note", mock): with patch("scribe.mcp.tools.tasks.notes_svc.create_note", mock):
await create_task(title="x", priority="") await create_task(title="x", priority="")
@@ -182,7 +162,7 @@ async def test_create_task_priority_empty_becomes_none():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_task_zero_id_sentinels_become_none(): async def test_create_task_zero_id_sentinels_become_none():
fake = _fake_task() fake = fake_task()
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("scribe.mcp.tools.tasks.notes_svc.create_note", mock): with patch("scribe.mcp.tools.tasks.notes_svc.create_note", mock):
await create_task(title="x", project_id=0, milestone_id=0, parent_id=0) await create_task(title="x", project_id=0, milestone_id=0, parent_id=0)
@@ -193,7 +173,7 @@ async def test_create_task_zero_id_sentinels_become_none():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_task_only_sends_non_default_fields(): async def test_update_task_only_sends_non_default_fields():
fake = _fake_task() fake = fake_task()
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock): with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock):
await update_task(task_id=1, status="done") await update_task(task_id=1, status="done")
@@ -205,7 +185,7 @@ async def test_update_task_only_sends_non_default_fields():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_task_empty_priority_is_omitted(): async def test_update_task_empty_priority_is_omitted():
"""Priority="" is "leave unchanged" — must not reach service as empty string.""" """Priority="" is "leave unchanged" — must not reach service as empty string."""
fake = _fake_task() fake = fake_task()
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock): with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock):
await update_task(task_id=1, status="done", priority="") await update_task(task_id=1, status="done", priority="")
@@ -225,7 +205,7 @@ async def test_update_task_raises_when_not_found():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_task_milestone_zero_is_omitted(): async def test_update_task_milestone_zero_is_omitted():
"""milestone_id=0 is 'leave unchanged' — must not reach the service.""" """milestone_id=0 is 'leave unchanged' — must not reach the service."""
fake = _fake_task() fake = fake_task()
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock): with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock):
await update_task(task_id=1, milestone_id=0) await update_task(task_id=1, milestone_id=0)
@@ -234,7 +214,7 @@ async def test_update_task_milestone_zero_is_omitted():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_task_milestone_positive_is_set(): async def test_update_task_milestone_positive_is_set():
fake = _fake_task() fake = fake_task()
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock): with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock):
await update_task(task_id=1, milestone_id=42) await update_task(task_id=1, milestone_id=42)
@@ -244,7 +224,7 @@ async def test_update_task_milestone_positive_is_set():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_task_milestone_negative_one_clears(): async def test_update_task_milestone_negative_one_clears():
"""milestone_id=-1 clears the milestone (sets the column NULL).""" """milestone_id=-1 clears the milestone (sets the column NULL)."""
fake = _fake_task() fake = fake_task()
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock): with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock):
await update_task(task_id=1, milestone_id=-1) await update_task(task_id=1, milestone_id=-1)
@@ -254,7 +234,7 @@ async def test_update_task_milestone_negative_one_clears():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_task_clearing_project_also_clears_milestone(): async def test_update_task_clearing_project_also_clears_milestone():
"""project_id=-1 clears the project and, with it, the milestone.""" """project_id=-1 clears the project and, with it, the milestone."""
fake = _fake_task() fake = fake_task()
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock): with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock):
await update_task(task_id=1, project_id=-1) await update_task(task_id=1, project_id=-1)
+4 -10
View File
@@ -2,6 +2,7 @@
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from tests.helpers import FakeMCP
pytestmark = pytest.mark.usefixtures("_bind_user") pytestmark = pytest.mark.usefixtures("_bind_user")
@@ -47,14 +48,7 @@ async def test_purge_trash_when_confirmed():
def test_register_attaches_three_tools(): def test_register_attaches_three_tools():
from scribe.mcp.tools.trash import register from scribe.mcp.tools.trash import register
names: list[str] = [] mcp = FakeMCP()
class FakeMCP: register(mcp)
def tool(self, name=None): assert set(mcp.names) == {"list_trash", "restore", "purge_trash"}
def deco(fn):
names.append(name)
return fn
return deco
register(FakeMCP())
assert set(names) == {"list_trash", "restore", "purge_trash"}
+3 -6
View File
@@ -5,17 +5,16 @@ from `body` which (post-Task-as-Durable-Record) becomes the LLM-maintained
consolidation summary. consolidation summary.
""" """
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from tests.helpers import make_mock_session
def _mock_session_for_update(mock_note): def _mock_session_for_update(mock_note):
mock_session = AsyncMock() mock_session = make_mock_session()
mock_result = MagicMock() mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = mock_note mock_result.scalars.return_value.first.return_value = mock_note
mock_session.execute = AsyncMock(return_value=mock_result) mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock() mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock() mock_session.refresh = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
return mock_session return mock_session
@@ -56,12 +55,10 @@ async def test_create_note_forwards_description_to_model():
for k, v in kw.items(): for k, v in kw.items():
setattr(self, k, v) setattr(self, k, v)
mock_session = AsyncMock() mock_session = make_mock_session()
mock_session.add = MagicMock() mock_session.add = MagicMock()
mock_session.commit = AsyncMock() mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock() mock_session.refresh = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch( with patch(
"scribe.services.notes.async_session", return_value=mock_session "scribe.services.notes.async_session", return_value=mock_session
+7 -18
View File
@@ -12,14 +12,12 @@ async def test_update_note_sets_started_at_on_in_progress():
mock_note.started_at = None mock_note.started_at = None
mock_note.recurrence_rule = None mock_note.recurrence_rule = None
mock_session = AsyncMock() mock_session = make_mock_session()
mock_result = MagicMock() mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = mock_note mock_result.scalars.return_value.first.return_value = mock_note
mock_session.execute = AsyncMock(return_value=mock_result) mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock() mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock() mock_session.refresh = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch("scribe.services.notes.async_session", return_value=mock_session): with patch("scribe.services.notes.async_session", return_value=mock_session):
from scribe.services.notes import update_note from scribe.services.notes import update_note
@@ -36,14 +34,12 @@ async def test_update_note_sets_completed_at_on_done():
mock_note.completed_at = None mock_note.completed_at = None
mock_note.recurrence_rule = None mock_note.recurrence_rule = None
mock_session = AsyncMock() mock_session = make_mock_session()
mock_result = MagicMock() mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = mock_note mock_result.scalars.return_value.first.return_value = mock_note
mock_session.execute = AsyncMock(return_value=mock_result) mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock() mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock() mock_session.refresh = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch("scribe.services.notes.async_session", return_value=mock_session): with patch("scribe.services.notes.async_session", return_value=mock_session):
from scribe.services.notes import update_note from scribe.services.notes import update_note
@@ -60,14 +56,12 @@ async def test_update_note_clears_timestamps_on_todo():
mock_note.completed_at = datetime(2026, 3, 15, tzinfo=timezone.utc) mock_note.completed_at = datetime(2026, 3, 15, tzinfo=timezone.utc)
mock_note.recurrence_rule = None mock_note.recurrence_rule = None
mock_session = AsyncMock() mock_session = make_mock_session()
mock_result = MagicMock() mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = mock_note mock_result.scalars.return_value.first.return_value = mock_note
mock_session.execute = AsyncMock(return_value=mock_result) mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock() mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock() mock_session.refresh = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch("scribe.services.notes.async_session", return_value=mock_session): with patch("scribe.services.notes.async_session", return_value=mock_session):
from scribe.services.notes import update_note from scribe.services.notes import update_note
@@ -85,14 +79,12 @@ async def test_update_note_preserves_started_at_if_already_set():
mock_note.started_at = original_start mock_note.started_at = original_start
mock_note.recurrence_rule = None mock_note.recurrence_rule = None
mock_session = AsyncMock() mock_session = make_mock_session()
mock_result = MagicMock() mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = mock_note mock_result.scalars.return_value.first.return_value = mock_note
mock_session.execute = AsyncMock(return_value=mock_result) mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock() mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock() mock_session.refresh = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch("scribe.services.notes.async_session", return_value=mock_session): with patch("scribe.services.notes.async_session", return_value=mock_session):
from scribe.services.notes import update_note from scribe.services.notes import update_note
@@ -104,6 +96,7 @@ async def test_update_note_preserves_started_at_if_already_set():
# ── Recurrence rule validation ──────────────────────────────────────────────── # ── Recurrence rule validation ────────────────────────────────────────────────
import pytest import pytest
from tests.helpers import make_mock_session
def test_validate_interval_rule_valid(): def test_validate_interval_rule_valid():
@@ -233,14 +226,12 @@ async def test_spawn_recurring_tasks_creates_child():
mock_task.due_date = date(2026, 3, 1) mock_task.due_date = date(2026, 3, 1)
mock_task.recurrence_rule = {"type": "interval", "every": 3, "unit": "month"} mock_task.recurrence_rule = {"type": "interval", "every": 3, "unit": "month"}
mock_session = AsyncMock() mock_session = make_mock_session()
mock_result = MagicMock() mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_task] mock_result.scalars.return_value.all.return_value = [mock_task]
mock_session.execute = AsyncMock(return_value=mock_result) mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.get = AsyncMock(return_value=mock_task) mock_session.get = AsyncMock(return_value=mock_task)
mock_session.commit = AsyncMock() mock_session.commit = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
mock_child = MagicMock() mock_child = MagicMock()
mock_child.id = 2 mock_child.id = 2
@@ -273,13 +264,11 @@ async def test_list_notes_multi_status_builds_in_clause():
"""list_notes with a list of statuses executes without error.""" """list_notes with a list of statuses executes without error."""
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
mock_session = AsyncMock() mock_session = make_mock_session()
mock_result = MagicMock() mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [] mock_result.scalars.return_value.all.return_value = []
mock_session.execute = AsyncMock(return_value=mock_result) mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.scalar = AsyncMock(return_value=0) mock_session.scalar = AsyncMock(return_value=0)
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch("scribe.services.notes.async_session", return_value=mock_session): with patch("scribe.services.notes.async_session", return_value=mock_session):
from scribe.services.notes import list_notes from scribe.services.notes import list_notes
+2 -4
View File
@@ -15,7 +15,7 @@ shared record would be findable by wording and invisible by meaning.
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from tests.helpers import fake_note from tests.helpers import fake_note, make_mock_session
pytestmark = pytest.mark.usefixtures("_no_supersession") pytestmark = pytest.mark.usefixtures("_no_supersession")
@@ -95,9 +95,7 @@ async def test_hybrid_search_halves_agree_on_scope():
patch.object(knowledge, "readable_notes_clause", patch.object(knowledge, "readable_notes_clause",
MagicMock(return_value=(Note.user_id == 7))) as read_clause, \ MagicMock(return_value=(Note.user_id == 7))) as read_clause, \
patch.object(knowledge, "async_session") as sess: patch.object(knowledge, "async_session") as sess:
session = AsyncMock() session = make_mock_session()
session.__aenter__ = AsyncMock(return_value=session)
session.__aexit__ = AsyncMock(return_value=False)
result = MagicMock() result = MagicMock()
result.scalars.return_value.all.return_value = [] result.scalars.return_value.all.return_value = []
session.execute = AsyncMock(return_value=result) session.execute = AsyncMock(return_value=result)
+4 -8
View File
@@ -5,6 +5,7 @@ deliberate: relaxing one to a pattern would stop it catching the thing it was
written for, which is a capability landing on one surface and not the other. written for, which is a capability landing on one surface and not the other.
""" """
import inspect import inspect
from tests.helpers import FakeMCP
def test_design_systems_blueprint_registered(): def test_design_systems_blueprint_registered():
@@ -107,17 +108,12 @@ def test_every_mcp_tool_in_the_module_is_registered():
else in the codebase would notice.""" else in the codebase would notice."""
from scribe.mcp.tools import design_systems as tools from scribe.mcp.tools import design_systems as tools
registered = [] mcp = FakeMCP()
class _Recorder: tools.register(mcp)
def tool(self, name):
registered.append(name)
return lambda fn: fn
tools.register(_Recorder())
public = { public = {
name for name, obj in vars(tools).items() name for name, obj in vars(tools).items()
if inspect.iscoroutinefunction(obj) and not name.startswith("_") if inspect.iscoroutinefunction(obj) and not name.startswith("_")
} }
assert set(registered) == public assert set(mcp.names) == public
+2 -3
View File
@@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from scribe.services.db_maintenance import MAINTENANCE_TABLES, run_maintenance from scribe.services.db_maintenance import MAINTENANCE_TABLES, run_maintenance
from tests.helpers import make_mock_session
def _mock_engine(exec_side_effect=None): def _mock_engine(exec_side_effect=None):
@@ -79,9 +80,7 @@ async def test_one_table_failure_does_not_abort_the_rest():
def _health_session(db_bytes, rows): def _health_session(db_bytes, rows):
s = AsyncMock() s = make_mock_session()
s.__aenter__ = AsyncMock(return_value=s)
s.__aexit__ = AsyncMock(return_value=False)
size_res = MagicMock() size_res = MagicMock()
size_res.scalar.return_value = db_bytes size_res.scalar.return_value = db_bytes
rows_res = MagicMock() rows_res = MagicMock()
+3 -7
View File
@@ -9,14 +9,12 @@ from scribe.services.dedup import (
find_duplicate_note, find_duplicate_note,
find_duplicate_rule, find_duplicate_rule,
) )
from tests.helpers import fake_note from tests.helpers import fake_note, make_mock_session
def _session_returning(note): def _session_returning(note):
"""A mocked async_session() whose single execute() yields `note` (or None).""" """A mocked async_session() whose single execute() yields `note` (or None)."""
s = AsyncMock() s = make_mock_session()
s.__aenter__ = AsyncMock(return_value=s)
s.__aexit__ = AsyncMock(return_value=False)
result = MagicMock() result = MagicMock()
result.scalars.return_value.first.return_value = note result.scalars.return_value.first.return_value = note
s.execute = AsyncMock(return_value=result) s.execute = AsyncMock(return_value=result)
@@ -150,9 +148,7 @@ def _session_sequence(results):
a location query and then a code query, and the whole point is that they a location query and then a code query, and the whole point is that they
answer differently. answer differently.
""" """
s = AsyncMock() s = make_mock_session()
s.__aenter__ = AsyncMock(return_value=s)
s.__aexit__ = AsyncMock(return_value=False)
wrapped = [] wrapped = []
for note in results: for note in results:
r = MagicMock() r = MagicMock()
+2 -3
View File
@@ -17,14 +17,13 @@ project would pass a correctness test and reproduce the outage.
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from tests.helpers import make_mock_session
def _session_factory(counter: list[int], results: list): def _session_factory(counter: list[int], results: list):
"""A session whose .execute() returns queued results, counting opens.""" """A session whose .execute() returns queued results, counting opens."""
def _make(): def _make():
s = AsyncMock() s = make_mock_session()
s.__aenter__ = AsyncMock(return_value=s)
s.__aexit__ = AsyncMock(return_value=False)
counter[0] += 1 counter[0] += 1
async def _execute(*_a, **_kw): async def _execute(*_a, **_kw):
+7 -59
View File
@@ -3,25 +3,9 @@
Mirrors the pattern in tests/test_events_service.py. Mirrors the pattern in tests/test_events_service.py.
""" """
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from datetime import datetime, timezone
import pytest import pytest
from tests.helpers import make_mock_session from tests.helpers import fake_rule, fake_rulebook, fake_topic, make_mock_session
def _fake_rulebook(id=1, owner_user_id=7, title="FabledSword family", description=""):
rb = MagicMock()
rb.id = id
rb.owner_user_id = owner_user_id
rb.title = title
rb.description = description
rb.created_at = datetime.now(timezone.utc)
rb.updated_at = datetime.now(timezone.utc)
rb.to_dict.return_value = {
"id": id, "owner_user_id": owner_user_id,
"title": title, "description": description or "",
}
return rb
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -39,7 +23,7 @@ async def test_create_rulebook_stores_to_db():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_rulebooks_returns_owned_only(): async def test_list_rulebooks_returns_owned_only():
rb = _fake_rulebook(id=1) rb = fake_rulebook(id=1)
mock_session = make_mock_session() mock_session = make_mock_session()
mock_result = MagicMock() mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [rb] mock_result.scalars.return_value.all.return_value = [rb]
@@ -67,7 +51,7 @@ async def test_get_rulebook_returns_none_when_not_owner():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_rulebook_only_sets_provided_fields(): async def test_update_rulebook_only_sets_provided_fields():
rb = _fake_rulebook(id=1, title="old") rb = fake_rulebook(id=1, title="old")
mock_session = make_mock_session() mock_session = make_mock_session()
mock_result = MagicMock() mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = rb mock_result.scalar_one_or_none.return_value = rb
@@ -81,7 +65,7 @@ async def test_update_rulebook_only_sets_provided_fields():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_rulebook_calls_delete(): async def test_delete_rulebook_calls_delete():
rb = _fake_rulebook(id=1) rb = fake_rulebook(id=1)
mock_session = make_mock_session() mock_session = make_mock_session()
mock_result = MagicMock() mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = rb mock_result.scalar_one_or_none.return_value = rb
@@ -96,22 +80,6 @@ async def test_delete_rulebook_calls_delete():
# ── Topic CRUD ─────────────────────────────────────────────────────────── # ── Topic CRUD ───────────────────────────────────────────────────────────
def _fake_topic(id=1, rulebook_id=1, title="git-workflow", description="", order_index=0):
t = MagicMock()
t.id = id
t.rulebook_id = rulebook_id
t.title = title
t.description = description
t.order_index = order_index
t.created_at = datetime.now(timezone.utc)
t.updated_at = datetime.now(timezone.utc)
t.to_dict.return_value = {
"id": id, "rulebook_id": rulebook_id, "title": title,
"description": description or "", "order_index": order_index,
}
return t
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_topic_requires_owned_rulebook(): async def test_create_topic_requires_owned_rulebook():
"""create_topic raises ValueError if the rulebook isn't owned by user.""" """create_topic raises ValueError if the rulebook isn't owned by user."""
@@ -130,8 +98,8 @@ async def test_create_topic_requires_owned_rulebook():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_topics_returns_topics_for_owned_rulebook(): async def test_list_topics_returns_topics_for_owned_rulebook():
rb = _fake_rulebook(id=1) rb = fake_rulebook(id=1)
topic = _fake_topic(id=10, rulebook_id=1, title="git-workflow") topic = fake_topic(id=10, rulebook_id=1, title="git-workflow")
# Two execute calls: ownership check, then topic select. # Two execute calls: ownership check, then topic select.
mock_session = make_mock_session() mock_session = make_mock_session()
@@ -151,26 +119,6 @@ async def test_list_topics_returns_topics_for_owned_rulebook():
# ── Rule CRUD ─────────────────────────────────────────────────────────── # ── Rule CRUD ───────────────────────────────────────────────────────────
def _fake_rule(id=1, topic_id=10, title="dev is home",
statement="Work directly on dev", why="", how_to_apply=""):
r = MagicMock()
r.id = id
r.topic_id = topic_id
r.title = title
r.statement = statement
r.why = why
r.how_to_apply = how_to_apply
r.order_index = 0
r.created_at = datetime.now(timezone.utc)
r.updated_at = datetime.now(timezone.utc)
r.to_dict.return_value = {
"id": id, "topic_id": topic_id, "title": title,
"statement": statement, "why": why or "",
"how_to_apply": how_to_apply or "", "order_index": 0,
}
return r
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_rule_requires_owned_topic(): async def test_create_rule_requires_owned_topic():
mock_session = make_mock_session() mock_session = make_mock_session()
@@ -189,7 +137,7 @@ async def test_create_rule_requires_owned_topic():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_rules_filters_by_topic_id(): async def test_list_rules_filters_by_topic_id():
"""list_rules(topic_id=X) returns rules in that topic, ownership-scoped.""" """list_rules(topic_id=X) returns rules in that topic, ownership-scoped."""
rule = _fake_rule(id=1, topic_id=10) rule = fake_rule(id=1, topic_id=10)
mock_session = make_mock_session() mock_session = make_mock_session()
mock_result = MagicMock() mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [rule] mock_result.scalars.return_value.all.return_value = [rule]
+3 -6
View File
@@ -15,13 +15,12 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from scribe.services import supersession from scribe.services import supersession
from tests.helpers import make_mock_session
def _session(scalars_sequence=None, get_returns=None): def _session(scalars_sequence=None, get_returns=None):
"""A mocked async_session whose execute() yields successive scalar lists.""" """A mocked async_session whose execute() yields successive scalar lists."""
s = AsyncMock() s = make_mock_session()
s.__aenter__ = AsyncMock(return_value=s)
s.__aexit__ = AsyncMock(return_value=False)
results = [] results = []
for scalars in scalars_sequence or []: for scalars in scalars_sequence or []:
@@ -156,9 +155,7 @@ async def test_get_relations_partitions_both_directions_from_one_query():
Note 5 supersedes 2 and 3, and is itself superseded by 9. All four rows come Note 5 supersedes 2 and 3, and is itself superseded by 9. All four rows come
back from a single OR query and are partitioned by which column holds 5. back from a single OR query and are partitioned by which column holds 5.
""" """
session = AsyncMock() session = make_mock_session()
session.__aenter__ = AsyncMock(return_value=session)
session.__aexit__ = AsyncMock(return_value=False)
result = MagicMock() result = MagicMock()
result.all.return_value = [(5, 3), (5, 2), (9, 5)] # (superseder, superseded) result.all.return_value = [(5, 3), (5, 2), (9, 5)] # (superseder, superseded)
session.execute = AsyncMock(return_value=result) session.execute = AsyncMock(return_value=result)
+3 -6
View File
@@ -9,14 +9,13 @@ passive surface Scribe has (each entry becomes an auto-surfacing skill file).
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from tests.helpers import make_mock_session
def _session_returning_rows(rows): def _session_returning_rows(rows):
result = MagicMock() result = MagicMock()
result.all.return_value = rows result.all.return_value = rows
session = AsyncMock() session = make_mock_session()
session.__aenter__ = AsyncMock(return_value=session)
session.__aexit__ = AsyncMock(return_value=False)
session.execute = AsyncMock(return_value=result) session.execute = AsyncMock(return_value=result)
return session return session
@@ -62,9 +61,7 @@ async def test_provenance_names_the_owner_and_permission():
from scribe.services.access import describe_provenance from scribe.services.access import describe_provenance
note = MagicMock(id=1, user_id=9) note = MagicMock(id=1, user_id=9)
owner = MagicMock(username="alex") owner = MagicMock(username="alex")
session = AsyncMock() session = make_mock_session()
session.__aenter__ = AsyncMock(return_value=session)
session.__aexit__ = AsyncMock(return_value=False)
session.get = AsyncMock(return_value=owner) session.get = AsyncMock(return_value=owner)
with patch("scribe.services.access.async_session") as cls, \ with patch("scribe.services.access.async_session") as cls, \
patch("scribe.services.access.get_note_permission", patch("scribe.services.access.get_note_permission",
+6 -13
View File
@@ -10,24 +10,17 @@ A record readable-but-not-writable raises PermissionError rather than reporting
"not found" — the caller can plainly open it, so not-found would be a lie that "not found" — the caller can plainly open it, so not-found would be a lie that
sends them hunting for a missing id. sends them hunting for a missing id.
""" """
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from tests.helpers import fake_snippet
def _snippet(id=1, owner=9): def _snippet(id=1, owner=9):
n = MagicMock() return fake_snippet(
n.id = id id=id, user_id=owner, title="formatDuration — humanize a millisecond count",
n.user_id = owner body="```ts\nexport const f = 1\n```\n", tags=["ts", "snippet"],
n.title = "formatDuration — humanize a millisecond count" )
n.body = "```ts\nexport const f = 1\n```\n"
n.tags = ["ts", "snippet"]
n.note_type = "snippet"
n.deleted_at = None
# Explicitly None — snippet_fields prefers `data` when truthy, and an
# auto-MagicMock attribute is truthy (see note 2109).
n.data = None
return n
@pytest.mark.asyncio @pytest.mark.asyncio
+7 -13
View File
@@ -6,26 +6,20 @@ only in the trash — and only for someone who already knew to go looking. So th
survivor carries `merged_from`, written to the body and the indexed mirror from survivor carries `merged_from`, written to the body and the indexed mirror from
one value, and ordinary edits must carry it forward rather than erase it. one value, and ordinary edits must carry it forward rather than erase it.
""" """
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from scribe.services import snippets as s from scribe.services import snippets as s
from tests.helpers import fake_snippet
def _snippet(id=1, owner=9, body=None, data=None, tags=None): def _snippet(id=1, owner=9, body=None, data=None, tags=None):
n = MagicMock() return fake_snippet(
n.id = id id=id, user_id=owner, title="formatDuration — humanize a millisecond count",
n.user_id = owner body=body if body is not None else s.compose_body(code="x = 1", language="ts"),
n.title = "formatDuration — humanize a millisecond count" tags=tags if tags is not None else ["ts", "snippet"], data=data,
n.body = body if body is not None else s.compose_body(code="x = 1", language="ts") )
n.tags = tags if tags is not None else ["ts", "snippet"]
n.note_type = "snippet"
n.deleted_at = None
# Explicitly None — snippet_fields prefers `data` when truthy, and an
# auto-MagicMock attribute is truthy (note 2109).
n.data = data
return n
async def _run_merge(target, sources, source_ids): async def _run_merge(target, sources, source_ids):
+14 -17
View File
@@ -7,15 +7,12 @@ that source actually added. These tests pin that rule, and the refusal that
guards the case where the attribution isn't there. guards the case where the attribution isn't there.
""" """
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from scribe.services import snippets as s from scribe.services import snippets as s
from tests.helpers import loc
def _loc(path, repo="r", symbol=""):
return {"repo": repo, "path": path, "symbol": symbol}
def _survivor(locations, merged_from, tags=None, owner=7): def _survivor(locations, merged_from, tags=None, owner=7):
@@ -54,7 +51,7 @@ async def _run_unmerge(survivor, *, source_alive=None, restore=1):
async def test_unmerge_strips_only_what_the_source_contributed(): async def test_unmerge_strips_only_what_the_source_contributed():
"""The survivor's own location survives; the source's is removed.""" """The survivor's own location survives; the source's is removed."""
mine, theirs = _loc("mine.py"), _loc("theirs.py") mine, theirs = loc(path="mine.py", repo="r"), loc(path="theirs.py", repo="r")
survivor = _survivor( survivor = _survivor(
[mine, theirs], [mine, theirs],
[{"id": 2, "locations": [theirs], "tags": ["helper"]}], [{"id": 2, "locations": [theirs], "tags": ["helper"]}],
@@ -70,7 +67,7 @@ async def test_a_location_the_survivor_also_owned_is_never_stripped():
"""The central hazard the task named. If a source brought a location the """The central hazard the task named. If a source brought a location the
survivor ALREADY had, merge attributes nothing to it — so reversing must survivor ALREADY had, merge attributes nothing to it — so reversing must
leave that call site in place.""" leave that call site in place."""
shared = _loc("shared.py") shared = loc(path="shared.py", repo="r")
survivor = _survivor([shared], [{"id": 2, "locations": [], "tags": ["t"]}]) survivor = _survivor([shared], [{"id": 2, "locations": [], "tags": ["t"]}])
kwargs = await _run_unmerge(survivor) kwargs = await _run_unmerge(survivor)
assert kwargs["data"]["locations"] == [shared] assert kwargs["data"]["locations"] == [shared]
@@ -78,8 +75,8 @@ async def test_a_location_the_survivor_also_owned_is_never_stripped():
async def test_the_reversed_entry_leaves_the_provenance_list(): async def test_the_reversed_entry_leaves_the_provenance_list():
survivor = _survivor( survivor = _survivor(
[_loc("a.py"), _loc("b.py")], [loc(path="a.py", repo="r"), loc(path="b.py", repo="r")],
[{"id": 2, "locations": [_loc("b.py")]}, {"id": 3, "locations": []}], [{"id": 2, "locations": [loc(path="b.py", repo="r")]}, {"id": 3, "locations": []}],
) )
kwargs = await _run_unmerge(survivor) kwargs = await _run_unmerge(survivor)
assert s.merged_from_ids(kwargs["data"]["merged_from"]) == [3] assert s.merged_from_ids(kwargs["data"]["merged_from"]) == [3]
@@ -89,7 +86,7 @@ async def test_the_reversed_entry_leaves_the_provenance_list():
async def test_unmerging_the_last_source_clears_the_provenance_line(): async def test_unmerging_the_last_source_clears_the_provenance_line():
survivor = _survivor([_loc("a.py")], [{"id": 2, "locations": [], "tags": ["t"]}]) survivor = _survivor([loc(path="a.py", repo="r")], [{"id": 2, "locations": [], "tags": ["t"]}])
kwargs = await _run_unmerge(survivor) kwargs = await _run_unmerge(survivor)
assert "Merged from" not in kwargs["body"] assert "Merged from" not in kwargs["body"]
@@ -101,8 +98,8 @@ async def test_an_already_restored_source_is_repaired_not_refused():
"""The scenario that motivated the feature: the operator restored the source """The scenario that motivated the feature: the operator restored the source
from the trash by hand, so both records claim its call sites and nothing ever from the trash by hand, so both records claim its call sites and nothing ever
stripped the survivor's copy. Un-merge must fix that, not reject it.""" stripped the survivor's copy. Un-merge must fix that, not reject it."""
theirs = _loc("theirs.py") theirs = loc(path="theirs.py", repo="r")
survivor = _survivor([_loc("mine.py"), theirs], survivor = _survivor([loc(path="mine.py", repo="r"), theirs],
[{"id": 2, "locations": [theirs]}]) [{"id": 2, "locations": [theirs]}])
alive = SimpleNamespace(id=2, user_id=7, note_type="snippet", deleted_at=None, alive = SimpleNamespace(id=2, user_id=7, note_type="snippet", deleted_at=None,
title="g — x", tags=["snippet"], body="", data=None) title="g — x", tags=["snippet"], body="", data=None)
@@ -110,13 +107,13 @@ async def test_an_already_restored_source_is_repaired_not_refused():
kwargs = await _run_unmerge(survivor, source_alive=alive) kwargs = await _run_unmerge(survivor, source_alive=alive)
# Nothing to revive — it's already alive — but the subtraction still happens. # Nothing to revive — it's already alive — but the subtraction still happens.
revive.assert_not_called() revive.assert_not_called()
assert kwargs["data"]["locations"] == [_loc("mine.py")] assert kwargs["data"]["locations"] == [loc(path="mine.py", repo="r")]
async def test_a_purged_source_is_refused_and_the_survivor_is_untouched(): async def test_a_purged_source_is_refused_and_the_survivor_is_untouched():
"""If the source can't come back, stripping the survivor would lose the """If the source can't come back, stripping the survivor would lose the
locations entirely — no record would claim them.""" locations entirely — no record would claim them."""
survivor = _survivor([_loc("a.py")], [{"id": 2, "locations": [_loc("a.py")]}]) survivor = _survivor([loc(path="a.py", repo="r")], [{"id": 2, "locations": [loc(path="a.py", repo="r")]}])
async def fake_get(_uid, sid): async def fake_get(_uid, sid):
return survivor if sid == 1 else None return survivor if sid == 1 else None
@@ -139,7 +136,7 @@ async def test_an_entry_without_attribution_is_refused_not_guessed():
"""Bare-id provenance comes from parsing the body, which can only hold ids. """Bare-id provenance comes from parsing the body, which can only hold ids.
Subtracting a guess could strip call sites the survivor owns — so refuse and Subtracting a guess could strip call sites the survivor owns — so refuse and
say what to do instead.""" say what to do instead."""
survivor = _survivor([_loc("a.py")], [2]) survivor = _survivor([loc(path="a.py", repo="r")], [2])
async def fake_get(_uid, sid): async def fake_get(_uid, sid):
return survivor if sid == 1 else None return survivor if sid == 1 else None
@@ -155,7 +152,7 @@ async def test_an_entry_without_attribution_is_refused_not_guessed():
async def test_unmerging_something_never_absorbed_is_refused(): async def test_unmerging_something_never_absorbed_is_refused():
survivor = _survivor([_loc("a.py")], [{"id": 99, "locations": []}]) survivor = _survivor([loc(path="a.py", repo="r")], [{"id": 99, "locations": []}])
with ( with (
patch.object(s, "get_snippet", AsyncMock(return_value=survivor)), patch.object(s, "get_snippet", AsyncMock(return_value=survivor)),
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)),
@@ -166,7 +163,7 @@ async def test_unmerging_something_never_absorbed_is_refused():
async def test_unmerge_requires_write_access(): async def test_unmerge_requires_write_access():
"""Same rule as merge: a read-only share can see the record, not rearrange it.""" """Same rule as merge: a read-only share can see the record, not rearrange it."""
survivor = _survivor([_loc("a.py")], [{"id": 2, "locations": []}]) survivor = _survivor([loc(path="a.py", repo="r")], [{"id": 2, "locations": []}])
with ( with (
patch.object(s, "get_snippet", AsyncMock(return_value=survivor)), patch.object(s, "get_snippet", AsyncMock(return_value=survivor)),
patch("scribe.services.access.can_write_note", AsyncMock(return_value=False)), patch("scribe.services.access.can_write_note", AsyncMock(return_value=False)),
+2 -3
View File
@@ -7,12 +7,11 @@ compiled SQL of every statement passed to execute, then assert the
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from tests.helpers import make_mock_session
def _capturing_session(captured: list[str]): def _capturing_session(captured: list[str]):
s = AsyncMock() s = make_mock_session()
s.__aenter__ = AsyncMock(return_value=s)
s.__aexit__ = AsyncMock(return_value=False)
s.commit = AsyncMock() s.commit = AsyncMock()
s.scalar = AsyncMock(return_value=0) s.scalar = AsyncMock(return_value=0)
+3 -6
View File
@@ -1,16 +1,15 @@
"""Tests for manual pin / unpin on note versions.""" """Tests for manual pin / unpin on note versions."""
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from tests.helpers import make_mock_session
def _mock_session_for_version(mock_version): def _mock_session_for_version(mock_version):
mock_session = AsyncMock() mock_session = make_mock_session()
result = MagicMock() result = MagicMock()
result.scalars.return_value.first.return_value = mock_version result.scalars.return_value.first.return_value = mock_version
mock_session.execute = AsyncMock(return_value=result) mock_session.execute = AsyncMock(return_value=result)
mock_session.commit = AsyncMock() mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock() mock_session.refresh = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
return mock_session return mock_session
@@ -92,14 +91,12 @@ async def test_pin_version_rejects_overlong_label():
async def test_pin_version_returns_none_when_not_found(): async def test_pin_version_returns_none_when_not_found():
mock_session = AsyncMock() mock_session = make_mock_session()
result = MagicMock() result = MagicMock()
result.scalars.return_value.first.return_value = None result.scalars.return_value.first.return_value = None
mock_session.execute = AsyncMock(return_value=result) mock_session.execute = AsyncMock(return_value=result)
mock_session.commit = AsyncMock() mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock() mock_session.refresh = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch( with patch(
"scribe.services.version_pinning.async_session", "scribe.services.version_pinning.async_session",
+2 -6
View File
@@ -10,7 +10,7 @@ async def test_create_version_prune_sql_filters_to_unpinned():
"""The DELETE statement issued by create_version's prune step must """The DELETE statement issued by create_version's prune step must
include `pin_kind IS NULL` in the inner SELECT so pinned versions include `pin_kind IS NULL` in the inner SELECT so pinned versions
aren't counted toward MAX_VERSIONS and can't be pruned by it.""" aren't counted toward MAX_VERSIONS and can't be pruned by it."""
mock_session = AsyncMock() mock_session = make_mock_session()
select_result = MagicMock() select_result = MagicMock()
# No prior version → skips the throttle/dedupe early-return paths and # No prior version → skips the throttle/dedupe early-return paths and
# proceeds straight to insert + prune. # proceeds straight to insert + prune.
@@ -18,8 +18,6 @@ async def test_create_version_prune_sql_filters_to_unpinned():
mock_session.add = MagicMock() mock_session.add = MagicMock()
mock_session.commit = AsyncMock() mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock() mock_session.refresh = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
captured_sql: list[str] = [] captured_sql: list[str] = []
@@ -48,10 +46,8 @@ async def test_create_version_prune_sql_filters_to_unpinned():
async def test_prune_auto_pins_filters_to_auto_kind(): async def test_prune_auto_pins_filters_to_auto_kind():
"""prune_auto_pins must filter to pin_kind='auto' so manual pins and """prune_auto_pins must filter to pin_kind='auto' so manual pins and
rolling rows aren't touched, and must bind MAX_AUTO_PINS as the OFFSET.""" rolling rows aren't touched, and must bind MAX_AUTO_PINS as the OFFSET."""
mock_session = AsyncMock() mock_session = make_mock_session()
mock_session.commit = AsyncMock() mock_session.commit = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
captured_sql: list[str] = [] captured_sql: list[str] = []
captured_params: list[dict] = [] captured_params: list[dict] = []