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:
@@ -22,7 +22,14 @@ vi.mock("../activeAccount", () => ({
|
||||
notifyActiveAccountChange: vi.fn(),
|
||||
}));
|
||||
|
||||
import { req, uploadReq, setUnauthorizedHandler, HttpError, alreadyReported } from "./core";
|
||||
import {
|
||||
req,
|
||||
uploadReq,
|
||||
setUnauthorizedHandler,
|
||||
HttpError,
|
||||
alreadyReported,
|
||||
isAborted,
|
||||
} from "./core";
|
||||
|
||||
// A fake XHR whose lifecycle the test drives: capture the instance, set status/responseText, then
|
||||
// invoke onload/onerror/abort by hand.
|
||||
@@ -74,11 +81,15 @@ describe("uploadReq", () => {
|
||||
await expect(p).resolves.toEqual({ id: 7 });
|
||||
});
|
||||
|
||||
// Both abort producers are asserted through `isAborted`, not through `{status: 0}`: what the
|
||||
// upload dialog reads is the SENTINEL, and a cancelled upload that stops matching it becomes a
|
||||
// red "the upload failed" toast. Changing either `reject` to a friendlier message left every
|
||||
// test green, because the only consumer test hand-builds the error it expects (round 6).
|
||||
it("rejects immediately if the signal is already aborted (never sends)", async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
const p = uploadReq("/u", form(), { signal: ac.signal });
|
||||
await expect(p).rejects.toBeInstanceOf(HttpError);
|
||||
await expect(p).rejects.toSatisfy(isAborted);
|
||||
expect(FakeXHR.last?.sent).not.toBe(true);
|
||||
});
|
||||
|
||||
@@ -86,7 +97,7 @@ describe("uploadReq", () => {
|
||||
const ac = new AbortController();
|
||||
const p = uploadReq("/u", form(), { signal: ac.signal });
|
||||
ac.abort(); // → our abort listener calls xhr.abort() → onabort → reject
|
||||
await expect(p).rejects.toMatchObject({ status: 0 });
|
||||
await expect(p).rejects.toSatisfy(isAborted);
|
||||
});
|
||||
|
||||
it("calls the unauthorized handler on 401", async () => {
|
||||
|
||||
@@ -85,6 +85,10 @@ function localizeDetail(d: unknown): string {
|
||||
// a raw key, which is what an i18n miss would otherwise put on screen.
|
||||
if (o?.code === "login")
|
||||
return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError"));
|
||||
// The shared demo account refusing a personal-state write (`require_human`). Not every affordance
|
||||
// behind that gate is UI-gated on `is_demo` — the comfort shelf is not — so without this the demo
|
||||
// user got their caller's generic "couldn't do that" and no reason at all (round 6).
|
||||
if (o?.code === "demo") return t("errors.demo");
|
||||
// The two email-link confirmations. Their failure is one a user meets by simply clicking an old
|
||||
// link, so it is the last place that may answer in hardcoded English under a bilingual UI.
|
||||
if (o?.code === "verify") return t("welcome.auth.verifyInvalid");
|
||||
|
||||
Reference in New Issue
Block a user