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:
@@ -6,14 +6,15 @@
|
||||
// each lifecycle branch.
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
const { reportError, notify, remove } = vi.hoisted(() => ({
|
||||
const { reportError, notify, remove, getActiveToasts } = vi.hoisted(() => ({
|
||||
reportError: vi.fn(),
|
||||
notify: vi.fn(() => 1),
|
||||
remove: vi.fn(),
|
||||
getActiveToasts: vi.fn((): { id: number }[] => []),
|
||||
}));
|
||||
|
||||
vi.mock("../errorDialog", () => ({ reportError }));
|
||||
vi.mock("../notifications", () => ({ notify, remove }));
|
||||
vi.mock("../notifications", () => ({ notify, remove, getActiveToasts }));
|
||||
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
|
||||
vi.mock("../activeAccount", () => ({
|
||||
activeAccountId: () => null,
|
||||
@@ -54,6 +55,9 @@ class FakeXHR {
|
||||
beforeEach(() => {
|
||||
reportError.mockReset();
|
||||
notify.mockReset();
|
||||
remove.mockReset();
|
||||
getActiveToasts.mockReset();
|
||||
getActiveToasts.mockReturnValue([]);
|
||||
setUnauthorizedHandler(null);
|
||||
vi.stubGlobal("XMLHttpRequest", FakeXHR as unknown as typeof XMLHttpRequest);
|
||||
});
|
||||
@@ -167,17 +171,45 @@ describe("req — what it has already put in front of the user", () => {
|
||||
expect(alreadyReported(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("marks a request that never reached the server (the connection notice says it)", async () => {
|
||||
// The connection notice says the server is unreachable — not that THIS action failed — and it
|
||||
// removes itself on the very next answer (the caller's own resync refetch). Marked as reported, a
|
||||
// delete that hit a keepalive reset flashed the notice and was then reported nowhere at all.
|
||||
it("raises the connection notice for a request that never reached the server, and leaves the failed action to the caller", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
const err = await req("/api/x", { method: "POST" }).catch((e: unknown) => e);
|
||||
expect(notify).toHaveBeenCalled();
|
||||
expect(alreadyReported(err)).toBe(true);
|
||||
expect(alreadyReported(err)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([502, 503, 504])(
|
||||
"raises the connection notice for a %s, and leaves the failed action to the caller",
|
||||
async (status) => {
|
||||
answer(status);
|
||||
const err = await req("/api/x", { method: "POST" }).catch((e: unknown) => e);
|
||||
expect(notify).toHaveBeenCalled();
|
||||
expect(reportError).not.toHaveBeenCalled();
|
||||
expect(err).toMatchObject({ status });
|
||||
expect(alreadyReported(err)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("raises the connection notice again after the user closed it", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
notify.mockReturnValue(7);
|
||||
await req("/api/x", { method: "POST" }).catch(() => {});
|
||||
getActiveToasts.mockReturnValue([{ id: 7 }]); // still on screen: one notice, not a stack
|
||||
notify.mockClear();
|
||||
await req("/api/x", { method: "POST" }).catch(() => {});
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
getActiveToasts.mockReturnValue([]); // closed with its ✕
|
||||
await req("/api/x", { method: "POST" }).catch(() => {});
|
||||
expect(notify).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("marks what the upload path reported the same way", async () => {
|
||||
const dead = uploadReq("/u", form());
|
||||
FakeXHR.last.onerror!();
|
||||
expect(alreadyReported(await dead.catch((e: unknown) => e))).toBe(true);
|
||||
expect(alreadyReported(await dead.catch((e: unknown) => e))).toBe(false);
|
||||
const refused = uploadReq("/u", form());
|
||||
FakeXHR.last.status = 500;
|
||||
FakeXHR.last.onload!();
|
||||
@@ -187,4 +219,20 @@ describe("req — what it has already put in front of the user", () => {
|
||||
FakeXHR.last.onload!();
|
||||
expect(alreadyReported(await gone.catch((e: unknown) => e))).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a gateway error on the upload path as the connection notice, not a blocking dialog", async () => {
|
||||
// The upload path kept its own copy of this policy, and it had drifted: a 502 opened the
|
||||
// blocking "Server error (502)" dialog and cleared a connection notice that was still true.
|
||||
notify.mockReturnValue(9);
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
await req("/api/x", { method: "POST" }).catch(() => {}); // a notice is up
|
||||
getActiveToasts.mockReturnValue([{ id: 9 }]);
|
||||
remove.mockClear();
|
||||
const p = uploadReq("/u", form());
|
||||
FakeXHR.last.status = 502;
|
||||
FakeXHR.last.onload!();
|
||||
await expect(p).rejects.toMatchObject({ status: 502 });
|
||||
expect(reportError).not.toHaveBeenCalled();
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user