- Own folders are made, renamed and deleted from the rail (right click, long press, Shift+F10) or the "…" beside an open folder's name; any folder's retention is a submenu (7/30/90 days, until deleted). Deleting confirms where its files go. - List or grid per folder, sharing one sort; the grid is the one media card. - A file dropped anywhere on the page opens Upload with it, bound for the folder on screen. - A link (?item=) or the upload notice opens Files on the file: its page turned to, the keyboard on it, a ring round it. - An in-place name field left by Enter or Escape hands the focus back to what replaced it (the row, the entry, the "…") instead of dropping it on <body>. - The table's saved page size is per account.
252 lines
12 KiB
TypeScript
252 lines
12 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The local-upload dialog. It's a general file upload (any type, no format list): a picked
|
|
// file surfaces its name+size, and Upload streams it through api.uploadFile (the XHR-progress
|
|
// path). This drives the real dialog against a mocked api so we can assert the file + optional name
|
|
// actually reach the ingest call, and that the success toast points at the library. i18n is
|
|
// key-passthrough.
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
|
|
const h = vi.hoisted(() => ({
|
|
uploadFile: vi.fn(async (_f: File, _o: unknown) => ({ id: 42, status: "done" })),
|
|
// The dialog reads the per-file upload cap so it can refuse an over-cap file before spending the
|
|
// user's bandwidth on it. Generous by default; the cap test below lowers it.
|
|
filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
|
|
notify: vi.fn(),
|
|
navigateTo: vi.fn(),
|
|
openFile: vi.fn(),
|
|
}));
|
|
|
|
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("../lib/api", () => ({
|
|
api: { uploadFile: h.uploadFile, filesUsage: h.filesUsage },
|
|
}));
|
|
vi.mock("../lib/downloads", () => ({ invalidateDownloads: vi.fn() }));
|
|
// The rest of the module stays real: the dialog is a ui/Modal, which hosts its own toasts.
|
|
vi.mock("../lib/notifications", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../lib/notifications")>()),
|
|
notify: h.notify,
|
|
}));
|
|
vi.mock("../lib/nav", () => ({ navigateTo: h.navigateTo }));
|
|
|
|
import UploadDialog from "./UploadDialog";
|
|
import { FilesActionsContext } from "./filesContext";
|
|
|
|
const wrap = (
|
|
onClose = vi.fn(),
|
|
folderId: number | null = null,
|
|
initialFile: File | null = null,
|
|
) => {
|
|
const qc = new QueryClient();
|
|
const files = {
|
|
setSearch: vi.fn(),
|
|
setView: vi.fn(),
|
|
openFile: h.openFile,
|
|
consumeFocus: vi.fn(),
|
|
};
|
|
return render(
|
|
<QueryClientProvider client={qc}>
|
|
<FilesActionsContext.Provider value={files}>
|
|
<UploadDialog folderId={folderId} initialFile={initialFile} onClose={onClose} />
|
|
</FilesActionsContext.Provider>
|
|
</QueryClientProvider>,
|
|
);
|
|
};
|
|
|
|
// The dialog portals into document.body, so query the whole document, not the render container.
|
|
const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
beforeEach(() => {
|
|
h.uploadFile.mockClear();
|
|
h.openFile.mockClear();
|
|
h.navigateTo.mockClear();
|
|
h.notify.mockClear();
|
|
h.filesUsage.mockClear();
|
|
h.filesUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
|
|
});
|
|
afterEach(cleanup);
|
|
|
|
describe("UploadDialog", () => {
|
|
it("shows the picked file's name and uploads it with the optional name", async () => {
|
|
const onClose = vi.fn();
|
|
wrap(onClose);
|
|
const f = new File(["hello world"], "notes.pdf", { type: "application/pdf" });
|
|
fireEvent.change(fileInput(), { target: { files: [f] } });
|
|
|
|
// The drop zone now surfaces the file name.
|
|
expect(screen.getByText("notes.pdf")).toBeTruthy();
|
|
|
|
// Optional display name.
|
|
const nameBox = screen.getByPlaceholderText("downloads.upload.namePlaceholder");
|
|
fireEvent.change(nameBox, { target: { value: "My notes" } });
|
|
|
|
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
|
|
|
await waitFor(() => expect(h.uploadFile).toHaveBeenCalledTimes(1));
|
|
const [passedFile, opts] = h.uploadFile.mock.calls[0]!;
|
|
expect(passedFile).toBe(f);
|
|
expect((opts as { displayName?: string }).displayName).toBe("My notes");
|
|
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
|
expect(h.notify.mock.calls[0]![0]).toMatchObject({ message: "downloads.upload.added" });
|
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
|
});
|
|
|
|
it("says so when the server queued the upload for its phone-safe conversion", async () => {
|
|
// A video no phone could play as it is (an iPhone's HEVC .mov) comes back `queued`, not `done`:
|
|
// "File uploaded" alone would send the user to a Library row that is still converting.
|
|
h.uploadFile.mockResolvedValueOnce({ id: 43, status: "queued" });
|
|
wrap();
|
|
fireEvent.change(fileInput(), {
|
|
target: { files: [new File(["x"], "clip.mov", { type: "video/quicktime" })] },
|
|
});
|
|
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
|
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
|
expect(h.notify.mock.calls[0]![0]).toMatchObject({
|
|
message: "downloads.upload.addedConverting",
|
|
});
|
|
});
|
|
|
|
it("puts the file in the folder it was opened from, and its notice opens where it now is", async () => {
|
|
// Done: Files opens on the file itself, in its folder. Still converting: it is in the download
|
|
// queue until it is done.
|
|
h.uploadFile.mockResolvedValueOnce({ id: 42, status: "done", folder_id: 5 } as never);
|
|
wrap(vi.fn(), 5);
|
|
fireEvent.change(fileInput(), { target: { files: [new File(["x"], "a.mp4")] } });
|
|
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
|
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
|
expect((h.uploadFile.mock.calls[0]![1] as { folderId?: number }).folderId).toBe(5);
|
|
(h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick();
|
|
expect(h.openFile).toHaveBeenLastCalledWith(42, 5);
|
|
expect(h.navigateTo).not.toHaveBeenCalled();
|
|
|
|
cleanup();
|
|
h.notify.mockClear();
|
|
h.uploadFile.mockResolvedValueOnce({ id: 43, status: "queued" });
|
|
wrap();
|
|
fireEvent.change(fileInput(), { target: { files: [new File(["x"], "b.mov")] } });
|
|
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
|
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
|
(h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick();
|
|
expect(h.navigateTo).toHaveBeenLastCalledWith("downloads");
|
|
});
|
|
|
|
it("opens ready to send a file dropped on the page", async () => {
|
|
const dropped = new File(["dropped"], "recept.pdf");
|
|
wrap(vi.fn(), null, dropped);
|
|
expect(screen.getByText("recept.pdf")).toBeTruthy();
|
|
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
|
expect(btn.disabled).toBe(false);
|
|
fireEvent.click(btn);
|
|
await waitFor(() => expect(h.uploadFile).toHaveBeenCalledTimes(1));
|
|
expect(h.uploadFile.mock.calls[0]![0]).toBe(dropped);
|
|
});
|
|
|
|
it("keeps Upload disabled until a file is chosen", () => {
|
|
wrap();
|
|
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
|
expect(btn.disabled).toBe(true);
|
|
});
|
|
|
|
// `file.size > cap`, on the boundary in both directions — the cap is inclusive server-side
|
|
// (backend test_upload.py pins that a body of EXACTLY the cap is accepted), so a dialog that
|
|
// refused it would make a legal file un-uploadable with no way round. Two sizes either side of
|
|
// one cap, because a single over-cap case passes just as happily with `>` loosened to `>=`.
|
|
const CAP = 8;
|
|
const pickFile = (bytes: number, name = "f.bin") =>
|
|
fireEvent.change(fileInput(), {
|
|
target: { files: [new File(["x".repeat(bytes)], name)] },
|
|
});
|
|
|
|
it("refuses a file one byte over the cap, naming the limit, instead of sending it", async () => {
|
|
// Both server-side caps answer only after the transfer — and on a default install the one that
|
|
// answers is the edge ceiling, whose refusal carries no number at all and may not even reach
|
|
// the browser. So the dialog checks the size itself: the user learns the limit before a byte
|
|
// moves.
|
|
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
|
wrap();
|
|
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
|
|
pickFile(CAP + 1, "big.bin");
|
|
|
|
await waitFor(() => expect(screen.getByText("downloads.errors.upload.tooLarge")).toBeTruthy());
|
|
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
|
expect(btn.disabled).toBe(true);
|
|
// The drop zone carries the reason, because a natively disabled Submit is not in the tab order
|
|
// to be asked for it.
|
|
const zone = screen.getByText("big.bin").closest("button")!;
|
|
expect(zone.getAttribute("aria-invalid")).toBe("true");
|
|
expect(zone.getAttribute("aria-describedby")).toBe(screen.getByRole("alert").id);
|
|
});
|
|
|
|
it("still highlights as a drop target while the picked file is over the cap", async () => {
|
|
// The state a user is in when they see the refusal is "replace this file". With a file
|
|
// already picked the zone's label shows its NAME rather than `dropActive`, so the border is
|
|
// the only feedback that a drag is over a live target — and a danger border that outranks the
|
|
// drag state reads as "drag-and-drop is off now", sending the user hunting for a control that
|
|
// does not exist. The drop would have worked.
|
|
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
|
wrap();
|
|
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
|
|
pickFile(CAP + 1, "big.bin");
|
|
const zone = screen.getByText("big.bin").closest("button")!;
|
|
expect(zone.className).toContain("border-danger");
|
|
|
|
fireEvent.dragEnter(zone);
|
|
expect(zone.className).toContain("border-accent");
|
|
expect(zone.className).not.toContain("border-danger");
|
|
});
|
|
|
|
it("swallows a Submit press that arrives while the button still holds focus", async () => {
|
|
// The OTHER half of "never sent": the test above covers the natively disabled button (not
|
|
// focused), this one covers the soft-disabled one. `ui/Button` is focus-safe, so a button that
|
|
// turns unavailable while it HOLDS focus stays in the tab order and stays clickable, and it is
|
|
// the primitive's own click guard that refuses — measured: deleting the `tooBig` term from
|
|
// `submit()` changes nothing here, which is why that term is documented as belt and braces
|
|
// rather than as the mechanism. What this pins is the user-visible contract: pressing Upload
|
|
// after dropping a bigger file does not start a transfer the server will refuse.
|
|
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
|
wrap();
|
|
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
|
|
pickFile(CAP, "fits.bin");
|
|
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
|
btn.focus();
|
|
fireEvent.focus(btn);
|
|
pickFile(CAP + 1, "toobig.bin");
|
|
await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
|
|
expect(btn.disabled).toBe(false); // soft-disabled, still focusable — and still clickable
|
|
fireEvent.click(btn);
|
|
expect(h.uploadFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("sends a file of EXACTLY the cap, which the server accepts", async () => {
|
|
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
|
wrap();
|
|
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
|
|
pickFile(CAP, "exact.bin");
|
|
expect(screen.queryByRole("alert")).toBeNull();
|
|
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
|
await waitFor(() => expect(h.uploadFile).toHaveBeenCalledTimes(1));
|
|
});
|
|
|
|
it("re-announces the refusal when a SECOND over-cap file is picked", async () => {
|
|
// Failing the same way again has to be announced again. The message names the CAP, not the
|
|
// file, so two over-cap picks leave the node and its text identical — and a live region that
|
|
// is never touched says nothing. See formAlerts.dom.test.tsx, which holds the same rule for
|
|
// the three other alerts in this app that can repeat.
|
|
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
|
wrap();
|
|
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
|
|
pickFile(CAP + 1, "a.bin");
|
|
const first = await screen.findByRole("alert");
|
|
pickFile(CAP + 2, "b.bin");
|
|
await waitFor(() => expect(screen.getByRole("alert")).not.toBe(first));
|
|
});
|
|
});
|