// @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. Round 4 swept 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 (deep-review round 5). 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(); return { ...actual, useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), }; }); vi.mock("./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()), notify, })); import PlexCollectionEditor from "./PlexCollectionEditor"; import UploadDialog from "./UploadDialog"; import DownloadDialog from "./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 (E11 S2b — 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( {}} onChanged={() => {}} /> , ); 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( {}} /> , ); 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 (deep-review round 2). vi.spyOn(api, "downloadUsage").mockResolvedValue({ upload_max_bytes: 1_000_000, } as Awaited>); }); 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, "uploadDownload").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, "uploadDownload").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 (E11 S2b). vi.spyOn(api, "downloadProfiles").mockResolvedValue([{ id: 1, name: "Best" }] as never); vi.spyOn(api, "enqueueDownload").mockRejectedValue(new HttpError(403, "nope")); render( {}} /> , ); await screen.findByText("Best"); fireEvent.click(screen.getByText("downloads.dialog.submit")); await waitFor(() => expect(errors()).toEqual(["errors.writeFailed.enqueueDownload"])); }); });