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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user