The Files library starts here. A user's every download, clip and upload now lands in a source folder made per origin (YouTube, Plex clips, uploads, one per other site), decided once in downloads/folders.py at every place a job is made. How long a file stays moves from the shared asset to the user's job: stamped from its folder when it is done, never for a file the user keeps. The GC warns each user once, then takes the expired file out of their library; the cache cap never evicts a kept file, and an evicted one leaves every holder's library. The media mount is /files now (FILES_ROOT, FILES_HOST_PATH, volume siftlode_files); docs/self-hosting.md says how to carry an old volume over. Migration 0102 places every existing job and carries each done file's expiry over from its asset. safe_abs_path takes a missing path and answers None, which drops one baselined type error.
187 lines
6.8 KiB
TypeScript
187 lines
6.8 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The Download Center's confirm dialogs name their ACTION on the confirm button: the shared
|
|
// `confirmThen` once labelled it with the dialog's question — "Re-download this file?" on the button,
|
|
// and the cancel dialog's own "Cancel" beside "Cancel". i18n is key-passthrough, so labels read as keys.
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { DownloadJob } from "../lib/api";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
jobs: [] as unknown[],
|
|
confirm: vi.fn(
|
|
async (_opts: { title: string; message?: string; confirmLabel?: string }) => false,
|
|
),
|
|
}));
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
|
};
|
|
});
|
|
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
|
|
vi.mock("../lib/useLiveQuery", () => ({
|
|
useLiveQuery: (key: readonly unknown[]) => ({
|
|
data: key[0] === "downloads" ? mocks.jobs : undefined,
|
|
isError: false,
|
|
isLoading: false,
|
|
refetch: vi.fn(),
|
|
}),
|
|
}));
|
|
vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: "user" }) }));
|
|
// The page toolbar (title + tabs) renders into the app shell's slot, which a bare render has none of.
|
|
vi.mock("./ui/PageShell", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("./ui/PageShell")>()),
|
|
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
}));
|
|
|
|
import DownloadCenter from "./DownloadCenter";
|
|
|
|
const job = (id: number, status: DownloadJob["status"]): DownloadJob => ({
|
|
id,
|
|
status,
|
|
progress: 0,
|
|
speed_bps: null,
|
|
eta_s: null,
|
|
phase: null,
|
|
error: null,
|
|
error_code: null,
|
|
error_detail: null,
|
|
queue_pos: 0,
|
|
created_at: null,
|
|
source_kind: "youtube",
|
|
source_ref: `v${id}`,
|
|
source_url: null,
|
|
poster_url: null,
|
|
profile_id: null,
|
|
spec: {
|
|
mode: "av",
|
|
max_height: null,
|
|
container: null,
|
|
audio_format: null,
|
|
vcodec: null,
|
|
embed_subs: false,
|
|
embed_chapters: false,
|
|
embed_thumbnail: false,
|
|
sponsorblock: false,
|
|
},
|
|
display_name: `Clip ${id}`,
|
|
display_uploader: null,
|
|
display_uploader_url: null,
|
|
extra_links: [],
|
|
can_download: status === "done",
|
|
expired: false,
|
|
folder_id: null,
|
|
kept: false,
|
|
expires_at: null,
|
|
asset: null,
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
mocks.confirm.mockClear();
|
|
});
|
|
|
|
/** Pick `label` from the row's menu, opened by its "…". The surface is visibility:hidden in jsdom
|
|
* (floating-ui never positions it), so the row is matched on its text. */
|
|
async function pick(row: string, label: string) {
|
|
const more = screen
|
|
.getByText(row)
|
|
.closest<HTMLElement>("[data-nav-index]")!
|
|
.querySelector<HTMLElement>('button[aria-label="downloads.rowMenu"]')!;
|
|
fireEvent.click(more);
|
|
const item = await waitFor(() => {
|
|
const found = screen
|
|
.getAllByRole("menuitem", { hidden: true })
|
|
.find((b) => b.textContent === label);
|
|
expect(found).toBeTruthy();
|
|
return found!;
|
|
});
|
|
fireEvent.click(item);
|
|
}
|
|
|
|
describe("DownloadCenter confirm dialogs", () => {
|
|
it("label the confirm button with the action, not the dialog's question", async () => {
|
|
mocks.jobs = [job(1, "running"), job(2, "done")];
|
|
render(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<DownloadCenter />
|
|
</QueryClientProvider>,
|
|
);
|
|
|
|
// The destructive and rare row buttons live in the row's "…".
|
|
await pick("Clip 1", "downloads.actions.cancel");
|
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
|
// The tab's label shares its button with a count badge.
|
|
fireEvent.click(screen.getByRole("tab", { name: /downloads\.tabs\.done/ }));
|
|
await screen.findByText("Clip 2");
|
|
await pick("Clip 2", "downloads.actions.redownload");
|
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2));
|
|
await pick("Clip 2", "downloads.actions.delete");
|
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(3));
|
|
|
|
expect(mocks.confirm.mock.calls.map(([o]) => [o.title, o.confirmLabel])).toEqual([
|
|
["downloads.confirm.cancelTitle", "downloads.confirm.cancelAction"],
|
|
["downloads.confirm.redownloadTitle", "downloads.confirm.redownloadAction"],
|
|
["downloads.confirm.deleteTitle", "downloads.actions.delete"],
|
|
]);
|
|
});
|
|
|
|
it("cancelling an upload says the uploaded file goes with it", async () => {
|
|
// Nothing to fetch again: the cancel deletes the original from the server.
|
|
mocks.jobs = [{ ...job(3, "queued"), job_kind: "upload" }];
|
|
render(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<DownloadCenter />
|
|
</QueryClientProvider>,
|
|
);
|
|
await pick("Clip 3", "downloads.actions.cancel");
|
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
|
const [opts] = mocks.confirm.mock.calls[0]!;
|
|
expect([opts.title, opts.confirmLabel]).toEqual([
|
|
"downloads.confirm.cancelUploadTitle",
|
|
"downloads.confirm.cancelUploadAction",
|
|
]);
|
|
});
|
|
|
|
it("cancelling a clip speaks of the clip", async () => {
|
|
mocks.jobs = [{ ...job(4, "running"), job_kind: "edit" }];
|
|
render(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<DownloadCenter />
|
|
</QueryClientProvider>,
|
|
);
|
|
await pick("Clip 4", "downloads.actions.cancel");
|
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
|
const [opts] = mocks.confirm.mock.calls[0]!;
|
|
expect([opts.title, opts.message, opts.confirmLabel]).toEqual([
|
|
"downloads.confirm.cancelEditTitle",
|
|
"downloads.confirm.cancelEditBody",
|
|
"downloads.confirm.cancelEditAction",
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
["upload", "downloads.confirm.deleteUploadTitle", "downloads.confirm.deleteUploadBody"],
|
|
["edit", "downloads.confirm.deleteEditTitle", "downloads.confirm.deleteEditBody"],
|
|
["download", "downloads.confirm.deleteTitle", "downloads.confirm.deleteBody"],
|
|
] as const)("deleting a finished %s says what happens to its file", async (kind, title, body) => {
|
|
// Only a download's file may stay cached for other users; an upload's or a clip's is theirs alone.
|
|
mocks.jobs = [{ ...job(5, "done"), job_kind: kind }];
|
|
render(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<DownloadCenter />
|
|
</QueryClientProvider>,
|
|
);
|
|
fireEvent.click(screen.getByRole("tab", { name: /downloads\.tabs\.done/ }));
|
|
await screen.findByText("Clip 5");
|
|
await pick("Clip 5", "downloads.actions.delete");
|
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
|
const [opts] = mocks.confirm.mock.calls[0]!;
|
|
expect([opts.title, opts.message]).toEqual([title, body]);
|
|
});
|
|
});
|