fix(i18n): every refusal the API answers is structured and said in the reader's language

~150 HTTPExceptions carried a hand-written English sentence as their detail,
shown verbatim under the Hungarian UI — in the blocking error dialog and on
every inline surface that printed HttpError.detail (sign-in, setup wizard,
change password, the passphrase gate, live search). Four also quoted an
upstream exception's text back to the user.

- app/refusal.py: `refused(status, reason, **params)` → {code: "refused",
  reason, …params}; localizeDetail translates errors.refused.<reason> with the
  params interpolated. "Unknown X" 404s use the existing not_found(what).
- sysconfig.set_value raises InvalidSetting (reason + bounds) instead of an
  English ValueError; the three writers answer it through setting_refused.
- The Plex connection/write/image failures log what the upstream said and
  answer a reason; the search scraper's failure is logged and answered as
  youtube/unavailable (it crashed refusal_reason before).
- HttpError.detail now only ever holds a translated reason: a string detail
  (only the framework's own can remain) is left out, so reading .detail is
  safe everywhere — including destructured, the hole the raw-detail source
  scan could not see. That scan and its five-file exception list are gone.
- Enforced: test_http_details.py fails on any HTTPException with a sentence
  for a detail; the contract test requires every refused reason and every
  not_found "what" in both languages, and no key for one that is gone.
- local/jsx-comment-text: a // inside JSX children renders as text; the rule
  is ten lines in eslint.config.js rather than eslint-plugin-react's ~1800
  lockfile lines.
This commit is contained in:
2026-09-19 07:34:17 +02:00
parent dc4e8b9fcd
commit 7ae5c57b6b
39 changed files with 716 additions and 378 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ def test_malformed_feed_cursor_returns_400(db, monkeypatch):
db.commit()
resp = _get(db, user, monkeypatch, "/api/feed?cursor=not-a-real-cursor")
assert resp.status_code == 400
assert resp.json()["detail"] == "Invalid feed cursor"
assert resp.json()["detail"] == {"code": "refused", "reason": "invalid_cursor"}
def test_feed_without_cursor_is_200(db, monkeypatch):
+53
View File
@@ -0,0 +1,53 @@
"""No refusal answers in a hand-written English sentence.
The UI is bilingual and translates a structured `detail` (`{code, reason, …}`), but a string detail it
can only print as it came — so ~150 of them reached the Hungarian UI verbatim, in the blocking error
dialog and on every inline surface that shows `HttpError.detail`. `app/refusal.py` is the family they
became; this test is what keeps a new one from being written the old way. Pure: it reads the sources.
"""
import ast
import pathlib
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
def _is_text(node: ast.AST) -> bool:
"""A detail that is a sentence: a literal, an f-string, `str(exc)`, or `"…" + x`."""
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return True
if isinstance(node, ast.JoinedStr):
return True
if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "str":
return True
if isinstance(node, ast.BoolOp):
return any(_is_text(v) for v in node.values)
if isinstance(node, ast.BinOp):
return _is_text(node.left) or _is_text(node.right)
return False
def test_no_http_exception_carries_a_sentence():
offenders = []
for path in sorted(APP.rglob("*.py")):
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
if not (
isinstance(node, ast.Call)
and getattr(node.func, "id", getattr(node.func, "attr", "")) == "HTTPException"
):
continue
kw = {k.arg: k.value for k in node.keywords}
detail = kw.get("detail", node.args[1] if len(node.args) > 1 else None)
if detail is not None and _is_text(detail):
offenders.append(f"{path.relative_to(APP).as_posix()}:{node.lineno}")
assert offenders == [], (
"a refusal with an English sentence for a detail — raise `refused(status, reason)` "
f"(app/refusal.py) and name the reason in both locales: {offenders}"
)
def test_the_scan_sees_a_sentence_when_there_is_one():
"""A guard that matches nothing is green over nothing."""
sample = ast.parse('HTTPException(status_code=400, detail=f"bad {x}")').body[0].value
assert _is_text(sample.keywords[1].value)
assert not _is_text(ast.parse('{"code": "refused", "reason": "x"}').body[0].value)
@@ -346,7 +346,7 @@ def test_create_resync_failure_is_422(db, monkeypatch):
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
)
assert r.status_code == 422, r.text
assert "reconcile" in r.json()["detail"].lower()
assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
assert "create_collection" in stub.calls
finally:
app.dependency_overrides.clear()
@@ -361,7 +361,7 @@ def test_mutation_failure_returns_422_write_failed(db, monkeypatch):
try:
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
assert r.status_code == 422, r.text
assert "write failed" in r.json()["detail"].lower()
assert r.json()["detail"] == {"code": "refused", "reason": "plex_write_failed"}
finally:
app.dependency_overrides.clear()
@@ -389,7 +389,7 @@ def test_resync_failure_after_successful_write_is_422_and_rolls_back(db, monkeyp
try:
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
assert r.status_code == 422, r.text
assert "reconcile" in r.json()["detail"].lower()
assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
assert "rename_collection" in stub.calls # the write DID happen
finally:
app.dependency_overrides.clear()
+1 -1
View File
@@ -153,4 +153,4 @@ def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch):
app.dependency_overrides.clear()
assert resp.status_code == 422, resp.text
assert "YouTube search failed" in resp.json()["detail"]
assert resp.json()["detail"] == {"code": "youtube", "reason": "unavailable"}