Files
siftlode/frontend/src/WriteFailureReporting.dom.test.tsx
T
peter 63a0fecc8b refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
2026-10-22 23:12:45 +02:00

178 lines
7.3 KiB
TypeScript

// @vitest-environment jsdom
//
// A write that fails is named, wherever it fails. `req`/`uploadReq` deliberately leave 403/404/429
// to the caller (see reportFailure) and answer a 502/503/504 with only the transient "connection
// lost" notice — so a handler with no `catch` reports nothing at all. A sweep fixed a dozen such
// call sites and missed these two: the collection editor's three siblings, in the very file it
// edited, and the one `uploadReq` caller, whose empty catch still claimed the global error modal had
// it.
import { describe, it, expect, vi, beforeAll, 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("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({
notify: vi.fn((_n: { level: string; title?: string; message: string }) => 0),
}));
vi.mock("./lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./lib/notifications")>()),
notify,
}));
import PlexCollectionEditor from "./modules/plex/PlexCollectionEditor";
import UploadDialog from "./modules/files/UploadDialog";
import DownloadDialog from "./features/downloads/DownloadDialog";
import { api, HttpError } from "./lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
beforeEach(() => notify.mockClear());
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// What failed, as the toast names it: the title (the title says WHAT, the line under it WHY).
const errors = () =>
notify.mock.calls.filter(([n]) => n.level === "error").map(([n]) => n.title ?? n.message);
const client = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
describe("the Plex collection editor", () => {
const collections = [
{ id: "c1", title: "Marathon", can_edit: true, child_count: 3, smart: false },
// Not marked "mine to edit" yet, so it offers the take-over button.
{
id: "c2",
title: "Theirs",
can_edit: false,
child_count: 9,
smart: false,
source: "collection",
},
];
const mount = () =>
render(
<QueryClientProvider client={client()}>
<PlexCollectionEditor
item={{ id: "m1", title: "A Movie" }}
library="1"
memberOf={[]}
onClose={() => {}}
onChanged={() => {}}
/>
</QueryClientProvider>,
);
it("says so when adding to a collection that is gone, instead of nothing happening", async () => {
vi.spyOn(api, "plexCollections").mockResolvedValue({ collections } as never);
vi.spyOn(api, "plexCollectionAddItem").mockRejectedValue(
new HttpError(404, "Unknown collection"),
);
mount();
fireEvent.click(await screen.findByText("Marathon"));
await waitFor(() => expect(errors()).toEqual(["plex.collection.editFailed"]));
});
it("says so when enabling editing is refused", async () => {
vi.spyOn(api, "plexCollections").mockResolvedValue({ collections } as never);
vi.spyOn(api, "plexSetCollectionEditable").mockRejectedValue(
new HttpError(403, "This collection is read-only"),
);
mount();
fireEvent.click(await screen.findByText("plex.collEditor.makeEditable"));
await waitFor(() => expect(errors()).toEqual(["plex.collection.editFailed"]));
});
it("says so when a CONFIRMED delete does not happen", async () => {
// The worst of the three to lose: the user has just confirmed a destructive dialog.
vi.spyOn(api, "plexCollections").mockResolvedValue({ collections } as never);
vi.spyOn(api, "plexDeleteCollection").mockRejectedValue(
new HttpError(404, "Unknown collection"),
);
mount();
fireEvent.click(await screen.findByText("plex.collEditor.delete"));
await waitFor(() => expect(errors()).toEqual(["plex.collection.deleteFailed"]));
});
});
describe("the upload dialog", () => {
const pick = () => {
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(["x"], "clip.mp4", { type: "video/mp4" });
Object.defineProperty(input, "files", { value: [file], configurable: true });
fireEvent.change(input);
};
const mount = () =>
render(
<QueryClientProvider client={client()}>
<UploadDialog onClose={() => {}} />
</QueryClientProvider>,
);
beforeEach(() => {
// The dialog reads the per-file cap on mount. Unstubbed it is a REAL fetch from jsdom, which
// fails, retries once after 250 ms and then raises the "connection lost" notice — a delayed
// toast that outlives the test that spawned it and lands as an unexpected error in whichever
// test is running by then. The file is fast enough today that it always lands after the suite,
// which is a coincidence, not a guarantee.
vi.spyOn(api, "filesUsage").mockResolvedValue({
upload_max_bytes: 1_000_000,
} as Awaited<ReturnType<typeof api.filesUsage>>);
});
it("names an upload the server refused", async () => {
// A 502 from the proxy raises only the self-clearing connection notice; the next successful poll
// wipes it, and the dialog snapped back with no error and no job in the list.
vi.spyOn(api, "uploadFile").mockRejectedValue(new HttpError(502, "Bad Gateway"));
mount();
pick();
fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(errors()).toEqual(["downloads.upload.failed"]));
});
it("says nothing when the user cancelled it themselves", async () => {
// A cancel is not a failure — and it rejects with the same status 0 a genuine network drop does,
// which is why the difference is a named sentinel (`isAborted`) and not a status check.
const upload = vi.spyOn(api, "uploadFile").mockRejectedValue(new HttpError(0, "aborted"));
mount();
pick();
fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(upload).toHaveBeenCalled());
expect(errors()).toEqual([]);
});
});
describe("the download dialog", () => {
it("names a queueing the server refused with no dialog of its own", async () => {
// Its catch said "the global error dialog already surfaced the reason" — true for a quota 422,
// not for a 403/404/429 or a lost connection, where the dialog just stayed open.
vi.spyOn(api, "downloadProfiles").mockResolvedValue([{ id: 1, name: "Best" }] as never);
vi.spyOn(api, "enqueueDownload").mockRejectedValue(new HttpError(403, "nope"));
render(
<QueryClientProvider client={client()}>
<DownloadDialog source="https://example.com/v" onClose={() => {}} />
</QueryClientProvider>,
);
await screen.findByText("Best");
fireEvent.click(screen.getByText("downloads.dialog.submit"));
await waitFor(() => expect(errors()).toEqual(["errors.writeFailed.enqueueDownload"]));
});
});