fix(notifications): a notice keeps what to say and says it in the reader's language when shown

This commit is contained in:
2026-10-29 22:37:14 +01:00
parent 6986eb1937
commit 4ef772d37b
125 changed files with 973 additions and 674 deletions
+46 -24
View File
@@ -4,12 +4,16 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Spy on the toast sink so we can assert exactly what each error maps to.
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) }));
vi.mock("./notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./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 { msg, notify, raw, sayNotif } from "./notifications";
import { localizeDetail } from "./api/refusalText";
import { reportError } from "./errorDialog";
import { failureReason, notifyActionError, notifyKnownFailure } from "./actionErrors";
import i18n from "../i18n";
@@ -32,10 +36,19 @@ describe("a Plex collection name that is already taken", () => {
);
const err = await api.plexCreateCollection("1", "Marvel", "m1").catch((e: unknown) => e);
notifyMock.mockClear();
notifyActionError(err, "EDIT_FAILED");
notifyActionError(err, raw("EDIT_FAILED"));
expect(notifyMock.mock.calls).toEqual([
[{ level: "error", title: "EDIT_FAILED", message: i18n.t("plex.collection.nameTaken") }],
[
{
level: "error",
title: raw("EDIT_FAILED"),
message: { detail: { code: "collection", reason: "name_taken" } },
},
],
]);
expect(sayNotif(notifyMock.mock.calls[0]![0].message)).toBe(
i18n.t("plex.collection.nameTaken"),
);
expect(reportError).not.toHaveBeenCalled();
});
});
@@ -52,26 +65,26 @@ describe("notifyActionError", () => {
);
const err = await api.plexSetState("1", "watched").catch((e: unknown) => e);
notifyMock.mockClear();
notifyActionError(err, "FAILED");
notifyActionError(err, raw("FAILED"));
expect(notifyMock).not.toHaveBeenCalled();
});
it("does toast one the caller was left to handle", () => {
notifyActionError(new HttpError(404, "gone"), "FAILED");
notifyActionError(new HttpError(404, "gone"), raw("FAILED"));
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");
notifyActionError(err, raw("FIRST"));
notifyActionError(err, raw("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");
notifyActionError(new HttpError(0, "aborted"), raw("FAILED"));
expect(notifyMock).not.toHaveBeenCalled();
});
@@ -79,27 +92,32 @@ describe("notifyActionError", () => {
// `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.
notifyActionError(new HttpError(404, "No user with that email."), "TRANSLATED");
notifyActionError(new HttpError(404, "No user with that email."), raw("TRANSLATED"));
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
title: "TRANSLATED",
message: i18n.t("errors.notFound.generic"),
title: raw("TRANSLATED"),
message: msg("errors.notFound.generic"),
});
});
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 anything a status can say — quota numbers, which row is gone.
it("still prefers a STRUCTURED reason, kept as the refusal and said in the reader's language", () => {
// The notice keeps `{code, reason}` and says it through localizeDetail when shown: in the
// reader's language 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",
});
notifyActionError(err, "TRANSLATED FALLBACK");
notifyActionError(err, raw("TRANSLATED FALLBACK"));
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
title: "TRANSLATED FALLBACK",
message: "Ez a csatorna már nincs a könyvtárban.",
title: raw("TRANSLATED FALLBACK"),
message: { detail: { code: "not_found", reason: "channel" } },
});
// Said at display through localizeDetail, in the language of that moment.
expect(sayNotif(notifyMock.mock.calls[0]![0].message)).toBe(
localizeDetail({ code: "not_found", reason: "channel" }),
);
});
});
@@ -114,17 +132,21 @@ describe("failureReason — why a write failed, when the server did not say", ()
[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));
const reason = failureReason(new HttpError(status, "hand-written English"));
expect(reason).toEqual(msg(key));
expect(sayNotif(reason)).toBe(i18n.t(key));
});
it("a request that never reached the server reads as unreachable", () => {
expect(failureReason(new TypeError("Failed to fetch"))).toBe(
expect(sayNotif(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"));
expect(sayNotif(failureReason(new Error("key import failed")))).toBe(
i18n.t("errors.reason.unexpected"),
);
});
it("every reason exists in both languages", () => {
@@ -142,9 +164,9 @@ describe("failureReason — why a write failed, when the server did not say", ()
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");
notifyKnownFailure(err, raw("EMAIL IS OFF"));
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: raw("EMAIL IS OFF") });
notifyActionError(err, raw("GENERIC"));
expect(notifyMock).toHaveBeenCalledTimes(1);
});
});