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
+28
View File
@@ -123,6 +123,17 @@ describe("uploadReq", () => {
expect(reportError).toHaveBeenCalledWith("downloads.errors.upload.tooLarge");
});
it("offers no string detail as the reason on the upload path either", async () => {
const p = uploadReq("/u", form());
const x = FakeXHR.last;
x.status = 405;
x.responseText = '{"detail":"Method Not Allowed"}';
x.onload!();
const err = (await p.catch((e: unknown) => e)) as HttpError;
expect(err).toBeInstanceOf(HttpError);
expect(err.detail).toBeUndefined();
});
it("reports a connectivity failure on a network error", async () => {
const p = uploadReq("/u", form());
FakeXHR.last.onerror!();
@@ -168,6 +179,23 @@ describe("req — a structured refusal reads in the user's language", () => {
});
});
it("never offers a string detail as the reason — only a structured one, translated", async () => {
// Every refusal the app answers is structured; a string can only be the framework's own English
// ("Method Not Allowed"). Surfaces print `err.detail` — some destructured, out of any source
// scan's reach — so the guarantee has to live here, where the value is made.
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ detail: "Method Not Allowed" }), {
status: 405,
headers: { "content-type": "application/json" },
}),
);
const err = (await req("/api/x", { method: "POST" }, { quiet: true }).catch(
(e: unknown) => e,
)) as HttpError;
expect(err).toBeInstanceOf(HttpError);
expect(err.detail).toBeUndefined();
});
it.each(["quota", "reconnect", "unavailable"])(
"says what to do about an action YouTube refused (%s)",
async (reason) => {