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.
178 lines
7.3 KiB
TypeScript
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"]));
|
|
});
|
|
});
|