feat(batch): add UploadFileModal + Used by column + Concept card on /batch/files (F5)

- Create UploadFileModal: drag/drop + click-to-pick, .jsonl + 512MB validation, purpose=batch upload, D14 error sanitization, Escape key handler
- Modify files/page.tsx: integrate FilesConceptCard (F3) + Upload toolbar button + UploadFileModal wired to fetchAll refresh
- Modify FilesListTab.tsx: add "Used by" column (D12 — derives related batches client-side), download button per row, delete button with canDelete guard (terminal-only or no related batches), colspan updated 6→8
- Create UploadFileModal.test.tsx (9 tests, all passing): render, invalid ext, valid .jsonl, >512MB (size property mock), upload 200 → onUploaded, upload 500 → sanitized error, Escape→onClose, drag-drop, sanitization assert (no /home/ in alert text)
This commit is contained in:
diegosouzapw
2026-05-27 20:25:43 -03:00
parent c5183ed55a
commit 150fe98ddd
4 changed files with 689 additions and 9 deletions

View File

@@ -65,6 +65,8 @@ const PURPOSE_STYLES_MAP: Record<string, string> = {
assistants: "bg-yellow-500/15 text-yellow-400 border-yellow-500/25",
};
const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled", "expired"]);
function Badge({ value, styles }: Readonly<{ value: string; styles: Record<string, string> }>) {
const cls = styles[value] ?? "bg-gray-500/15 text-gray-400 border-gray-500/25";
return (
@@ -93,6 +95,7 @@ export default function FilesListTab({
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
const [fileContents, setFileContents] = useState<string | null>(null);
const [contentsLoading, setContentsLoading] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const purposes = ["all", ...Array.from(new Set(files.map((f) => f.purpose)))];
@@ -127,6 +130,22 @@ export default function FilesListTab({
}
};
const handleDeleteFile = async (file: FileRecord) => {
setDeletingId(file.id);
try {
const res = await fetch(`/api/v1/files/${file.id}`, { method: "DELETE" });
if (res.ok) {
onRefresh?.();
} else {
console.error("[FilesListTab] DELETE returned", res.status);
}
} catch (err) {
console.error("[FilesListTab] DELETE threw", err);
} finally {
setDeletingId(null);
}
};
return (
<div className="flex flex-col gap-4">
{/* Filters */}
@@ -171,18 +190,24 @@ export default function FilesListTab({
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
Size
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
{t("filesListUsedByColumn")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
Created
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
Expires
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
{/* Actions */}
</th>
</tr>
</thead>
<tbody>
{loading && filtered.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
<td colSpan={8} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
<div className="flex items-center justify-center gap-2">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-[var(--color-accent)]" />
Loading
@@ -191,7 +216,7 @@ export default function FilesListTab({
</tr>
) : filtered.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
<td colSpan={8} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
No files found
</td>
</tr>
@@ -199,6 +224,17 @@ export default function FilesListTab({
filtered.map((file) => {
const fileCreatedAt = file.createdAt;
const fileExpiresAt = file.expiresAt;
// D12 — derive "Used by" from batches prop
const related = (batches ?? []).filter(
(b) =>
b.inputFileId === file.id ||
b.outputFileId === file.id ||
b.errorFileId === file.id
);
const allTerminal = related.every((b) => TERMINAL_STATUSES.has(b.status));
const canDelete = related.length === 0 || allTerminal;
return (
<tr
key={file.id}
@@ -225,12 +261,75 @@ export default function FilesListTab({
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)] whitespace-nowrap">
{formatBytes(file.bytes)}
</td>
{/* "Used by" column (D12) */}
<td className="px-4 py-3 text-xs">
{related.length === 0 ? (
<span className="text-[var(--color-text-muted)]">
{t("filesListUsedByNone")}
</span>
) : (
<div
className="flex flex-col gap-0.5"
title={related.map((b) => b.id).join(", ")}
>
{related.slice(0, 2).map((b) => (
<span
key={b.id}
className="font-mono text-[10px] text-[var(--color-text-main)]"
>
{b.id.slice(0, 16)}
</span>
))}
{related.length > 2 && (
<span className="text-[10px] text-[var(--color-text-muted)]">
+{related.length - 2}
</span>
)}
</div>
)}
</td>
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)] whitespace-nowrap">
{fileCreatedAt ? relativeTime(fileCreatedAt) : "—"}
</td>
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)] whitespace-nowrap">
{fileExpiresAt ? relativeExpiration(fileExpiresAt) : "Never"}
</td>
{/* Actions column */}
<td
className="px-4 py-3 whitespace-nowrap"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-1">
{/* Download button */}
<a
href={`/api/v1/files/${file.id}/content`}
download={file.filename}
className="p-1.5 rounded text-[var(--color-text-muted)] hover:text-[var(--color-accent)] hover:bg-[var(--color-bg-alt)] transition-colors"
title={t("filesListDownload")}
onClick={(e) => e.stopPropagation()}
>
<span className="material-symbols-outlined text-[16px]">download</span>
</a>
{/* Delete button */}
<button
onClick={(e) => {
e.stopPropagation();
void handleDeleteFile(file);
}}
disabled={!canDelete || deletingId === file.id}
title={
canDelete
? t("filesListDelete")
: "File in use by active batch"
}
className="p-1.5 rounded text-[var(--color-text-muted)] hover:text-red-400 hover:bg-[var(--color-bg-alt)] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-[16px]">
{deletingId === file.id ? "hourglass_empty" : "delete"}
</span>
</button>
</div>
</td>
</tr>
);
})

View File

@@ -0,0 +1,245 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
const MAX_BYTES = 512 * 1024 * 1024; // 512 MB
interface Props {
onClose: () => void;
onUploaded: (fileId: string) => void;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
export default function UploadFileModal({ onClose, onUploaded }: Props) {
const t = useTranslations("common");
const [file, setFile] = useState<File | null>(null);
const [dragging, setDragging] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
// Escape key → onClose
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
function validateAndSet(picked: File) {
setError(null);
if (!picked.name.endsWith(".jsonl")) {
setError(t("uploadModalError"));
return;
}
if (picked.size > MAX_BYTES) {
setError(t("uploadModalError"));
return;
}
setFile(picked);
}
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
const picked = e.target.files?.[0];
if (picked) validateAndSet(picked);
// Reset input so the same file can be re-picked after Remove
e.target.value = "";
}
function handleDragOver(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
setDragging(true);
}
function handleDragLeave(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
setDragging(false);
}
function handleDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
setDragging(false);
const picked = e.dataTransfer.files?.[0];
if (picked) validateAndSet(picked);
}
async function handleUpload() {
if (!file || uploading) return;
setUploading(true);
setError(null);
try {
const form = new FormData();
form.append("purpose", "batch"); // D22 hardcoded
form.append("file", file);
const res = await fetch("/api/v1/files", { method: "POST", body: form });
if (!res.ok) {
setError(t("uploadModalError"));
return;
}
const data = (await res.json()) as { id: string };
onUploaded(data.id);
onClose();
} catch (err) {
console.error("[UploadFileModal]", err);
setError(t("uploadModalError"));
} finally {
setUploading(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Overlay */}
<div
ref={overlayRef}
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Panel */}
<div
className="relative w-full sm:max-w-md bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl shadow-2xl animate-in fade-in slide-in-from-bottom-4 duration-200 flex flex-col"
role="dialog"
aria-modal="true"
aria-label={t("uploadModalTitle")}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border)]">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-[var(--color-accent)]">
upload_file
</span>
<h2 className="text-base font-semibold text-[var(--color-text-main)]">
{t("uploadModalTitle")}
</h2>
</div>
<button
onClick={onClose}
aria-label="Close"
className="p-1.5 rounded-lg text-[var(--color-text-muted)] hover:bg-[var(--color-bg-alt)] transition-colors"
>
<span className="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
{/* Body */}
<div className="px-6 py-5 flex flex-col gap-4">
{/* Error banner */}
{error && (
<div
role="alert"
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/25 text-red-400 text-sm"
>
<span className="material-symbols-outlined text-[16px]">error</span>
{error}
</div>
)}
{/* Drop zone or file info */}
{!file ? (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
className={`flex flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed px-6 py-10 cursor-pointer transition-colors select-none ${
dragging
? "border-[var(--color-accent)] bg-[var(--color-accent)]/5"
: "border-[var(--color-border)] hover:border-[var(--color-accent)]/60 hover:bg-[var(--color-bg-alt)]"
}`}
role="button"
tabIndex={0}
aria-label={t("uploadModalDropOrPick")}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
}}
>
<span
className={`material-symbols-outlined text-[40px] ${dragging ? "text-[var(--color-accent)]" : "text-[var(--color-text-muted)]"}`}
>
upload_file
</span>
<span className="text-sm text-[var(--color-text-main)] text-center">
{t("uploadModalDropOrPick")}
</span>
<span className="text-xs text-[var(--color-text-muted)] text-center">
{t("uploadModalSizeLimit")}
</span>
<input
ref={inputRef}
type="file"
accept=".jsonl"
className="hidden"
onChange={handleInputChange}
aria-hidden="true"
tabIndex={-1}
/>
</div>
) : (
<div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[var(--color-bg-alt)] border border-[var(--color-border)]">
<span className="material-symbols-outlined text-[24px] text-[var(--color-accent)] shrink-0">
description
</span>
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
<span
className="text-sm font-medium text-[var(--color-text-main)] truncate"
title={file.name}
>
{file.name}
</span>
<span className="text-xs text-[var(--color-text-muted)]">
{formatBytes(file.size)}
</span>
</div>
<button
onClick={() => {
setFile(null);
setError(null);
}}
className="shrink-0 text-xs text-[var(--color-text-muted)] hover:text-red-400 transition-colors px-2 py-1 rounded border border-[var(--color-border)] hover:border-red-400/40"
>
Remove
</button>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--color-border)]">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-lg text-[var(--color-text-muted)] hover:bg-[var(--color-bg-alt)] transition-colors border border-[var(--color-border)]"
>
{t("uploadModalCancel")}
</button>
<button
onClick={() => void handleUpload()}
disabled={!file || uploading}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-[var(--color-accent)] text-white hover:opacity-90 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
>
{uploading ? (
<>
<span className="animate-spin inline-block rounded-full h-4 w-4 border-b-2 border-white" />
Uploading
</>
) : (
<>
<span className="material-symbols-outlined text-[16px]">upload</span>
{t("uploadModalUpload")}
</>
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,16 +1,21 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import FilesListTab from "../FilesListTab";
import FilesConceptCard from "../components/FilesConceptCard";
import UploadFileModal from "../components/UploadFileModal";
import { mapFileApiToRecord, mapBatchApiToRecord } from "../batch-utils";
import { FileRecord } from "@/lib/db/files";
import { BatchRecord } from "@/lib/db/batches";
export default function BatchFilesPage() {
const t = useTranslations("common");
const [files, setFiles] = useState<FileRecord[]>([]);
const [filesTotal, setFilesTotal] = useState(0);
const [batches, setBatches] = useState<BatchRecord[]>([]);
const [loading, setLoading] = useState(true);
const [showUpload, setShowUpload] = useState(false);
const fetchAll = useCallback(async () => {
setLoading(true);
@@ -40,12 +45,33 @@ export default function BatchFilesPage() {
}, [fetchAll]);
return (
<FilesListTab
files={files}
filesTotal={filesTotal}
loading={loading}
onRefresh={fetchAll}
batches={batches}
/>
<div className="flex flex-col gap-6">
<FilesConceptCard />
<div className="flex justify-end">
<button
onClick={() => setShowUpload(true)}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-[var(--color-accent)] text-white hover:opacity-90 transition-opacity"
>
<span className="material-symbols-outlined text-[16px]">upload</span>
{t("filesListUploadButton")}
</button>
</div>
<FilesListTab
files={files}
filesTotal={filesTotal}
loading={loading}
onRefresh={fetchAll}
batches={batches}
/>
{showUpload && (
<UploadFileModal
onClose={() => setShowUpload(false)}
onUploaded={() => {
setShowUpload(false);
void fetchAll();
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,310 @@
// @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");
});
});