311 lines
12 KiB
TypeScript
311 lines
12 KiB
TypeScript
// @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");
|
|
});
|
|
});
|