// @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(); 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()), 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( , ); }; // 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)); }); });