Files
siftlode/backend/tests/test_route_conventions.py
T
peter 6d8b7be6d8 feat!: remove the shared demo account
The demo account — one shared user that whitelisted emails entered from
the login page, reset on a schedule — goes end to end (owner decision,
E34): demo.py and the reset job, users.is_demo and demo_whitelist
(migration 0098 deletes the demo user, its audit rows, the reset job's
override and notices, then drops the column and table; the downgrade
restores the empty schema), the /auth/demo entry, the admin whitelist and
reset, the Demo tab, the banner, the login page's "Try the demo", and
every is_demo branch in routes, userscope, scheduler and the UI.

require_human only ever refused the demo account, so its 87 uses become
current_user (owner-approved); require_write_scope keeps its own check.
The login page's "or" divider now shows only beside the Google button:
without the demo entry under it, an instance without Google would have
said "or" to nothing.

A contract test (removedFeatures) fails on the word anywhere in the
backend, frontend, e2e or scripts — seen red on a planted hit. Size
ceilings tightened for the modules that shrank; admin.py is back under
the limit.
2026-10-05 00:19:38 +02:00

117 lines
4.9 KiB
Python

"""Two rows of CLAUDE.md's canonical-patterns table, held in code rather than prose.
A rule stated only in a comment drifts: this one had — 63 mutation responses without `ok`, and
twelve route helpers that committed on their own, among them an approval whose decision, activation
and token landed in three separate transactions. The debt burn of 2026-09 fixed the drift; these keep
it fixed.
"""
import ast
from pathlib import Path
ROUTES = Path(__file__).parent.parent / "app" / "routes"
MUTATING = {"post", "put", "patch", "delete"}
# A route helper that commits where it stands, and why the commit has to be there rather than in
# the handler. Anything else flushes and leaves the commit to its handler.
COMMITTING_HELPERS = {
"auth.py:_issue_token": "the token must be durable before the link that carries it is mailed",
"auth.py:_consume_token": "burning a single-use token is its own unit: a later failure must "
"not hand the token back",
"auth.py:_complete_link": "the tail of the Google callback route, which answers with its result",
"auth.py:_register_account": "a background task with its own session, i.e. a job wrapper",
"search.py:_ingest_candidates": "stages around YouTube calls: the stubs must survive an enrich "
"that fails",
"search.py:_classify_shorts": "the last stage of that ingest, after its probes",
"auth.py:upsert_pending_invite": "commits only when it writes — the contract its two "
"callers rely on (`_register_account` persists its own flushed user because a no-op here "
"commits nothing)",
"debug_sessions.py:_end_send": "gives back the claim a send took, in a `finally`: it runs when "
"the handler is already unwinding and has no commit left to reach",
}
def _modules():
for path in sorted(ROUTES.rglob("*.py")):
yield path.relative_to(ROUTES).as_posix(), ast.parse(path.read_text(encoding="utf-8"))
FUNCTIONS = (ast.FunctionDef, ast.AsyncFunctionDef)
# The calls a mutation may answer with in place of an acknowledgement: each describes the resource
# it wrote (or is the logout's cookie-clearing response). A domain function's REPORT dict is not one
# of them — `{"synced": n}` is an acknowledgement, and gets spread into `{"ok": True, **report}`.
SERIALIZERS = {
"JSONResponse",
"_collection_card",
"_playlist_card",
"_serialize",
"_serialize_job",
"_serialize_profile",
"_serialize_user",
"_summary",
"admin_get_quota",
"describe",
"owner_view",
}
def _called(node: ast.Call) -> str:
f = node.func
return f.id if isinstance(f, ast.Name) else getattr(f, "attr", "")
def _is_route(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
return any(
isinstance(d, ast.Call) and getattr(d.func, "attr", "") in MUTATING | {"get", "websocket"}
for d in fn.decorator_list
)
def _is_mutation_route(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
return any(
isinstance(d, ast.Call) and getattr(d.func, "attr", "") in MUTATING
for d in fn.decorator_list
)
def test_every_mutation_answers_ok():
"""`{"ok": true, …}` (or `{"deleted": id}`) for every mutation, sync or async — one field a
client can read the same way everywhere. A serializer's return (SERIALIZERS) is exempt: it
describes a resource, not an acknowledgement. A dict built elsewhere — returned by name, or
straight from a domain call — is not: both hid acknowledgements without `ok` from a
literal-only scan, so such a dict is spread into a literal."""
missing = []
for name, tree in _modules():
for fn in ast.walk(tree):
if not (isinstance(fn, FUNCTIONS) and _is_mutation_route(fn)):
continue
for node in ast.walk(fn):
if not isinstance(node, ast.Return):
continue
if isinstance(node.value, ast.Call) and _called(node.value) in SERIALIZERS:
continue
keys = (
{k.value for k in node.value.keys if isinstance(k, ast.Constant)}
if isinstance(node.value, ast.Dict)
else set()
)
if not keys & {"ok", "deleted"}:
missing.append(f"{name}:{node.lineno} {fn.name}")
assert missing == []
def test_route_helpers_flush_and_leave_the_commit_to_the_handler():
"""Every function in a route module that is not itself a route is a helper — whatever its name
(two public ones committed unseen while this looked only at `_`-names) and sync or async."""
committing = set()
for name, tree in _modules():
for fn in ast.walk(tree):
if isinstance(fn, FUNCTIONS) and not _is_route(fn):
if any(
isinstance(n, ast.Call) and getattr(n.func, "attr", "") == "commit"
for n in ast.walk(fn)
):
committing.add(f"{name}:{fn.name}")
assert committing == set(COMMITTING_HELPERS)