Files
siftlode/frontend/src/components/downloadAdmin.storage.dom.test.tsx
T
peter 078194731b feat(files): the Downloads page goes — the tray, a Storage tab and a Downloads settings tab take its place
- The Downloads module, its rail icon and badge are gone. A bookmark, the remembered page and an old
  history entry that name it land on Files (urlState.resolvePage); `g d` goes to Files too.
- Users page: a Storage tab — the disk in numbers, every user's use against their own limit with
  their file count, every user's downloads; aligned tables, one menu per row (edit / reset quota,
  open source, copy, the owner's quota). The admin storage rows carry quota + file count, the admin
  job list its owner.
- Settings: a Downloads tab — the download formats in one list (duplicate a built-in, edit or delete
  your own) and the site sign-ins, moved from Account.
- "View" on an added download, a clip or a converting upload opens the tray on that row (unfolded,
  back if closed; a finished one opens as a file; under a player it opens without taking focus).
- The download dialog opens on the format used last.
- DataTable: `pager={false}` for a short list, `cardRow` to give a lone "…" its own card line.
- Welcome and README speak of Files; a new Files screenshot.
2026-10-11 19:37:13 +02:00

161 lines
5.6 KiB
TypeScript

// @vitest-environment jsdom
//
// The admin Users page's Storage tab: every user's footprint against their OWN limit (or
// "Unlimited") with their file count, and every user's downloads — each list's row has one menu.
// A user's: Edit quota, Reset (behind a confirm). A download's: Open source, the copies, and Edit
// this user's quota — which opens the quota of the download's OWNER. i18n is key-passthrough.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { api, type AdminStorage, type DownloadJob } from "../lib/api";
import { qk } from "../lib/queryKeys";
const mocks = vi.hoisted(() => ({
storage: undefined as unknown,
jobs: undefined as unknown,
confirm: vi.fn(async (_o?: unknown) => true),
}));
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: { size?: string; email?: string }) =>
`${key}${o?.size ? `:${o.size}` : ""}${o?.email ? `:${o.email}` : ""}`,
}),
}));
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
vi.mock("../lib/useLiveQuery", () => ({
useLiveQuery: (key: readonly unknown[]) => ({
data: key[0] === qk.adminStorage()[0] ? mocks.storage : mocks.jobs,
isError: false,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { StorageTab } from "./downloadAdmin";
const GiB = 1024 ** 3;
const storage = (): AdminStorage => ({
files_on_disk: 3,
total_bytes: 3 * GiB,
total_assets: 3,
total_cap_bytes: 0,
per_user: [
{
user_id: 4,
email: "anna@x",
footprint_bytes: GiB,
files: 27,
max_bytes: 5 * GiB,
unlimited: false,
},
{
user_id: 1,
email: "peter@x",
footprint_bytes: 2 * GiB,
files: 180,
// Unlimited wins over the limit the row still carries.
max_bytes: 10 * GiB,
unlimited: true,
},
],
});
const job = (id: number, extra: Partial<DownloadJob> = {}) =>
({
id,
status: "done",
display_name: `Clip ${id}`,
source_url: `https://example.com/${id}`,
user_id: 4,
user_email: "anna@x",
asset: { size_bytes: 1024 },
...extra,
}) as unknown as DownloadJob;
afterEach(() => {
cleanup();
vi.restoreAllMocks();
mocks.confirm.mockClear();
});
function mount() {
mocks.storage = storage();
mocks.jobs = [job(7), job(8, { source_url: null, user_id: 1, user_email: "peter@x" })];
render(
<QueryClientProvider client={new QueryClient()}>
<StorageTab />
</QueryClientProvider>,
);
}
const rowOf = (text: string) =>
screen.getAllByText(text)[0]!.closest<HTMLElement>("[data-nav-index]")!;
const menuRows = () =>
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
const pick = (label: string) =>
fireEvent.click(
screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!,
);
async function openMenu(row: HTMLElement) {
act(() => {
fireEvent.contextMenu(row);
});
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
);
}
describe("StorageTab", () => {
it("names each user's use against their own limit, and their files", () => {
mount();
const anna = rowOf("anna@x");
expect(anna.textContent).toMatch(/downloads\.admin\.ofQuota:5(\.0)? GB/);
expect(anna.textContent).toContain("27");
expect(anna.querySelector('[role="meter"]')?.getAttribute("aria-valuenow")).toBe("20");
expect(rowOf("peter@x").textContent).toContain("downloads.admin.unlimitedShort");
});
it("a user's menu edits their quota, and resets it only through the confirm", async () => {
vi.spyOn(api, "adminGetDownloadQuota").mockReturnValue(new Promise(() => {}) as never);
const reset = vi.spyOn(api, "adminResetDownloadQuota").mockResolvedValue({} as never);
mount();
await openMenu(rowOf("anna@x"));
expect(menuRows()).toEqual(["downloads.admin.editQuota", "downloads.admin.reset"]);
mocks.confirm.mockResolvedValueOnce(false);
pick("downloads.admin.reset");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
expect(reset).not.toHaveBeenCalled();
await openMenu(rowOf("anna@x"));
pick("downloads.admin.reset");
await waitFor(() => expect(reset).toHaveBeenCalledWith(4));
await openMenu(rowOf("anna@x"));
pick("downloads.admin.editQuota");
expect(await screen.findByText("downloads.admin.quotaTitle:anna@x")).toBeTruthy();
expect(api.adminGetDownloadQuota).toHaveBeenCalledWith(4);
});
it("a download's menu opens its source, copies, and opens its OWNER's quota", async () => {
vi.spyOn(api, "adminGetDownloadQuota").mockReturnValue(new Promise(() => {}) as never);
const open = vi.spyOn(window, "open").mockReturnValue(null);
mount();
await openMenu(rowOf("Clip 7"));
expect(menuRows()).toEqual([
"downloads.admin.openSource",
"common.copy.title",
"common.copy.link",
"downloads.admin.editUserQuota",
]);
pick("downloads.admin.openSource");
expect(open).toHaveBeenCalledWith("https://example.com/7", "_blank", "noopener,noreferrer");
// One with no source link offers neither the link nor its copy.
await openMenu(rowOf("Clip 8"));
expect(menuRows()).toEqual(["common.copy.title", "downloads.admin.editUserQuota"]);
pick("downloads.admin.editUserQuota");
await waitFor(() => expect(api.adminGetDownloadQuota).toHaveBeenCalledWith(1));
});
});