Spaces:
Runtime error
Runtime error
| // @vitest-environment jsdom | |
| import React, { act } from "react"; | |
| import { createRoot } from "react-dom/client"; | |
| import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; | |
| // ββ Mocks βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| vi.mock("next-intl", () => ({ | |
| useTranslations: () => (key: string) => key, | |
| })); | |
| // ββ Import component after mocks βββββββββββββββββββββββββββββββββββββββββββββ | |
| const { default: UploadFileModal } = await import( | |
| "../../../../../src/app/(dashboard)/dashboard/batch/components/UploadFileModal" | |
| ); | |
| // ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = []; | |
| function renderModal( | |
| props: Partial<{ onClose: () => void; onUploaded: (fileId: string) => void }> = {} | |
| ) { | |
| const onClose = props.onClose ?? vi.fn(); | |
| const onUploaded = props.onUploaded ?? vi.fn(); | |
| const el = document.createElement("div"); | |
| document.body.appendChild(el); | |
| const root = createRoot(el); | |
| act(() => { | |
| root.render(<UploadFileModal onClose={onClose} onUploaded={onUploaded} />); | |
| }); | |
| containers.push({ root, el }); | |
| return { el, onClose, onUploaded }; | |
| } | |
| function makeFile(name: string, sizeBytes: number, type = "application/x-jsonlines"): File { | |
| const content = "x".repeat(sizeBytes); | |
| return new File([content], name, { type }); | |
| } | |
| // ββ Lifecycle βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| afterEach(() => { | |
| for (const { root, el } of containers.splice(0)) { | |
| act(() => root.unmount()); | |
| el.remove(); | |
| } | |
| vi.restoreAllMocks(); | |
| }); | |
| beforeEach(() => { | |
| vi.stubGlobal("fetch", vi.fn()); | |
| }); | |
| // ββ Tests βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| describe("UploadFileModal", () => { | |
| // 1. Render: text + Upload button disabled | |
| it("renders drop area and Upload button initially disabled", () => { | |
| const { el } = renderModal(); | |
| // Title renders (i18n key returned as-is by mock) | |
| const header = el.querySelector("h2"); | |
| expect(header).not.toBeNull(); | |
| expect(header!.textContent).toContain("uploadModalTitle"); | |
| // Drop area text | |
| expect(el.textContent).toContain("uploadModalDropOrPick"); | |
| expect(el.textContent).toContain("uploadModalSizeLimit"); | |
| // Upload button should be disabled (no file selected) | |
| const buttons = Array.from(el.querySelectorAll("button")); | |
| const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload")); | |
| expect(uploadBtn).not.toBeNull(); | |
| expect(uploadBtn!.disabled).toBe(true); | |
| }); | |
| // 2. Select .txt file β error (invalid extension) | |
| it("shows error when a non-.jsonl file is selected via input", () => { | |
| const { el } = renderModal(); | |
| const input = el.querySelector("input[type='file']") as HTMLInputElement; | |
| expect(input).not.toBeNull(); | |
| const txtFile = makeFile("data.txt", 100, "text/plain"); | |
| act(() => { | |
| Object.defineProperty(input, "files", { value: [txtFile], configurable: true }); | |
| input.dispatchEvent(new Event("change", { bubbles: true })); | |
| }); | |
| // Error banner should appear | |
| const alert = el.querySelector("[role='alert']"); | |
| expect(alert).not.toBeNull(); | |
| expect(alert!.textContent).toContain("uploadModalError"); | |
| // Upload button still disabled | |
| const buttons = Array.from(el.querySelectorAll("button")); | |
| const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload")); | |
| expect(uploadBtn!.disabled).toBe(true); | |
| }); | |
| // 3. Select valid .jsonl β shows filename + enables Upload | |
| it("shows filename and enables Upload after selecting a valid .jsonl file", () => { | |
| const { el } = renderModal(); | |
| const input = el.querySelector("input[type='file']") as HTMLInputElement; | |
| const jsonlFile = makeFile("batch.jsonl", 1024); | |
| act(() => { | |
| Object.defineProperty(input, "files", { value: [jsonlFile], configurable: true }); | |
| input.dispatchEvent(new Event("change", { bubbles: true })); | |
| }); | |
| // Filename visible | |
| expect(el.textContent).toContain("batch.jsonl"); | |
| // Upload button now enabled | |
| const buttons = Array.from(el.querySelectorAll("button")); | |
| const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload")); | |
| expect(uploadBtn!.disabled).toBe(false); | |
| // No error | |
| expect(el.querySelector("[role='alert']")).toBeNull(); | |
| }); | |
| // 4. Select file > 512 MB β error | |
| it("shows error when file exceeds 512 MB size limit", () => { | |
| const { el } = renderModal(); | |
| const input = el.querySelector("input[type='file']") as HTMLInputElement; | |
| // Cannot allocate 513MB string in V8; simulate large size via Object.defineProperty on a small File | |
| const smallFile = makeFile("huge.jsonl", 10, "application/x-jsonlines"); | |
| const oversizedFile = Object.defineProperty(smallFile, "size", { | |
| value: 513 * 1024 * 1024, | |
| configurable: true, | |
| }) as File; | |
| act(() => { | |
| Object.defineProperty(input, "files", { | |
| value: [oversizedFile], | |
| configurable: true, | |
| }); | |
| input.dispatchEvent(new Event("change", { bubbles: true })); | |
| }); | |
| const alert = el.querySelector("[role='alert']"); | |
| expect(alert).not.toBeNull(); | |
| expect(alert!.textContent).toContain("uploadModalError"); | |
| }); | |
| // 5. Upload with mock fetch 200 β onUploaded called with file id | |
| it("calls onUploaded with the file id on successful upload", async () => { | |
| const onUploaded = vi.fn(); | |
| const onClose = vi.fn(); | |
| const { el } = renderModal({ onUploaded, onClose }); | |
| const fetchMock = vi.fn().mockResolvedValue({ | |
| ok: true, | |
| json: async () => ({ id: "file-id-test" }), | |
| }); | |
| vi.stubGlobal("fetch", fetchMock); | |
| const input = el.querySelector("input[type='file']") as HTMLInputElement; | |
| const jsonlFile = makeFile("batch.jsonl", 100); | |
| act(() => { | |
| Object.defineProperty(input, "files", { value: [jsonlFile], configurable: true }); | |
| input.dispatchEvent(new Event("change", { bubbles: true })); | |
| }); | |
| const buttons = Array.from(el.querySelectorAll("button")); | |
| const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload")); | |
| expect(uploadBtn!.disabled).toBe(false); | |
| await act(async () => { | |
| uploadBtn!.click(); | |
| }); | |
| expect(fetchMock).toHaveBeenCalledTimes(1); | |
| const [url, opts] = fetchMock.mock.calls[0] as [string, RequestInit & { body: FormData }]; | |
| expect(url).toBe("/api/v1/files"); | |
| expect(opts.method).toBe("POST"); | |
| expect(opts.body).toBeInstanceOf(FormData); | |
| expect(onUploaded).toHaveBeenCalledWith("file-id-test"); | |
| expect(onClose).toHaveBeenCalled(); | |
| }); | |
| // 6. Upload with mock fetch 500 β shows error, never exposes raw message | |
| it("shows generic error on fetch 500 β never exposes raw error message", async () => { | |
| const onUploaded = vi.fn(); | |
| const { el } = renderModal({ onUploaded }); | |
| const fetchMock = vi.fn().mockResolvedValue({ | |
| ok: false, | |
| status: 500, | |
| json: async () => ({ error: { message: "stack at /home/user/x.ts:10" } }), | |
| }); | |
| vi.stubGlobal("fetch", fetchMock); | |
| const input = el.querySelector("input[type='file']") as HTMLInputElement; | |
| const jsonlFile = makeFile("batch.jsonl", 100); | |
| act(() => { | |
| Object.defineProperty(input, "files", { value: [jsonlFile], configurable: true }); | |
| input.dispatchEvent(new Event("change", { bubbles: true })); | |
| }); | |
| const buttons = Array.from(el.querySelectorAll("button")); | |
| const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload")); | |
| await act(async () => { | |
| uploadBtn!.click(); | |
| }); | |
| // error banner visible | |
| const alert = el.querySelector("[role='alert']"); | |
| expect(alert).not.toBeNull(); | |
| expect(alert!.textContent).toContain("uploadModalError"); | |
| // Sanitization assert: raw server message must NOT appear in UI | |
| expect(alert!.textContent).not.toMatch(/home\//); | |
| expect(alert!.textContent).not.toMatch(/stack at/); | |
| // onUploaded never called | |
| expect(onUploaded).not.toHaveBeenCalled(); | |
| }); | |
| // 7. Escape key β onClose | |
| it("calls onClose when Escape key is pressed", () => { | |
| const onClose = vi.fn(); | |
| renderModal({ onClose }); | |
| act(() => { | |
| document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); | |
| }); | |
| expect(onClose).toHaveBeenCalledTimes(1); | |
| }); | |
| // 8. Drag-and-drop valid .jsonl β same flow as click | |
| it("accepts a .jsonl file via drag and drop", () => { | |
| const { el } = renderModal(); | |
| // Find the drop zone (div with onDrop) | |
| const dropZone = el.querySelector("[role='button']") as HTMLDivElement; | |
| expect(dropZone).not.toBeNull(); | |
| const jsonlFile = makeFile("dropped.jsonl", 512); | |
| act(() => { | |
| const dragOverEvent = new Event("dragover", { bubbles: true }) as DragEvent; | |
| Object.defineProperty(dragOverEvent, "dataTransfer", { | |
| value: { files: [jsonlFile] }, | |
| configurable: true, | |
| }); | |
| Object.defineProperty(dragOverEvent, "preventDefault", { value: vi.fn() }); | |
| dropZone.dispatchEvent(dragOverEvent); | |
| }); | |
| act(() => { | |
| const dropEvent = new Event("drop", { bubbles: true }) as DragEvent; | |
| Object.defineProperty(dropEvent, "dataTransfer", { | |
| value: { files: [jsonlFile] }, | |
| configurable: true, | |
| }); | |
| Object.defineProperty(dropEvent, "preventDefault", { value: vi.fn() }); | |
| dropZone.dispatchEvent(dropEvent); | |
| }); | |
| // Filename should be visible | |
| expect(el.textContent).toContain("dropped.jsonl"); | |
| // Upload button enabled | |
| const buttons = Array.from(el.querySelectorAll("button")); | |
| const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload")); | |
| expect(uploadBtn!.disabled).toBe(false); | |
| }); | |
| // 9. Sanitization assert: 500 with stack trace in body β UI does NOT show path | |
| it("sanitization: 500 with raw stack trace in error.message β UI never shows file path", async () => { | |
| const { el } = renderModal(); | |
| const fetchMock = vi.fn().mockResolvedValue({ | |
| ok: false, | |
| status: 500, | |
| json: async () => ({ | |
| error: { | |
| message: | |
| "TypeError: Cannot read properties of undefined\n at /home/user/server/route.ts:45:12", | |
| }, | |
| }), | |
| }); | |
| vi.stubGlobal("fetch", fetchMock); | |
| const input = el.querySelector("input[type='file']") as HTMLInputElement; | |
| const jsonlFile = makeFile("test.jsonl", 50); | |
| act(() => { | |
| Object.defineProperty(input, "files", { value: [jsonlFile], configurable: true }); | |
| input.dispatchEvent(new Event("change", { bubbles: true })); | |
| }); | |
| const buttons = Array.from(el.querySelectorAll("button")); | |
| const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload")); | |
| await act(async () => { | |
| uploadBtn!.click(); | |
| }); | |
| const alert = el.querySelector("[role='alert']"); | |
| expect(alert).not.toBeNull(); | |
| // Must NOT contain any path-like content | |
| expect(alert!.textContent).not.toMatch(/\/home\//); | |
| expect(alert!.textContent).not.toMatch(/route\.ts/); | |
| expect(alert!.textContent).not.toMatch(/at \//); | |
| // But must show the safe generic key | |
| expect(alert!.textContent).toContain("uploadModalError"); | |
| }); | |
| }); | |