Round 5 taught `notifyActionError` to discard a plain-string `detail` in favour
of the caller's translated fallback, because `req` leaves 403/404/429 to the
caller and those routes hand-write English. Three places did not get the
message.
**The demo account lost its explanation.** `require_human` refused with a plain
string, so from that commit on the shared demo account got its caller's generic
"couldn't do that" and no hint that the demo account was the reason — and the
gate is not backed by a UI gate everywhere: the comfort shelf has no `is_demo`
check, so the pin button is live and simply fails. A structured `{code: "demo"}`
and an `errors.demo` string in both locales. The two auth routes that hand-roll
the same check were swept with it.
**`ChatThread` carried a second copy of the contract.** It filtered 404/429
itself and then did `e.detail || t(…)`, the pre-fix order, so the central fix
could not reach it and "That member hasn't set up messaging yet." stayed English
under a Hungarian UI. It goes through the shared helper now, and a contract test
pins the remaining raw-`detail` consumers as an explicit SET — never a count —
so a new copy fails the gate instead of shipping. The five on that list are
inline surfaces (a form field's error line, a setup banner) and are deferred
with the ~60 backend routes whose details they print.
**The Plex "Add to playlist" dialog was still silent.** Same shape as the
collection editor round 5 fixed and the YouTube twin that already had the fix:
no catch, so a playlist deleted on another device answered a structured 404 that
nothing reported — the tick never flipped, the gone row stayed, and the floating
promise became an unhandled rejection.
Also: the `ABORTED` sentinel's VALUE was asserted nowhere. The consumer test
hand-built the error it expected, so replacing `xhr.onabort`'s rejection with a
friendlier message left the lane green — and every upload the user cancels would
then raise a red "the upload failed" toast, which is what the sentinel prevents.
Both producer tests assert through `isAborted` now.
And two backend tests were pinning fewer sites than their own docstrings claimed:
the channel-404 loop said "all five" and issued three requests (explore and
subscribe unexercised, the excuse for subscribe naming a different call site
with a different reason slug), and the recipient/job test named three refusals
and requested two — `share_download`'s `not_found("recipient")` had no test in
the suite at all, nor did the locale keys added beside it. Every site named now
has a request behind it.
6 tests, mutation-checked against 7 mutations: the demo detail at both the gate
and an auth route, a re-hand-rolled ChatThread, the silent Plex dialog, the
abort sentinel, and each unpinned 404 — all red on the test that names them.
48 lines
2.5 KiB
Python
48 lines
2.5 KiB
Python
"""The shared demo account's refusal says WHY, in the reader's language (deep-review round 6).
|
|
|
|
`require_human` answered `403 "Not available in the demo account."` — a plain string. `req` leaves
|
|
403 to the caller, and round 5 taught `notifyActionError` to discard a plain-string detail in favour
|
|
of the caller's already-translated fallback, so from that commit on the demo user got a generic
|
|
"couldn't do that" and no hint that the demo account was the reason. Not every affordance behind
|
|
this gate is UI-gated on `is_demo` either — the comfort shelf is not, so the pin button is live.
|
|
|
|
A structured `{code: "demo"}` is what `localizeDetail` can turn into `errors.demo` in both locales.
|
|
Pinned at all three sites: the shared dependency and the two auth routes that hand-roll the same
|
|
check, because an asymmetric fix is the smell this repo bans. Needs the DB.
|
|
"""
|
|
|
|
from app.models import User
|
|
|
|
|
|
def test_every_demo_refusal_carries_the_same_structured_reason(db, make_user, as_user):
|
|
demo = make_user(email="demo@example.com", is_demo=True)
|
|
client = as_user(demo)
|
|
for method, url, body in [
|
|
# `require_human` itself, through an affordance the UI does NOT gate on is_demo.
|
|
("post", "/api/discover/pins", {"kind": "video", "ref": "v1"}),
|
|
# The two routes that repeat the check inline rather than depending on the gate.
|
|
("post", "/auth/logout-others", None),
|
|
("post", "/auth/set-password", {"password": "a long enough password"}),
|
|
]:
|
|
r = getattr(client, method)(url, **({"json": body} if body is not None else {}))
|
|
assert r.status_code == 403, (url, r.text)
|
|
assert r.json()["detail"] == {"code": "demo"}, url
|
|
|
|
|
|
def test_a_real_account_is_not_refused_by_the_gate(db, make_user, as_user):
|
|
"""The other half: the structured detail must not come from having broken the gate open the
|
|
other way. A non-demo account gets past `require_human` and fails (or succeeds) on its own
|
|
merits — anything but a 403 demo refusal."""
|
|
client = as_user(make_user(email="real@example.com"))
|
|
r = client.post("/auth/logout-others")
|
|
assert r.status_code == 200, r.text
|
|
assert not isinstance(r.json().get("detail"), dict)
|
|
|
|
|
|
def test_the_gate_reads_the_flag_and_not_the_email(db):
|
|
"""`is_demo` is a column, not a convention about the address — a real account that happens to
|
|
be called demo@ must not be refused."""
|
|
from app.auth import require_human
|
|
|
|
assert require_human(User(email="demo@example.com", is_demo=False)) is not None
|