mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
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:
@@ -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>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
310
tests/unit/dashboard/batch/components/UploadFileModal.test.tsx
Normal file
310
tests/unit/dashboard/batch/components/UploadFileModal.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user