// @vitest-environment jsdom // // jsdom (not node): importing ../api pulls in i18n, which reads navigator.language at module load. 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("./errorDialog", () => ({ reportError: vi.fn() })); import { HttpError, api } from "./api"; import { alreadyReported } from "./api/core"; import { notify } from "./notifications"; import { reportError } from "./errorDialog"; import { failureReason, notifyActionError, notifyKnownFailure } from "./actionErrors"; import i18n from "../i18n"; const notifyMock = vi.mocked(notify); beforeEach(() => { notifyMock.mockClear(); vi.mocked(reportError).mockClear(); }); describe("a Plex collection name that is already taken", () => { it("is reported once, in the reader's language", async () => { // The 409 was a bare English string: the global error dialog showed it in English, and the // editor's own toast said the same thing again, translated (deep-review round 4). vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response(JSON.stringify({ detail: { code: "collection", reason: "name_taken" } }), { status: 409, headers: { "content-type": "application/json" }, }), ); const err = await api.plexCreateCollection("1", "Marvel", "m1").catch((e: unknown) => e); notifyMock.mockClear(); notifyActionError(err, "EDIT_FAILED"); expect(notifyMock.mock.calls).toEqual([ [{ level: "error", title: "EDIT_FAILED", message: i18n.t("plex.collection.nameTaken") }], ]); expect(reportError).not.toHaveBeenCalled(); }); }); describe("notifyActionError", () => { it("does not toast a fault the request layer already put on screen", async () => { // A 500 opens the blocking error dialog inside `req`; the watched toggles, the saved views and a // playlist delete then toasted the same failure as well. vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response(JSON.stringify({ detail: "boom" }), { status: 500, headers: { "content-type": "application/json" }, }), ); const err = await api.plexSetState("1", "watched").catch((e: unknown) => e); notifyMock.mockClear(); notifyActionError(err, "FAILED"); expect(notifyMock).not.toHaveBeenCalled(); }); it("does toast one the caller was left to handle", () => { notifyActionError(new HttpError(404, "gone"), "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"); 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", title: "TRANSLATED", message: i18n.t("errors.notFound.generic"), }); }); it("tells a demo user that the demo account is the reason", async () => { // `require_human`'s refusal was a plain English string, so round 5's fix — discard a // plain-string detail, show the caller's translated fallback — discarded the only explanation // the demo user got: a generic "couldn't pin it" with no hint why (round 6). The gate answers // a structured code now, and `localizeDetail` has a branch for it in both locales. vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response(JSON.stringify({ detail: { code: "demo" } }), { status: 403, headers: { "content-type": "application/json" }, }), ); const err = await api.addComfortPin("video", "v1").catch((e: unknown) => e); notifyMock.mockClear(); 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 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"); 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); }); });