`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.
105 lines
3.5 KiB
TypeScript
105 lines
3.5 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// A failed user action is said once, and a user who is gone is not left listed (deep-review round 4).
|
|
// setRole / suspend / delete had no onError at all — "the global error dialog says it" — but a 404
|
|
// (the user was deleted in another tab) gets no dialog, so the click did nothing and the row stayed.
|
|
// And the invite approve/deny toasted unconditionally on top of the dialog `req` opens for a 409.
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { render, cleanup, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
|
};
|
|
});
|
|
vi.mock("../lib/useMe", () => ({
|
|
useMe: () => ({
|
|
id: 1,
|
|
email: "admin@x",
|
|
role: "admin",
|
|
pending_invites: 0,
|
|
pending_activations: 0,
|
|
}),
|
|
}));
|
|
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
|
const { notify } = vi.hoisted(() => ({
|
|
notify: vi.fn((_n: { level: string; message: string }) => 0),
|
|
}));
|
|
vi.mock("../lib/notifications", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../lib/notifications")>()),
|
|
notify,
|
|
}));
|
|
vi.mock("../lib/errorDialog", () => ({ reportError: vi.fn() }));
|
|
|
|
import AdminUsers from "./AdminUsers";
|
|
import { api, type AdminUserRow } from "../lib/api";
|
|
import i18n from "../i18n";
|
|
|
|
const row = {
|
|
id: 2,
|
|
email: "gone@x",
|
|
role: "user",
|
|
is_suspended: false,
|
|
is_demo: false,
|
|
email_verified: true,
|
|
is_active: true,
|
|
} as unknown as AdminUserRow;
|
|
|
|
function answer(status: number, detail: unknown) {
|
|
return new Response(JSON.stringify({ detail }), {
|
|
status,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
notify.mockClear();
|
|
localStorage.clear();
|
|
});
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function mount() {
|
|
const users = vi.spyOn(api, "adminUsers").mockResolvedValue([row] as never);
|
|
render(
|
|
<QueryClientProvider
|
|
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
|
>
|
|
<AdminUsers />
|
|
</QueryClientProvider>,
|
|
);
|
|
return users;
|
|
}
|
|
|
|
describe("a user action that fails", () => {
|
|
it("says a user who is gone, and refetches the list", async () => {
|
|
const users = mount();
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
answer(404, { code: "not_found", reason: "user" }),
|
|
);
|
|
fireEvent.click(await screen.findByText("users.roles.makeAdmin"));
|
|
await waitFor(() =>
|
|
expect(notify).toHaveBeenCalledWith({
|
|
level: "error",
|
|
title: "users.roles.failed", // what failed…
|
|
message: i18n.t("errors.notFound.user"), // …and why: the request layer localizes a structured detail
|
|
}),
|
|
);
|
|
await waitFor(() => expect(users).toHaveBeenCalledTimes(2));
|
|
});
|
|
|
|
it("is not toasted on top of the error dialog the request already opened", async () => {
|
|
mount();
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(answer(409, "Cannot demote the last admin"));
|
|
fireEvent.click(await screen.findByText("users.roles.makeAdmin"));
|
|
const { reportError } = await import("../lib/errorDialog");
|
|
await waitFor(() => expect(reportError).toHaveBeenCalled());
|
|
expect(notify.mock.calls.filter(([n]) => n.level === "error")).toEqual([]);
|
|
});
|
|
});
|