fix(api): a gone row and a refused playlist action are said in the reader's language, and a failure is reported once
Deep-review round 3 of bug hunt 2026-09.
- get_or_404 / owned_or_404 answer a structured {code: "not_found", reason: <what>}
(errors.notFound.* in HU+EN, generic fallback). The structured-404 fix of round 2 had gone into the
ad-hoc `_own_playlist` (CLAUDE.md's "Not" pattern) while the shared helpers stayed string-only, so
views, tags, pins, Plex playlists, invites and users still 404'd in English. `_own_playlist` now
goes through owned_or_404; admin's hand-rolled user lookups use get_or_404.
- A move into a list that is gone names the TARGET (reason "move_target") — "this playlist doesn't
exist any more" was read as the list on screen, which still exists.
- Every refusal in the playlists router is a {code: "playlist", reason} (playlists.errors.* HU+EN)
instead of a bare English string shown verbatim in the Hungarian UI.
- `req` / `uploadReq` mark the errors they already put on screen (the error dialog, the connection
notice, the 401 sign-out), and notifyActionError skips those. The watched toggles, the saved views
and a playlist delete toasted a fault the dialog was already showing; Playlists' two hand-written
status filters now go through the same check.
This commit is contained in:
@@ -21,7 +21,7 @@ vi.mock("../activeAccount", () => ({
|
||||
notifyActiveAccountChange: vi.fn(),
|
||||
}));
|
||||
|
||||
import { req, uploadReq, setUnauthorizedHandler, HttpError } from "./core";
|
||||
import { req, uploadReq, setUnauthorizedHandler, HttpError, alreadyReported } from "./core";
|
||||
|
||||
// A fake XHR whose lifecycle the test drives: capture the instance, set status/responseText, then
|
||||
// invoke onload/onerror/abort by hand.
|
||||
@@ -123,15 +123,68 @@ describe("req — a structured refusal reads in the user's language", () => {
|
||||
// The 404 every playlist route answers when the list was deleted in another tab. It used to be a
|
||||
// bare "Unknown playlist" string, shown verbatim under the Hungarian UI.
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail: { code: "playlist", reason: "not_found" } }), {
|
||||
new Response(JSON.stringify({ detail: { code: "not_found", reason: "playlist" } }), {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
await expect(req("/api/playlists/7/items/v1", { method: "DELETE" })).rejects.toMatchObject({
|
||||
status: 404,
|
||||
detail: "playlists.errors.not_found",
|
||||
detail: "errors.notFound.playlist",
|
||||
});
|
||||
expect(reportError).not.toHaveBeenCalled(); // a 404 is the caller's to report
|
||||
});
|
||||
});
|
||||
|
||||
describe("req — what it has already put in front of the user", () => {
|
||||
// A caller that toasts a failed action on top of the dialog `req` already opened reported one fault
|
||||
// twice. `alreadyReported` is how `notifyActionError` tells the two apart.
|
||||
const answer = (status: number) =>
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail: "x" }), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
it.each([500, 422, 409, 400])("marks a %s it raised the error dialog for", async (status) => {
|
||||
answer(status);
|
||||
const err = await req("/api/x", { method: "POST" }).catch((e: unknown) => e);
|
||||
expect(reportError).toHaveBeenCalled();
|
||||
expect(alreadyReported(err)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([404, 403, 429])("leaves a %s to the caller", async (status) => {
|
||||
answer(status);
|
||||
const err = await req("/api/x", { method: "POST" }).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(HttpError);
|
||||
expect(alreadyReported(err)).toBe(false);
|
||||
});
|
||||
|
||||
it("marks a 401 the app shell handles (it signs the tab out)", async () => {
|
||||
answer(401);
|
||||
const err = await req("/api/x", { method: "POST" }).catch((e: unknown) => e);
|
||||
expect(alreadyReported(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("marks a request that never reached the server (the connection notice says it)", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
const err = await req("/api/x", { method: "POST" }).catch((e: unknown) => e);
|
||||
expect(notify).toHaveBeenCalled();
|
||||
expect(alreadyReported(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("marks what the upload path reported the same way", async () => {
|
||||
const dead = uploadReq("/u", form());
|
||||
FakeXHR.last.onerror!();
|
||||
expect(alreadyReported(await dead.catch((e: unknown) => e))).toBe(true);
|
||||
const refused = uploadReq("/u", form());
|
||||
FakeXHR.last.status = 500;
|
||||
FakeXHR.last.onload!();
|
||||
expect(alreadyReported(await refused.catch((e: unknown) => e))).toBe(true);
|
||||
const gone = uploadReq("/u", form());
|
||||
FakeXHR.last.status = 404;
|
||||
FakeXHR.last.onload!();
|
||||
expect(alreadyReported(await gone.catch((e: unknown) => e))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,14 @@ import { activeAccountId, ACTIVE_ACCOUNT_KEY, notifyActiveAccountChange } from "
|
||||
* field nobody ever filled (E9.5 S6 → S7). */
|
||||
export const uiLang = (): string => i18n.language.split("-")[0] || "en";
|
||||
|
||||
// Errors `req` has already put in front of the user — the blocking error dialog, or the connection
|
||||
// notice. A caller that toasts a failed action as well reported the same fault twice (a modal AND a
|
||||
// toast); `notifyActionError` asks this instead of each caller filtering statuses by hand.
|
||||
const reported = new WeakSet<object>();
|
||||
export function alreadyReported(err: unknown): boolean {
|
||||
return typeof err === "object" && err !== null && reported.has(err);
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
status: number;
|
||||
detail?: string; // server-provided reason (FastAPI's `detail`), when present
|
||||
@@ -82,8 +90,13 @@ function localizeDetail(d: unknown): string {
|
||||
// unknown reason falls back to the plain failure rather than printing a key.
|
||||
if (o?.code === "playlist_move")
|
||||
return t(`playlists.moveErrors.${o.reason}`, t("playlists.moveFailed"));
|
||||
// Any playlist route whose list is gone (deleted in another tab) — or was never the caller's.
|
||||
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
|
||||
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
|
||||
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's
|
||||
// row 404s the same way). `reason` names WHAT is gone; one this build doesn't know says it
|
||||
// generically rather than printing a key.
|
||||
if (o?.code === "not_found")
|
||||
return t(`errors.notFound.${o.reason}`, t("errors.notFound.generic"));
|
||||
// SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace
|
||||
// limit, and the copy says so.
|
||||
if (o?.code === "sync_cooldown") return t("errors.syncCooldown");
|
||||
@@ -223,6 +236,7 @@ export async function req<T = unknown>(
|
||||
continue;
|
||||
}
|
||||
markConnectivityLost();
|
||||
if (typeof e === "object" && e !== null) reported.add(e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -257,20 +271,26 @@ export async function req<T = unknown>(
|
||||
// self-clearing toast rather than a blocking modal the user must acknowledge.
|
||||
// A genuine 500 (real fault, carries a JSON detail) still gets the modal, as do
|
||||
// 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow.
|
||||
const err = new HttpError(r.status, detail, detailData);
|
||||
if (RETRIABLE_GATEWAY.has(r.status)) {
|
||||
markConnectivityLost();
|
||||
reported.add(err);
|
||||
} else if (r.status === 401) {
|
||||
// Session ended server-side (e.g. the account was suspended or deleted mid-visit). Let the
|
||||
// app shell decide what to do (drop to the login page if we were signed in). Not a modal.
|
||||
// app shell decide what to do (drop to the login page if we were signed in). Not a modal —
|
||||
// but handled all the same, so not the caller's to toast either.
|
||||
onUnauthorized?.();
|
||||
reported.add(err);
|
||||
} else if (cfg.quiet) {
|
||||
/* caller handles the error inline — no global modal */
|
||||
} else if (r.status >= 500) {
|
||||
reportError(detail || `${i18n.t("errors.server")} (${r.status})`);
|
||||
reported.add(err);
|
||||
} else if (r.status === 400 || r.status === 409 || r.status === 422) {
|
||||
reportError(detail);
|
||||
reported.add(err);
|
||||
}
|
||||
throw new HttpError(r.status, detail, detailData);
|
||||
throw err;
|
||||
}
|
||||
// The single audited cast in this layer: the server contract is asserted here, once, so
|
||||
// every typed api.* method (T comes from its declared return type) is checked from here up.
|
||||
@@ -326,16 +346,21 @@ export function uploadReq<T = unknown>(
|
||||
} catch {
|
||||
/* no JSON body */
|
||||
}
|
||||
const err = new HttpError(xhr.status, detail, detailData);
|
||||
if (xhr.status === 401) onUnauthorized?.();
|
||||
else if (xhr.status >= 500)
|
||||
reportError(detail || `${i18n.t("errors.server")} (${xhr.status})`);
|
||||
else if (xhr.status === 400 || xhr.status === 409 || xhr.status === 422) reportError(detail);
|
||||
reject(new HttpError(xhr.status, detail, detailData));
|
||||
if (xhr.status === 401 || xhr.status >= 500 || [400, 409, 422].includes(xhr.status))
|
||||
reported.add(err); // as in `req`: already on screen, not the caller's to toast again
|
||||
reject(err);
|
||||
};
|
||||
// A network-level failure (server unreachable) — same soft "connection lost" status as `req`.
|
||||
xhr.onerror = () => {
|
||||
markConnectivityLost();
|
||||
reject(new HttpError(0, i18n.t("errors.offline.body")));
|
||||
const err = new HttpError(0, i18n.t("errors.offline.body"));
|
||||
reported.add(err);
|
||||
reject(err);
|
||||
};
|
||||
xhr.onabort = () => reject(new HttpError(0, "aborted"));
|
||||
xhr.send(form);
|
||||
|
||||
Reference in New Issue
Block a user