Files
siftlode/backend/tests/test_route_conventions.py
T
peter 0ca0cac534 fix(api): a waiting reorder deals from the fresh order; the commit check sees staged writes
Round 2 of the deep review, on round 1's own fixes:
- `ORDER BY position … FOR UPDATE` sorts before it locks, so the reorder that waited got the
  fresh rows in its STALE order and dealt the pins it left out (a tab with Plex off) back to their
  old slots. It locks in id order — one global order, so two reorders cannot deadlock — and sorts
  the fresh rows itself. The race test now sends that partial list and fails unless tab B really
  blocked.
- The request check saw only writes the handler flushed: a `db.delete` left staged, or a flush
  followed by a released savepoint, passed. Staged changes count at the end of the request, and
  only the ROOT transaction's end clears it (a savepoint test drives the dependency directly).
- The `ok` test exempted every call as a serializer, hiding four domain REPORTS (YouTube sync,
  revert, push; Plex sync) without `ok`. They are spread into `{"ok": True, …}`; the exemption is a
  named list of real serializers.
2026-09-19 19:30:27 +02:00

119 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 three "
"callers rely on (`_register_account` persists its own flushed user because a no-op here "
"commits nothing)",
"messages.py:ensure_welcome": "the websocket push after it must announce a row the client "
"can already fetch",
}
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_demo",
"_serialize_job",
"_serialize_msg",
"_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)