fix(api,ui): a refused write says why, wherever the refusal happens
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.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// One contract, one implementation — `notifyActionError` (see actionErrors.ts).
|
||||
//
|
||||
// `req` deliberately leaves 403/404/429 to the caller, and the routes behind those statuses
|
||||
// hand-write English. Round 5 taught the shared helper to discard a plain-string `detail` in favour
|
||||
// of the caller's already-translated fallback. Round 6 then found `ChatThread` carrying a SECOND,
|
||||
// hand-written copy of the same contract (`e.detail || t("messages.sendFailed")`) — so the central
|
||||
// fix could not reach it and the English sentence stayed in a Hungarian UI. A fix that lands in one
|
||||
// place while a copy of the rule lives in another is this repo's recurring smell; this is the check
|
||||
// that names the copies.
|
||||
//
|
||||
// The assertion pins the expected SET, never a count: a guard that counts is green the moment its
|
||||
// glob stops matching (E10 review round 4 learned that the hard way).
|
||||
|
||||
// Reads a raw `HttpError.detail` and uses it as the message a user sees. Matches `e.detail ||`,
|
||||
// `err.detail ? err.detail :` and `(e instanceof HttpError ? e.detail : undefined) ??`.
|
||||
const RAW_DETAIL = /\b[A-Za-z_$][\w$]*\.detail\b(?!\s*(?:=[^=]|\)))/;
|
||||
|
||||
// Every place that still prefers the server's raw string over a translated fallback. These are
|
||||
// INLINE surfaces — a form field's error line, a setup step's banner — not toasts, and each shows a
|
||||
// reason the user is mid-flow on, so converting them is a sweep of its own (with the ~60 backend
|
||||
// routes that hand-write those strings). Logged on ROADMAP.md under the bug-hunt deferrals. What
|
||||
// must not happen is a NEW one, or a toast path growing one again.
|
||||
const KNOWN_RAW_DETAIL_CONSUMERS = [
|
||||
"components/Feed.tsx", // the live-YouTube search error line
|
||||
"components/KeyGate.tsx", // the passphrase field's own refusal
|
||||
"components/SettingsPanel.tsx", // the change-password field
|
||||
"components/SetupWizard.tsx", // the first-run wizard's banner
|
||||
"components/Welcome.tsx", // the sign-in card's refusal panel
|
||||
];
|
||||
|
||||
// Where the rule itself lives, plus the request layer that produces and localizes the value.
|
||||
const IMPLEMENTATION = ["lib/actionErrors.ts", "lib/api/core.ts"];
|
||||
|
||||
function consumers(): string[] {
|
||||
const files = import.meta.glob("../**/*.{ts,tsx}", {
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
eager: true,
|
||||
});
|
||||
const hits: string[] = [];
|
||||
for (const [rel, source] of Object.entries(files)) {
|
||||
// Vite resolves the glob against THIS file, so same-directory hits come back as "./x".
|
||||
const path = rel.startsWith("./") ? `lib/${rel.slice(2)}` : rel.replace(/^\.\.\//, "");
|
||||
if (/\.test\.tsx?$|\.d\.ts$/.test(path)) continue;
|
||||
if (IMPLEMENTATION.includes(path)) continue;
|
||||
const lines = String(source)
|
||||
.split("\n")
|
||||
.filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)); // a comment about the rule is not a copy of it
|
||||
if (lines.some((l) => RAW_DETAIL.test(l))) hits.push(path);
|
||||
}
|
||||
return hits.sort();
|
||||
}
|
||||
|
||||
describe("the refused-write contract", () => {
|
||||
it("has no copies beyond the ones on the deferral list", () => {
|
||||
// The glob must actually match something, or this whole file is a green no-op.
|
||||
expect(readFileSync(new URL("./actionErrors.ts", import.meta.url), "utf8")).toContain(
|
||||
"detailData",
|
||||
);
|
||||
expect(consumers()).toEqual(KNOWN_RAW_DETAIL_CONSUMERS);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user