fix(api): a write that hit a connection failure is still reported, and every failure is reported once
Deep-review round 4, the "one report per failure" contract round 3 introduced. - `req` marked gateway (502/503/504) and network failures "already reported" because they raise the connection notice. But that notice is about connectivity and removes itself on the very next answer — which every caller's own resync refetch fetches at once — so a delete that hit a keepalive reset flashed the notice and was then reported nowhere, the list still there. Only the durable reports (the error dialog, the 401 sign-out) count now; the caller says the action failed. - A connection notice the user closed swallowed every later one for the rest of the visit; it is raised again. - `req` and `uploadReq` share one failure policy (`reportFailure`). The upload copy had drifted: a 502 opened the blocking "Server error (502)" dialog and cleared a notice that was still true. - A taken Plex collection name is a structured 409, localized, and the create/rename calls are quiet: it was an English dialog plus a Hungarian toast. `notifyCollectionEditError` is gone. - The mutation onErrors that toasted unconditionally after a non-quiet request (invite approve / deny / add, demo whitelist, audit clear, scheduler, share, full history, edit, config tests, tag attach) go through `notifyActionError`; the playlist delete's hand-rolled copy of it too (it takes a level now). The user role / suspend / delete actions had no onError at all: a user deleted in another tab failed silently and stayed listed — now said, and the list refetched.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
// @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",
|
||||
message: i18n.t("errors.notFound.user"), // 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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user