CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 32s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 33s
Milestone 333 step 5, and rule 27 — the counter had a tuning point from step 4 and no operator-facing one until now. The task said to reuse the snippet badge's classes rather than mint a parallel set, citing the eight duplicated CSS families the ledger already carries (#3207). `.usage-tag` lived in SnippetListView's SCOPED block, so "reuse" was not available: copying it into the rule pane would have been the ninth family, and importing it is not a thing a scoped block permits. So it was promoted rather than copied. Three pieces, each of which existed once and now exists once: - `components.css` gains `.usage-tag` / `.usage-dead`, geometry and colour only, with the scoped original deleted rather than left behind. - `UsageBadge.vue` holds the logic the two lists would otherwise duplicate — the >=3 dead-weight threshold, the empty-string-renders-nothing rule, the tooltip. - `types/usage.ts` holds `RecordUsage`, one client type over two tables. `SnippetUsage` becomes an alias, so no existing consumer changes. THE ADVICE IS A PROP, and that is the substance rather than the plumbing. The counts read identically for every kind; the remedy does not. A snippet offered and never opened should probably be rewritten or deleted — one action. A rule in the same position has TWO possible causes and the operator has to pick: its trigger may fire on the wrong work, in which case `when_to_apply` wants rewording, or it may genuinely not be wanted. Baking "delete it" into the component would give the wrong nudge half the time on the surface where being wrong is most expensive, since a deleted rule stops binding behaviour. The route zero-fills every row through `usage_for_rules`, one aggregate per page — per-row would be N+1 by construction. That matters more here than for snippets: every rule on every existing install predates `rule_usage_events`, so the zero-filled shape IS the common case for a while, and a route that attached the key only where it found events would leave the badge reading undefined on almost every row. `usage_for_rules` had no test at all — step 1 covered the write path and the zero shape and left the aggregate uncovered, which only became load-bearing when a list started rendering it. It now has an integration test over real Postgres, including that a rule with no events comes back zero-filled rather than absent. Recorded as snippet #3460, per the design system's own instruction that the component layer lives as snippets rather than as prose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
149 lines
6.1 KiB
Python
149 lines
6.1 KiB
Python
"""Route-level tests for the rulebooks blueprint.
|
|
|
|
Mirrors the structural-tests pattern in tests/test_events_routes.py:
|
|
covers blueprint registration, callable handlers, and service-signature
|
|
contracts. Full HTTP integration requires a live DB and auth machinery
|
|
that the unit-test environment doesn't provide.
|
|
"""
|
|
import inspect
|
|
|
|
|
|
def test_rulebooks_blueprint_registered():
|
|
from scribe.routes.rulebooks import rulebooks_bp
|
|
assert rulebooks_bp.name == "rulebooks"
|
|
assert rulebooks_bp.url_prefix == "/api"
|
|
|
|
|
|
def test_rulebooks_blueprint_registered_in_app():
|
|
from scribe.app import create_app
|
|
app = create_app()
|
|
assert "rulebooks" in app.blueprints
|
|
|
|
|
|
def test_rulebook_handlers_callable():
|
|
from scribe.routes import rulebooks as rb_routes
|
|
for name in (
|
|
"list_rulebooks", "create_rulebook", "get_rulebook",
|
|
"update_rulebook", "delete_rulebook",
|
|
):
|
|
assert callable(getattr(rb_routes, name))
|
|
|
|
|
|
def test_topic_handlers_callable():
|
|
from scribe.routes import rulebooks as rb_routes
|
|
for name in (
|
|
"list_topics", "create_topic", "update_topic", "delete_topic",
|
|
):
|
|
assert callable(getattr(rb_routes, name))
|
|
|
|
|
|
def test_service_signatures_require_user_id():
|
|
"""Routes must call services with user_id — verify the contract."""
|
|
from scribe.services import rulebooks as svc
|
|
for fn_name in (
|
|
"create_rulebook", "list_rulebooks", "get_rulebook",
|
|
"update_rulebook", "delete_rulebook", "find_rulebook_by_title",
|
|
"create_topic", "list_topics", "get_topic", "update_topic", "delete_topic",
|
|
"create_rule", "create_project_rule", "rule_detail",
|
|
"set_rule_systems", "add_rule_relation", "remove_rule_relation",
|
|
"list_rules", "list_always_on_rules",
|
|
"get_rule", "update_rule", "delete_rule",
|
|
"subscribe_project", "unsubscribe_project", "get_applicable_rules",
|
|
"suppress_rule_for_project", "unsuppress_rule_for_project",
|
|
"suppress_topic_for_project", "unsuppress_topic_for_project",
|
|
):
|
|
sig = inspect.signature(getattr(svc, fn_name))
|
|
assert "user_id" in sig.parameters, f"{fn_name} missing user_id param"
|
|
|
|
|
|
def test_rule_model_carries_project_id_and_topic_id_nullable():
|
|
"""Migration 0059 made topic_id nullable and added project_id."""
|
|
from scribe.models.rulebook import Rule
|
|
assert "project_id" in Rule.__table__.columns
|
|
assert Rule.__table__.columns["topic_id"].nullable is True
|
|
assert Rule.__table__.columns["project_id"].nullable is True
|
|
|
|
|
|
def test_create_project_rule_route_exists():
|
|
"""POST /api/projects/<id>/rules — the frontend fast-path endpoint."""
|
|
from scribe.routes import rulebooks as rb_routes
|
|
assert callable(getattr(rb_routes, "create_project_rule"))
|
|
|
|
|
|
def test_suppression_route_handlers_exist():
|
|
"""The 4 suppression endpoint handlers are registered as Python callables."""
|
|
from scribe.routes import rulebooks as rb_routes
|
|
for name in (
|
|
"suppress_project_rule", "unsuppress_project_rule",
|
|
"suppress_project_topic", "unsuppress_project_topic",
|
|
):
|
|
assert callable(getattr(rb_routes, name)), f"missing route handler: {name}"
|
|
|
|
|
|
def test_suppression_association_tables_declared():
|
|
"""Migration 0060 created two new association tables; the models module
|
|
must declare matching Table() objects so the rest of the service layer
|
|
can reference them via .c.<column>."""
|
|
from scribe.models import rulebook as rb_models
|
|
for tbl_name in ("project_rule_suppressions", "project_topic_suppressions"):
|
|
tbl = getattr(rb_models, tbl_name, None)
|
|
assert tbl is not None, f"models.rulebook missing {tbl_name}"
|
|
cols = {c.name for c in tbl.columns}
|
|
assert "project_id" in cols
|
|
assert "rule_id" in cols or "topic_id" in cols
|
|
|
|
|
|
def test_rulebook_model_carries_always_on():
|
|
"""Migration 0058 added rulebooks.always_on — verify the model declares it."""
|
|
from scribe.models.rulebook import Rulebook
|
|
assert "always_on" in Rulebook.__table__.columns
|
|
col = Rulebook.__table__.columns["always_on"]
|
|
assert col.nullable is False
|
|
|
|
|
|
def test_update_rulebook_route_accepts_always_on():
|
|
"""PATCH /api/rulebooks/<id> must pass always_on through to the service.
|
|
|
|
The handler filters body keys against a whitelist; that whitelist needs to
|
|
include always_on or toggling from the UI silently drops the field.
|
|
"""
|
|
import inspect as _inspect
|
|
from scribe.routes import rulebooks as rb_routes
|
|
src = _inspect.getsource(rb_routes.update_rulebook)
|
|
assert "always_on" in src, "update_rulebook handler missing always_on in field whitelist"
|
|
|
|
|
|
def test_rule_and_subscription_handlers_callable():
|
|
from scribe.routes import rulebooks as rb_routes
|
|
for name in (
|
|
"list_rules", "create_rule", "get_rule", "update_rule", "delete_rule",
|
|
"subscribe_project", "unsubscribe_project", "get_project_rules",
|
|
# The typed edges — both doors carry them (rule 33).
|
|
"relate_rules", "unrelate_rules",
|
|
):
|
|
assert callable(getattr(rb_routes, name))
|
|
|
|
|
|
def test_the_rule_list_zero_fills_usage_on_every_row():
|
|
"""Milestone 333 step 5, asserted the only way this harness allows.
|
|
|
|
There is no live-HTTP fixture here (see this module's docstring), so this
|
|
reads the handler's source. What it can still prove is the property that
|
|
gets forgotten: the route must attach the key to EVERY row, zero-filled,
|
|
rather than only to rows that happen to have events. Every rule on every
|
|
existing install predates `rule_usage_events`, so a route that only
|
|
attached the key when it found something would leave the badge component
|
|
reading `undefined` on almost every row — and the difference between "no
|
|
events" and "no field" is exactly the distinction #2663 is about.
|
|
"""
|
|
import inspect
|
|
|
|
from scribe.routes import rulebooks as rb_routes
|
|
|
|
src = inspect.getsource(rb_routes.list_rules)
|
|
assert "usage_for_rules" in src, "the rule list does not read usage at all"
|
|
assert "empty_rule_usage()" in src, (
|
|
"the rule list does not zero-fill — a rule with no events would come "
|
|
"back without the key rather than with an empty one"
|
|
)
|