fix(ui): a write that fails says what failed and why, wherever it fails

`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.
This commit is contained in:
2026-09-13 01:45:28 +02:00
parent b4aea7fa5b
commit 8a50538275
45 changed files with 851 additions and 132 deletions
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
//
// The two shared channel write flows report their own failures, with the real error (E11 S2b).
// Before: the channel detail page's tag toggle and both pages' priority stepper only refetched, so a
// refused write just snapped back; and the manager reported a failed tag toggle by handing the
// CHANNEL ID to the reporter as though it were the error.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) }));
import { api, HttpError } from "./api";
import { notify } from "./notifications";
import { useChannelTagActions } from "./useChannelTagActions";
import { useChannelPriority } from "./useChannelPriority";
import i18n from "../i18n";
const notifyMock = vi.mocked(notify);
beforeEach(() => notifyMock.mockClear());
afterEach(() => vi.restoreAllMocks());
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
);
describe("useChannelTagActions", () => {
it("names a refused tag toggle with its real reason, then lets the caller revert", async () => {
vi.spyOn(api, "attachChannelTag").mockRejectedValue(new HttpError(403, "nope"));
const onError = vi.fn();
const { result } = renderHook(
() => useChannelTagActions({ optimisticToggle: () => {}, onError }),
{ wrapper },
);
act(() => result.current.toggleTag("UC1", 7, false));
await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1"));
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
title: i18n.t("channels.tags.updateFailed"),
message: i18n.t("errors.reason.forbidden"),
});
});
it("names a refused create-and-attach", async () => {
vi.spyOn(api, "createTag").mockRejectedValue(new HttpError(429, "slow"));
const { result } = renderHook(
() => useChannelTagActions({ optimisticToggle: () => {}, onError: () => {} }),
{ wrapper },
);
act(() => result.current.createAndAttachTag("UC1", "Music"));
await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
title: i18n.t("channels.tags.createFailed"),
message: i18n.t("errors.reason.rateLimited"),
}),
);
});
});
describe("useChannelPriority", () => {
it("names a refused priority save, then lets the caller revert", async () => {
vi.spyOn(api, "updateChannel").mockRejectedValue(new TypeError("Failed to fetch"));
const onError = vi.fn();
let priority = 1;
const { result } = renderHook(() =>
useChannelPriority({
read: () => priority,
optimistic: (_id, next) => void (priority = next),
onError,
delay: 0,
}),
);
act(() => result.current.bumpPriority("UC1", 1));
await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1"));
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
title: i18n.t("errors.writeFailed.channelPriority"),
message: i18n.t("errors.reason.unreachable"),
});
});
});