Files
2026-07-13 13:39:12 +08:00

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");
});
});