// @vitest-environment jsdom // // The ONE app-wide reporter for a failed mutation. `req` shows its blocking dialog for // 400/409/422/5xx only, so before this every mutation without a reporting `onError` failed in // silence on a 403/404/429 or a lost connection — 34 of them. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) })); vi.mock("./errorDialog", () => ({ reportError: vi.fn() })); import { MutationObserver, type MutationObserverOptions } from "@tanstack/react-query"; import { createQueryClient } from "./queryClient"; import { HttpError, req } from "./api/core"; import { notify } from "./notifications"; import { reportError } from "./errorDialog"; import { notifyActionError } from "./actionErrors"; import i18n from "../i18n"; const notifyMock = vi.mocked(notify); beforeEach(() => { notifyMock.mockClear(); vi.mocked(reportError).mockClear(); }); afterEach(() => vi.restoreAllMocks()); async function fail(options: MutationObserverOptions): Promise { const observer = new MutationObserver(createQueryClient(), options); await observer.mutate().catch(() => {}); // The reporter waits a task for the mutation's own onError; give it that task. await new Promise((r) => setTimeout(r, 5)); } const TITLE = "errors.writeFailed.hideChannel"; describe("a failed mutation", () => { it("is named, with the reason under it, when the mutation did not report it itself", async () => { await fail({ mutationFn: () => Promise.reject(new HttpError(404, "Not subscribed")), meta: { failTitle: TITLE }, }); expect(notifyMock.mock.calls).toEqual([ [{ level: "error", title: i18n.t(TITLE), message: i18n.t("errors.notFound.generic") }], ]); }); it("is still reported under the generic title when the mutation names nothing", async () => { await fail({ mutationFn: () => Promise.reject(new HttpError(429, "slow down")) }); expect(notifyMock).toHaveBeenCalledWith({ level: "error", title: i18n.t("errors.title"), message: i18n.t("errors.reason.rateLimited"), }); }); it("says a lost connection is why the change did not go through", async () => { await fail({ mutationFn: () => Promise.reject(new TypeError("Failed to fetch")), meta: { failTitle: TITLE }, }); expect(notifyMock).toHaveBeenCalledWith( expect.objectContaining({ message: i18n.t("errors.reason.unreachable") }), ); }); it("gets ONE toast when the mutation's own onError reported it — react-query runs that one second", async () => { await fail({ mutationFn: () => Promise.reject(new HttpError(404, "gone")), meta: { failTitle: TITLE }, onError: (e) => notifyActionError(e, "OWN TITLE"), }); expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual(["OWN TITLE"]); }); it("is still reported when the mutation's onError only rolls back", async () => { const rollback = vi.fn(); await fail({ mutationFn: () => Promise.reject(new HttpError(403, "nope")), meta: { failTitle: TITLE }, onError: rollback, }); expect(rollback).toHaveBeenCalled(); expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual([i18n.t(TITLE)]); }); it("gets no toast when the page shows the failure itself", async () => { await fail({ mutationFn: () => Promise.reject(new HttpError(403, "nope")), meta: { errorShownInline: true }, }); expect(notifyMock).not.toHaveBeenCalled(); }); it("gets no toast on top of the error dialog the request layer already opened", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response(JSON.stringify({ detail: "conflict" }), { status: 409, headers: { "content-type": "application/json" }, }), ); await fail({ mutationFn: () => req("/api/x", { method: "POST" }), meta: { failTitle: TITLE } }); expect(reportError).toHaveBeenCalled(); expect(notifyMock).not.toHaveBeenCalled(); }); });