Files
FabledGrimoire/tests/test_record_kind_surface.py
T
bvandeusenandClaude Opus 5.5 0f5ef0be63
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
rename(547): the server is grimoire — package, image, DB defaults, backups
Step 2 of the Scribe -> Grimoire rename.

- src/scribe -> src/grimoire, and every import, patch target, module path,
  hypercorn entry point and `python -m grimoire.restore` with it; pyproject,
  uv.lock and the frontend package are named grimoire.
- Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service
  runs as grimoire / grimoire_test.
- Compose files default the DB user, database and (prod) volume to grimoire,
  each overridable from .env, so an install created as Scribe keeps its data
  by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME.
- Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar.
  Archives named scribe-* with the scribe-backup-tar manifest still restore
  and are still counted at the destination (folder and S3), with tests.
- Exports, the MCP auth realm, the ASGI scope key and the persistent logger
  carry the new name.

Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1",
because every stored secret was derived under it.

Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is
step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:50:45 -04:00

115 lines
4.3 KiB
Python

"""Every typed record kind gets the same doors.
WHY THIS IS ONE GUARD AND NOT FIFTEEN
A kind can ship across several steps and still arrive with three of five
tools, no way to list it, and no duplicate report — with nothing red, because
every test asks "does what I built work" and none asks "is the kind
finished". A per-tool test cannot catch a MISSING tool.
So this asserts the property directly — a kind with its own `note_type` has a
full create/read/update/delete plus a listing, each one registered and each one
classified for auth — and derives the expectation from the kinds themselves. A
fourth kind added later inherits the bar without anyone remembering to.
A kind deletable only through `delete_note` is the same failure: "a kind whose
own tools offer create/read/update reads as one you cannot retire".
"""
from __future__ import annotations
import importlib
import pytest
from grimoire.mcp.server import (
_DELIBERATELY_WRITE_SCOPED,
_READ_ONLY_TOOLS,
_WRITE_TOOLS,
)
from grimoire.services.dedup import _REPORT_KINDS
# singular -> the tools module, which is also the plural used by the listing.
KINDS = {"snippet": "snippets", "process": "processes", "lesson": "lessons"}
def _module(plural: str):
return importlib.import_module(f"grimoire.mcp.tools.{plural}")
def _expected_tools(singular: str, plural: str) -> list[str]:
return [
f"list_{plural}",
f"create_{singular}",
f"get_{singular}",
f"update_{singular}",
f"delete_{singular}",
]
@pytest.mark.parametrize("singular,plural", sorted(KINDS.items()))
def test_a_kind_has_all_five_doors(singular, plural):
"""THE guard. A kind that can be created and not listed, or updated and not
retired, is one whose gaps are invisible until someone needs the missing
half."""
module = _module(plural)
missing = [
name for name in _expected_tools(singular, plural)
if not callable(getattr(module, name, None))
]
assert not missing, f"{plural}.py is missing {missing}"
@pytest.mark.parametrize("singular,plural", sorted(KINDS.items()))
def test_every_door_is_actually_registered(singular, plural):
"""Defining the function is not offering it. `register(mcp)` is what puts a
tool on the surface, and a handler left out of that list is dead code that
every other test still exercises directly."""
module = _module(plural)
registered: list[str] = []
class _Recorder:
def tool(self, name):
registered.append(name)
return lambda fn: fn
module.register(_Recorder())
missing = [n for n in _expected_tools(singular, plural) if n not in registered]
assert not missing, f"{plural}.register() does not offer {missing}"
@pytest.mark.parametrize("singular,plural", sorted(KINDS.items()))
def test_every_door_is_classified_for_auth(singular, plural):
"""`test_mcp_auth` requires every registered tool to sit in exactly one of
the three sets. Asserted per KIND as well, because that test enumerates
what is registered — so a whole module wired up with none of its tools
classified is caught here by the kind that owns them."""
classified = _READ_ONLY_TOOLS | _WRITE_TOOLS | _DELIBERATELY_WRITE_SCOPED
missing = [n for n in _expected_tools(singular, plural) if n not in classified]
assert not missing, f"unclassified in server.py: {missing}"
@pytest.mark.parametrize("singular", sorted(KINDS))
def test_every_kind_can_be_asked_whether_it_holds_duplicates(singular):
"""A kind with no report is one whose duplicates are found by accident.
It matters most where the write-path gate is deliberately permissive: a
lesson's bar sits above the band where two genuinely different lessons
would block each other, and the report is where that tolerance is meant to
be reviewed.
"""
assert singular in _REPORT_KINDS
def test_the_surface_guards_can_fail():
"""Falsify the shape these assert against, so a guard that has
quietly stopped describing anything cannot pass by describing nothing."""
assert _expected_tools("lesson", "lessons") == [
"list_lessons", "create_lesson", "get_lesson",
"update_lesson", "delete_lesson",
]
# A kind that does not exist has no module — the lookup is real, not a
# getattr that returns None for everything.
with pytest.raises(ModuleNotFoundError):
_module("widgets")