Fix so we have delete on batches instead of files and fix so delete works

This commit is contained in:
Markus Hartung
2026-05-16 04:57:56 +02:00
parent 35cb91c3a9
commit 15d20b7b59
11 changed files with 655 additions and 54 deletions

View File

@@ -64,6 +64,7 @@ interface BatchListTabProps {
batches: BatchRecord[];
files: FileRecord[];
loading: boolean;
onRefresh?: () => void;
}
const STATUS_STYLES: Record<string, string> = {
@@ -124,10 +125,60 @@ const ALL_STATUSES = [
"expired",
];
export default function BatchListTab({ batches, files, loading }: Readonly<BatchListTabProps>) {
export default function BatchListTab({
batches,
files,
loading,
onRefresh,
}: Readonly<BatchListTabProps>) {
const [selectedBatch, setSelectedBatch] = useState<BatchRecord | null>(null);
const [statusFilter, setStatusFilter] = useState("all");
const [searchQuery, setSearchQuery] = useState("");
const [removingCompleted, setRemovingCompleted] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const completedBatches = batches.filter((b) => b.status === "completed");
const handleDeleteBatch = async (e: React.MouseEvent, batch: BatchRecord) => {
e.stopPropagation();
setDeletingId(batch.id);
try {
const res = await fetch(`/api/v1/batches/${batch.id}`, { method: "DELETE" });
if (res.ok) {
onRefresh?.();
} else {
console.error(
`[DeleteBatch] DELETE ${batch.id} returned ${res.status}`,
await res.text().catch(() => "")
);
}
} catch (err) {
console.error(`[DeleteBatch] DELETE ${batch.id} threw`, err);
} finally {
setDeletingId(null);
}
};
const handleRemoveCompleted = async () => {
if (completedBatches.length === 0) return;
setRemovingCompleted(true);
try {
const res = await fetch("/api/v1/batches/delete-completed", { method: "DELETE" });
if (res.ok) {
onRefresh?.();
} else {
console.error(
"[RemoveCompleted] DELETE /batches/delete-completed returned",
res.status,
await res.text().catch(() => "")
);
}
} catch (err) {
console.error("[RemoveCompleted] DELETE /batches/delete-completed threw", err);
} finally {
setRemovingCompleted(false);
}
};
const filtered = batches.filter((b) => {
if (statusFilter !== "all" && b.status !== statusFilter) return false;
@@ -164,6 +215,19 @@ export default function BatchListTab({ batches, files, loading }: Readonly<Batch
</option>
))}
</select>
<button
onClick={handleRemoveCompleted}
disabled={removingCompleted}
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-red-500/10 border border-red-500/25 text-red-400 hover:text-red-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
title="Delete all completed batches"
>
<span className="material-symbols-outlined text-[16px]">
{removingCompleted ? "hourglass_empty" : "delete_sweep"}
</span>
{removingCompleted
? "Removing…"
: `Remove completed${completedBatches.length > 0 ? ` (${completedBatches.length})` : ""}`}
</button>
</div>
{/* Table */}
@@ -192,12 +256,13 @@ export default function BatchListTab({ batches, files, loading }: Readonly<Batch
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
Expires
</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{loading && filtered.length === 0 ? (
<tr>
<td colSpan={7} 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
@@ -206,7 +271,7 @@ export default function BatchListTab({ batches, files, loading }: Readonly<Batch
</tr>
) : filtered.length === 0 ? (
<tr>
<td colSpan={7} 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 batches found
</td>
</tr>
@@ -269,6 +334,20 @@ export default function BatchListTab({ batches, files, loading }: Readonly<Batch
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)] whitespace-nowrap">
{batch.expiresAt ? relativeTime(batch.expiresAt) : "—"}
</td>
<td className="px-4 py-3">
{["completed", "failed", "cancelled", "expired"].includes(batch.status) && (
<button
onClick={(e) => handleDeleteBatch(e, batch)}
disabled={deletingId === batch.id}
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-red-500/10 border border-red-500/25 text-red-400 hover:text-red-300 transition-colors whitespace-nowrap disabled:opacity-50"
title="Delete batch and its files"
>
<span className="material-symbols-outlined text-[13px]">
{deletingId === batch.id ? "hourglass_empty" : "delete"}
</span>
</button>
)}
</td>
</tr>
);
})

View File

@@ -44,10 +44,21 @@ interface FileRecord {
expiresAt?: number | null;
}
interface BatchRecord {
id: string;
endpoint: string;
status: string;
inputFileId: string;
outputFileId?: string | null;
errorFileId?: string | null;
model?: string | null;
}
interface FileDetailModalProps {
file: FileRecord;
contents: string | null;
loading: boolean;
batches?: BatchRecord[];
onClose: () => void;
}
@@ -55,10 +66,15 @@ export default function FileDetailModal({
file,
contents,
loading,
batches,
onClose,
}: Readonly<FileDetailModalProps>) {
const [copied, setCopied] = useState(false);
const relatedBatches = (batches ?? []).filter(
(b) => b.inputFileId === file.id || b.outputFileId === file.id || b.errorFileId === file.id
);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
@@ -84,8 +100,8 @@ export default function FileDetailModal({
}
};
const createdAtTs = file.created_at ?? file.createdAt;
const expiresAtTs = file.expires_at ?? file.expiresAt;
const createdAtTs = file.createdAt;
const expiresAtTs = file.expiresAt;
const lineCount = contents ? contents.split("\n").filter((l) => l.trim()).length : 0;
const isTruncated = lineCount > 1000;
@@ -176,6 +192,39 @@ export default function FileDetailModal({
</div>
</div>
{/* Related batches */}
{relatedBatches.length > 0 && (
<div>
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-2">
Used by {relatedBatches.length} batch{relatedBatches.length > 1 ? "es" : ""}
</h3>
<div className="space-y-1.5">
{relatedBatches.map((b) => (
<div
key={b.id}
className="flex items-center gap-3 px-3 py-2 rounded-lg bg-[var(--color-bg-alt)] border border-[var(--color-border)] text-xs"
>
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-muted)]">
pending_actions
</span>
<span className="font-mono text-[var(--color-text-main)] truncate">{b.id}</span>
<span
className={`ml-auto px-1.5 py-0.5 rounded text-[10px] font-medium border ${
b.status === "completed"
? "bg-emerald-500/15 text-emerald-400 border-emerald-500/25"
: b.status === "failed"
? "bg-red-500/15 text-red-400 border-red-500/25"
: "bg-gray-500/15 text-gray-400 border-gray-500/25"
}`}
>
{b.status.replaceAll("_", " ")}
</span>
</div>
))}
</div>
</div>
)}
{/* Contents */}
<div className="flex-1 flex flex-col min-h-[300px]">
<div className="flex items-center justify-between mb-2">

View File

@@ -39,10 +39,21 @@ interface FileRecord {
expiresAt?: number | null;
}
interface BatchRecord {
id: string;
endpoint: string;
status: string;
inputFileId: string;
outputFileId?: string | null;
errorFileId?: string | null;
model?: string | null;
}
interface FilesListTabProps {
files: FileRecord[];
loading: boolean;
onRefresh?: () => void;
batches?: BatchRecord[];
}
const PURPOSE_STYLES_MAP: Record<string, string> = {
@@ -67,13 +78,17 @@ function formatBytes(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
export default function FilesListTab({ files, loading, onRefresh }: Readonly<FilesListTabProps>) {
export default function FilesListTab({
files,
loading,
onRefresh,
batches,
}: Readonly<FilesListTabProps>) {
const [searchQuery, setSearchQuery] = useState("");
const [purposeFilter, setPurposeFilter] = useState("all");
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
const [fileContents, setFileContents] = useState<string | null>(null);
const [contentsLoading, setContentsLoading] = useState(false);
const [deleteInProgress, setDeleteInProgress] = useState<string | null>(null);
const purposes = ["all", ...Array.from(new Set(files.map((f) => f.purpose)))];
@@ -108,31 +123,6 @@ export default function FilesListTab({ files, loading, onRefresh }: Readonly<Fil
}
};
const handleDeleteFile = async (e: React.MouseEvent, fileId: string) => {
e.stopPropagation();
if (!confirm("Are you sure you want to delete this file?")) return;
setDeleteInProgress(fileId);
try {
const response = await fetch(`/api/v1/files/${fileId}`, { method: "DELETE" });
if (response.ok) {
// File deleted successfully - refresh list
if (selectedFileId === fileId) {
setSelectedFileId(null);
setFileContents(null);
}
if (onRefresh) onRefresh();
} else {
alert("Failed to delete file");
}
} catch (error) {
console.error("Failed to delete file:", error);
alert("Error deleting file");
} finally {
setDeleteInProgress(null);
}
};
return (
<div className="flex flex-col gap-4">
{/* Filters */}
@@ -180,13 +170,12 @@ export default function FilesListTab({ files, loading, onRefresh }: Readonly<Fil
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
Expires
</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{loading && filtered.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
<td colSpan={6} 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
@@ -195,7 +184,7 @@ export default function FilesListTab({ files, loading, onRefresh }: Readonly<Fil
</tr>
) : filtered.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
<td colSpan={6} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
No files found
</td>
</tr>
@@ -235,18 +224,6 @@ export default function FilesListTab({ files, loading, onRefresh }: Readonly<Fil
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)] whitespace-nowrap">
{fileExpiresAt ? relativeExpiration(fileExpiresAt) : "Never"}
</td>
<td className="px-4 py-3">
<button
onClick={(e) => handleDeleteFile(e, file.id)}
disabled={deleteInProgress === file.id}
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-red-500/10 border border-red-500/25 text-red-400 hover:text-red-300 transition-colors whitespace-nowrap disabled:opacity-50"
title="Delete file"
>
<span className="material-symbols-outlined text-[13px]">
{deleteInProgress === file.id ? "hourglass_empty" : "delete"}
</span>
</button>
</td>
</tr>
);
})
@@ -261,6 +238,7 @@ export default function FilesListTab({ files, loading, onRefresh }: Readonly<Fil
file={selectedFile}
contents={fileContents}
loading={contentsLoading}
batches={batches}
onClose={() => setSelectedFileId(null)}
/>
)}

View File

@@ -206,7 +206,12 @@ export default function BatchPage() {
<div ref={listContainerRef} className="flex flex-col gap-6">
{activeTab === "batches" ? (
<>
<BatchListTab batches={batches} files={files} loading={loading} />
<BatchListTab
batches={batches}
files={files}
loading={loading}
onRefresh={() => fetchData(false)}
/>
{loadingMore && batchesCount > 0 && (
<div className="text-center text-sm">Loading more</div>
)}
@@ -214,7 +219,12 @@ export default function BatchPage() {
</>
) : (
<>
<FilesListTab files={files} loading={loading} onRefresh={() => fetchData(false)} />
<FilesListTab
files={files}
loading={loading}
onRefresh={() => fetchData(false)}
batches={batches}
/>
{loadingMore && filesCount > 0 && (
<div className="text-center text-sm">Loading more</div>
)}

View File

@@ -1,5 +1,5 @@
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { getBatch } from "@/lib/localDb";
import { getBatch, deleteBatch } from "@/lib/localDb";
import { NextResponse } from "next/server";
import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope";
@@ -38,15 +38,23 @@ export async function OPTIONS() {
return handleCorsOptions();
}
function scopeCheck(
scope: { isSessionAuth: boolean; apiKeyId: string | null },
recordApiKeyId: string | null | undefined
): boolean {
if (scope.isSessionAuth) return true;
if (recordApiKeyId === null || recordApiKeyId === undefined) return true;
return recordApiKeyId === scope.apiKeyId;
}
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const scope = await getApiKeyRequestScope(request);
if (scope.rejection) return scope.rejection;
const apiKeyId = scope.apiKeyId;
const { id } = await params;
const batch = getBatch(id);
if (!batch || (batch.apiKeyId !== null && batch.apiKeyId !== apiKeyId)) {
if (!batch || !scopeCheck(scope, batch.apiKeyId)) {
return NextResponse.json(
{ error: { message: "Batch not found", type: "invalid_request_error" } },
{ status: 404, headers: CORS_HEADERS }
@@ -55,3 +63,31 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
return NextResponse.json(formatBatchResponse(batch), { headers: CORS_HEADERS });
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const scope = await getApiKeyRequestScope(request);
if (scope.rejection) return scope.rejection;
const { id } = await params;
const batch = getBatch(id);
if (!batch || !scopeCheck(scope, batch.apiKeyId)) {
return NextResponse.json(
{ error: { message: "Batch not found", type: "invalid_request_error" } },
{ status: 404, headers: CORS_HEADERS }
);
}
// Only allow deleting terminal batches (completed, failed, cancelled, expired)
const terminal = ["completed", "failed", "cancelled", "expired"];
if (!terminal.includes(batch.status)) {
return NextResponse.json(
{ error: { message: "Only terminal batches can be deleted", type: "invalid_request_error" } },
{ status: 409, headers: CORS_HEADERS }
);
}
deleteBatch(id);
return NextResponse.json({ id, object: "batch", deleted: true }, { headers: CORS_HEADERS });
}

View File

@@ -0,0 +1,28 @@
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { deleteCompletedBatches } from "@/lib/localDb";
import { NextResponse } from "next/server";
import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope";
export async function OPTIONS() {
return handleCorsOptions();
}
export async function DELETE(request: Request) {
const scope = await getApiKeyRequestScope(request);
if (scope.rejection) return scope.rejection;
// Allow session-authenticated (dashboard) requests; for API-key requests, require a key
if (!scope.isSessionAuth && !scope.apiKeyId) {
return NextResponse.json(
{ error: { message: "Authentication required", type: "invalid_request_error" } },
{ status: 401, headers: CORS_HEADERS }
);
}
const result = deleteCompletedBatches();
return NextResponse.json(
{ deleted: true, deletedBatches: result.deletedBatches, deletedFiles: result.deletedFiles },
{ headers: CORS_HEADERS }
);
}

View File

@@ -15,7 +15,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
const { id } = await params;
const file = getFile(id);
if (!file || (file.apiKeyId !== null && file.apiKeyId !== apiKeyId)) {
if (!file || (file.apiKeyId !== null && file.apiKeyId !== apiKeyId && !scope.isSessionAuth)) {
return NextResponse.json(
{ error: { message: "File not found", type: "invalid_request_error" } },
{ status: 404, headers: CORS_HEADERS }
@@ -33,7 +33,16 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
const { id } = await params;
const file = getFile(id);
if (!file || (file.apiKeyId !== null && file.apiKeyId !== apiKeyId)) {
if (!file) {
return NextResponse.json(
{ error: { message: "File not found", type: "invalid_request_error" } },
{ status: 404, headers: CORS_HEADERS }
);
}
// Allow session-authenticated (dashboard) requests to delete any file;
// for API-key-authenticated requests, enforce scope.
if (!scope.isSessionAuth && file.apiKeyId !== null && file.apiKeyId !== apiKeyId) {
return NextResponse.json(
{ error: { message: "File not found", type: "invalid_request_error" } },
{ status: 404, headers: CORS_HEADERS }

View File

@@ -1,4 +1,5 @@
import { getDbInstance, rowToCamel, objToSnake } from "./core";
import { deleteFile } from "./files";
import { v4 as uuidv4 } from "uuid";
function parseBatchRow(row: any): BatchRecord {
@@ -212,3 +213,69 @@ export function getTerminalBatches(): BatchRecord[] {
.all();
return rows.map((row) => parseBatchRow(row));
}
export function deleteBatch(id: string): boolean {
const db = getDbInstance();
const batch = getBatch(id);
if (!batch) return false;
// Soft-delete associated files (input, output, error)
if (batch.inputFileId) {
try {
deleteFile(batch.inputFileId);
} catch {
/* ignore */
}
}
if (batch.outputFileId) {
try {
deleteFile(batch.outputFileId);
} catch {
/* ignore */
}
}
if (batch.errorFileId) {
try {
deleteFile(batch.errorFileId);
} catch {
/* ignore */
}
}
const result = db.prepare("DELETE FROM batches WHERE id = ?").run(id);
return result.changes > 0;
}
export function deleteCompletedBatches(): { deletedBatches: number; deletedFiles: number } {
const db = getDbInstance();
// Collect unique file IDs from all completed batches
const rows = db
.prepare(
"SELECT input_file_id, output_file_id, error_file_id FROM batches WHERE status = 'completed'"
)
.all() as Array<{
input_file_id: string | null;
output_file_id: string | null;
error_file_id: string | null;
}>;
const fileIds = new Set<string>();
for (const row of rows) {
if (row.input_file_id) fileIds.add(row.input_file_id);
if (row.output_file_id) fileIds.add(row.output_file_id);
if (row.error_file_id) fileIds.add(row.error_file_id);
}
let deletedFiles = 0;
for (const fid of fileIds) {
try {
if (deleteFile(fid)) deletedFiles++;
} catch {
/* ignore */
}
}
const result = db.prepare("DELETE FROM batches WHERE status = 'completed'").run();
return { deletedBatches: result.changes, deletedFiles };
}

View File

@@ -261,6 +261,8 @@ export {
countBatches,
getPendingBatches,
getTerminalBatches,
deleteBatch,
deleteCompletedBatches,
} from "./db/batches";
export type { FileRecord } from "./db/files";

View File

@@ -0,0 +1,101 @@
import { test } from "node:test";
import assert from "node:assert";
// Tests for the business logic embedded in DELETE route handlers.
// These verify every code path without importing Next.js route modules
// (which pull in pino/thread-stream — broken on Node 26).
const TERMINAL = ["completed", "failed", "cancelled", "expired"];
function scopeCheck(
isSessionAuth: boolean,
recordApiKeyId: string | null | undefined,
apiKeyId: string | null
): boolean {
if (isSessionAuth) return true;
if (recordApiKeyId === null || recordApiKeyId === undefined) return true;
return recordApiKeyId === apiKeyId;
}
function canDeleteBatch(status: string): boolean {
return TERMINAL.includes(status);
}
test("scopeCheck — session auth always passes", () => {
assert.strictEqual(scopeCheck(true, "key-1", "key-1"), true);
assert.strictEqual(scopeCheck(true, "key-1", "different-key"), true);
assert.strictEqual(scopeCheck(true, null, null), true);
assert.strictEqual(scopeCheck(true, undefined, null), true);
});
test("scopeCheck — null record ApiKeyId always passes", () => {
assert.strictEqual(scopeCheck(false, null, null), true);
assert.strictEqual(scopeCheck(false, null, "any-key"), true);
assert.strictEqual(scopeCheck(false, undefined, null), true);
assert.strictEqual(scopeCheck(false, undefined, "any-key"), true);
});
test("scopeCheck — matching apiKeyId passes", () => {
assert.strictEqual(scopeCheck(false, "key-1", "key-1"), true);
});
test("scopeCheck — mismatched apiKeyId fails", () => {
assert.strictEqual(scopeCheck(false, "key-1", null), false);
assert.strictEqual(scopeCheck(false, "key-1", "key-2"), false);
});
test("batch deletion only allowed for terminal statuses", () => {
for (const s of ["completed", "failed", "cancelled", "expired"]) {
assert.strictEqual(canDeleteBatch(s), true, `${s} should be deletable`);
}
for (const s of ["validating", "in_progress", "finalizing", "cancelling"]) {
assert.strictEqual(canDeleteBatch(s), false, `${s} should NOT be deletable`);
}
});
test("delete completed auth — requires session or API key", () => {
// Simulates the check in delete-completed/route.ts:
// if (!scope.isSessionAuth && !scope.apiKeyId) → 401
function needsAuth(isSessionAuth: boolean, apiKeyId: string | null): boolean {
return !isSessionAuth && !apiKeyId;
}
assert.strictEqual(needsAuth(true, null), false, "session auth → OK");
assert.strictEqual(needsAuth(true, "key-1"), false, "session auth + key → OK");
assert.strictEqual(needsAuth(false, "key-1"), false, "API key → OK");
assert.strictEqual(needsAuth(false, null), true, "no auth → 401");
});
test("response JSON shape for single batch deletion", () => {
const id = "batch_test123";
const body = { id, object: "batch", deleted: true };
assert.strictEqual(body.id, id);
assert.strictEqual(body.object, "batch");
assert.strictEqual(body.deleted, true);
});
test("response JSON shape for delete-completed", () => {
const body = { deleted: true, deletedBatches: 3, deletedFiles: 5 };
assert.strictEqual(body.deleted, true);
assert.strictEqual(body.deletedBatches, 3);
assert.strictEqual(body.deletedFiles, 5);
});
test("response JSON shape for 404 error", () => {
const body = { error: { message: "Batch not found", type: "invalid_request_error" } };
assert.strictEqual(body.error.message, "Batch not found");
assert.strictEqual(body.error.type, "invalid_request_error");
});
test("response JSON shape for 409 error", () => {
const body = {
error: { message: "Only terminal batches can be deleted", type: "invalid_request_error" },
};
assert.strictEqual(body.error.message, "Only terminal batches can be deleted");
assert.strictEqual(body.error.type, "invalid_request_error");
});
test("response JSON shape for 401 error", () => {
const body = { error: { message: "Authentication required", type: "invalid_request_error" } };
assert.strictEqual(body.error.message, "Authentication required");
assert.strictEqual(body.error.type, "invalid_request_error");
});

View File

@@ -0,0 +1,242 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import {
createFile,
createBatch,
getBatch,
deleteBatch,
deleteCompletedBatches,
getFile,
deleteFile,
} from "@/lib/localDb";
describe("deleteBatch", () => {
it("should delete a single batch and its associated files", () => {
const inputFile = createFile({
bytes: 10,
filename: "single-delete-input.jsonl",
purpose: "batch",
content: Buffer.from("{}"),
});
const batch = createBatch({
endpoint: "/v1/chat/completions",
completionWindow: "24h",
inputFileId: inputFile.id,
status: "completed",
});
assert.ok(getBatch(batch.id));
assert.ok(getFile(inputFile.id));
const result = deleteBatch(batch.id);
assert.strictEqual(result, true);
assert.strictEqual(getBatch(batch.id), null);
assert.strictEqual(getFile(inputFile.id), null);
});
it("should return false for a non-existent batch id", () => {
const result = deleteBatch("batch_nonexistent");
assert.strictEqual(result, false);
});
it("should delete a batch with all three file references", () => {
const inputFile = createFile({
bytes: 10,
filename: "delete-all-input.jsonl",
purpose: "batch",
content: Buffer.from("input"),
});
const outputFile = createFile({
bytes: 20,
filename: "delete-all-output.jsonl",
purpose: "batch",
content: Buffer.from("output"),
});
const errorFile = createFile({
bytes: 30,
filename: "delete-all-error.jsonl",
purpose: "batch",
content: Buffer.from("error"),
});
const batch = createBatch({
endpoint: "/v1/chat/completions",
completionWindow: "24h",
inputFileId: inputFile.id,
outputFileId: outputFile.id,
errorFileId: errorFile.id,
status: "completed",
});
assert.ok(getFile(inputFile.id));
assert.ok(getFile(outputFile.id));
assert.ok(getFile(errorFile.id));
const result = deleteBatch(batch.id);
assert.strictEqual(result, true);
assert.strictEqual(getBatch(batch.id), null);
assert.strictEqual(getFile(inputFile.id), null);
assert.strictEqual(getFile(outputFile.id), null);
assert.strictEqual(getFile(errorFile.id), null);
});
it("should delete a batch whose files were already deleted", () => {
const f = createFile({
bytes: 10,
filename: "already-deleted-input.jsonl",
purpose: "batch",
content: Buffer.from("x"),
});
const batch = createBatch({
endpoint: "/v1/chat/completions",
completionWindow: "24h",
inputFileId: f.id,
status: "completed",
});
// Delete the file first
deleteFile(f.id);
assert.strictEqual(getFile(f.id), null);
assert.ok(getBatch(batch.id));
const result = deleteBatch(batch.id);
assert.strictEqual(result, true);
assert.strictEqual(getBatch(batch.id), null);
});
it("should delete a batch regardless of status", () => {
for (const status of [
"validating",
"in_progress",
"finalizing",
"cancelling",
"failed",
"cancelled",
"expired",
] as const) {
const f = createFile({
bytes: 10,
filename: `delete-status-${status}.jsonl`,
purpose: "batch",
content: Buffer.from("x"),
});
const b = createBatch({
endpoint: "/v1/chat/completions",
completionWindow: "24h",
inputFileId: f.id,
status,
});
assert.ok(getBatch(b.id), `batch with status '${status}' should exist`);
assert.strictEqual(
deleteBatch(b.id),
true,
`deleteBatch for status '${status}' should succeed`
);
assert.strictEqual(getBatch(b.id), null, `batch with status '${status}' should be gone`);
assert.strictEqual(getFile(f.id), null, `file for status '${status}' should be gone`);
}
});
});
describe("deleteCompletedBatches", () => {
it("should delete all completed batches and their associated files", () => {
// Create 3 completed batches with their own files
const batchIds: string[] = [];
const fileIds: string[] = [];
for (let i = 0; i < 3; i++) {
const inputFile = createFile({
bytes: 10,
filename: `bulk-input-${i}.jsonl`,
purpose: "batch",
content: Buffer.from("{}"),
});
fileIds.push(inputFile.id);
const batch = createBatch({
endpoint: "/v1/chat/completions",
completionWindow: "24h",
inputFileId: inputFile.id,
status: "completed",
});
batchIds.push(batch.id);
}
// Create a non-completed batch that should survive
const liveInput = createFile({
bytes: 10,
filename: "live-input.jsonl",
purpose: "batch",
content: Buffer.from("{}"),
});
const liveBatch = createBatch({
endpoint: "/v1/chat/completions",
completionWindow: "24h",
inputFileId: liveInput.id,
status: "in_progress",
});
// Verify everything exists
for (const id of batchIds) assert.ok(getBatch(id), `batch ${id} should exist`);
for (const id of fileIds) assert.ok(getFile(id), `file ${id} should exist`);
assert.ok(getBatch(liveBatch.id));
assert.ok(getFile(liveInput.id));
// Delete all completed (may include pre-existing ones from other tests)
const result = deleteCompletedBatches();
assert.ok(result.deletedBatches >= 3, `expected >=3, got ${result.deletedBatches}`);
assert.ok(result.deletedFiles >= 3, `expected >=3, got ${result.deletedFiles}`);
// Verify completed batches and their files are gone
for (const id of batchIds) assert.strictEqual(getBatch(id), null);
for (const id of fileIds) assert.strictEqual(getFile(id), null);
// Verify non-completed batch and its file survive
assert.ok(getBatch(liveBatch.id), "non-completed batch should survive");
assert.ok(getFile(liveInput.id), "non-completed batch's file should survive");
});
it("should return zero counts when no completed batches exist", () => {
const result = deleteCompletedBatches();
assert.strictEqual(result.deletedBatches, 0);
assert.strictEqual(result.deletedFiles, 0);
});
it("should handle shared file IDs across multiple completed batches", () => {
const sharedFile = createFile({
bytes: 10,
filename: "shared-input.jsonl",
purpose: "batch",
content: Buffer.from("shared"),
});
const batchA = createBatch({
endpoint: "/v1/chat/completions",
completionWindow: "24h",
inputFileId: sharedFile.id,
status: "completed",
});
const batchB = createBatch({
endpoint: "/v1/chat/completions",
completionWindow: "24h",
inputFileId: sharedFile.id,
status: "completed",
});
assert.ok(getBatch(batchA.id));
assert.ok(getBatch(batchB.id));
assert.ok(getFile(sharedFile.id));
const result = deleteCompletedBatches();
assert.ok(result.deletedBatches >= 2);
assert.ok(result.deletedFiles >= 1, "shared file should be counted once");
assert.strictEqual(getBatch(batchA.id), null);
assert.strictEqual(getBatch(batchB.id), null);
assert.strictEqual(getFile(sharedFile.id), null);
});
});