fix(ui): a write that fails says what failed and why, wherever it fails
`req` opens its blocking dialog for 400/409/422/5xx only. A 403/404/429 was left to the caller and a 502-504 or lost connection got just the self-clearing connection notice, so 34 mutations without a reporting onError and 15 direct api writes with a bare or missing catch failed in silence: a hidden channel that snapped back, a share link that never revoked, a confirmed account delete that ended with nothing said. - lib/queryClient: one MutationCache reporter. react-query awaits it BEFORE the mutation's own onError, so it waits a task and skips a failure the site already reported; the toast title comes from `meta.failTitle`, `meta.errorShownInline` opts out. - notifyActionError: title names WHAT failed, the line under it says WHY (`failureReason`: a structured localized detail, else the status). It marks the error reported (`markReported`), so nothing reports one failure twice; `notifyKnownFailure` for a site's own specific sentence. - Every silent site named (45 new HU/EN titles, 4 reasons). Stale "the global dialog shows it" comments corrected on the job actions, channel block, Plex sync, cookies, download enqueue, account delete and usePref. - Channels passed the tag hook's channel id to the reporter as the error; the tag and priority hooks now report themselves, with the real error. - A contract test keeps every useMutation naming its failure; CLAUDE.md gains the pattern row. - history.dom.test: the "navigates only once the entries are back" assertion raced an async act's macrotask flush (1 in 5 runs); it now asserts from a later popstate listener.
This commit is contained in:
@@ -8,9 +8,10 @@ vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) }));
|
||||
vi.mock("./errorDialog", () => ({ reportError: vi.fn() }));
|
||||
|
||||
import { HttpError, api } from "./api";
|
||||
import { alreadyReported } from "./api/core";
|
||||
import { notify } from "./notifications";
|
||||
import { reportError } from "./errorDialog";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
import { failureReason, notifyActionError, notifyKnownFailure } from "./actionErrors";
|
||||
import i18n from "../i18n";
|
||||
|
||||
const notifyMock = vi.mocked(notify);
|
||||
@@ -33,7 +34,7 @@ describe("a Plex collection name that is already taken", () => {
|
||||
notifyMock.mockClear();
|
||||
notifyActionError(err, "EDIT_FAILED");
|
||||
expect(notifyMock.mock.calls).toEqual([
|
||||
[{ level: "error", message: i18n.t("plex.collection.nameTaken") }],
|
||||
[{ level: "error", title: "EDIT_FAILED", message: i18n.t("plex.collection.nameTaken") }],
|
||||
]);
|
||||
expect(reportError).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -60,12 +61,30 @@ describe("notifyActionError", () => {
|
||||
expect(notifyMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports one failure once, however many callers try", () => {
|
||||
// What keeps the app-wide mutation reporter quiet for a mutation whose own onError already spoke.
|
||||
const err = new HttpError(404, "gone");
|
||||
notifyActionError(err, "FIRST");
|
||||
notifyActionError(err, "SECOND");
|
||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||
expect(alreadyReported(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("says nothing about an upload the user cancelled", () => {
|
||||
notifyActionError(new HttpError(0, "aborted"), "FAILED");
|
||||
expect(notifyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says a bare-English server reason in the reader's language, not the server's", () => {
|
||||
// `reportFailure` leaves 403/404/429 to the caller, so for exactly those statuses whatever
|
||||
// English a route hand-wrote went straight into the toast — "No user with that email." in an
|
||||
// otherwise Hungarian UI, while the caller's translated fallback sat unused (round 5).
|
||||
notifyActionError(new HttpError(404, "No user with that email."), "TRANSLATED");
|
||||
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "TRANSLATED" });
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: "TRANSLATED",
|
||||
message: i18n.t("errors.notFound.generic"),
|
||||
});
|
||||
});
|
||||
|
||||
it("tells a demo user that the demo account is the reason", async () => {
|
||||
@@ -84,13 +103,14 @@ describe("notifyActionError", () => {
|
||||
notifyActionError(err, "GENERIC FALLBACK");
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: "GENERIC FALLBACK",
|
||||
message: i18n.t("errors.demo"),
|
||||
});
|
||||
});
|
||||
|
||||
it("still prefers a STRUCTURED reason, which the request layer has already localized", () => {
|
||||
// `req` runs `{code, reason}` through localizeDetail, so `detail` is in the reader's language
|
||||
// AND more specific than the caller's generic fallback — quota numbers, which row is gone.
|
||||
// AND more specific than anything a status can say — quota numbers, which row is gone.
|
||||
const err = new HttpError(404, "Ez a csatorna már nincs a könyvtárban.", {
|
||||
code: "not_found",
|
||||
reason: "channel",
|
||||
@@ -98,7 +118,54 @@ describe("notifyActionError", () => {
|
||||
notifyActionError(err, "TRANSLATED FALLBACK");
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: "TRANSLATED FALLBACK",
|
||||
message: "Ez a csatorna már nincs a könyvtárban.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("failureReason — why a write failed, when the server did not say", () => {
|
||||
it.each([
|
||||
[403, "errors.reason.forbidden"],
|
||||
[404, "errors.notFound.generic"],
|
||||
[429, "errors.reason.rateLimited"],
|
||||
[502, "errors.reason.unreachable"],
|
||||
[503, "errors.reason.unreachable"],
|
||||
[504, "errors.reason.unreachable"],
|
||||
[0, "errors.reason.unreachable"], // uploadReq's network failure
|
||||
[500, "errors.generic"],
|
||||
])("a %s reads %s", (status, key) => {
|
||||
expect(failureReason(new HttpError(status, "hand-written English"))).toBe(i18n.t(key));
|
||||
});
|
||||
|
||||
it("a request that never reached the server reads as unreachable", () => {
|
||||
expect(failureReason(new TypeError("Failed to fetch"))).toBe(
|
||||
i18n.t("errors.reason.unreachable"),
|
||||
);
|
||||
});
|
||||
|
||||
it("anything else is the app's own fault, not the server's", () => {
|
||||
expect(failureReason(new Error("key import failed"))).toBe(i18n.t("errors.reason.unexpected"));
|
||||
});
|
||||
|
||||
it("every reason exists in both languages", () => {
|
||||
for (const lng of ["en", "hu"])
|
||||
for (const key of [
|
||||
"errors.reason.forbidden",
|
||||
"errors.reason.rateLimited",
|
||||
"errors.reason.unreachable",
|
||||
"errors.reason.unexpected",
|
||||
])
|
||||
expect(i18n.exists(key, { lng }), `${lng}:${key}`).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notifyKnownFailure", () => {
|
||||
it("shows the caller's specific sentence and marks the failure reported", () => {
|
||||
const err = new HttpError(409, "x", { code: "resend", reason: "email_off" });
|
||||
notifyKnownFailure(err, "EMAIL IS OFF");
|
||||
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "EMAIL IS OFF" });
|
||||
notifyActionError(err, "GENERIC");
|
||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user