mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-05-23 04:28:06 +00:00
Some checks are pending
Build Fork Image (ghcr.io) / Build and Push to ghcr.io (push) Waiting to run
CI / Security Tests (push) Blocked by required conditions
CI / Lint (push) Waiting to run
CI / Build language matrix (push) Waiting to run
CI / i18n Validation (push) Blocked by required conditions
CI / PR Test Policy (push) Waiting to run
CI / Build (push) Waiting to run
CI / Package Artifact (push) Blocked by required conditions
CI / Electron Package Smoke (push) Blocked by required conditions
CI / Unit Tests (1/2) (push) Blocked by required conditions
CI / Unit Tests (2/2) (push) Blocked by required conditions
CI / Node 24 Compatibility (1/2) (push) Blocked by required conditions
CI / Node 24 Compatibility (2/2) (push) Blocked by required conditions
CI / Node 26 Compatibility (1/2) (push) Blocked by required conditions
CI / Node 26 Compatibility (2/2) (push) Blocked by required conditions
CI / Coverage (push) Blocked by required conditions
CI / SonarQube (push) Blocked by required conditions
CI / PR Coverage Comment (push) Blocked by required conditions
CI / E2E Tests (1/6) (push) Blocked by required conditions
CI / E2E Tests (2/6) (push) Blocked by required conditions
CI / E2E Tests (3/6) (push) Blocked by required conditions
CI / E2E Tests (4/6) (push) Blocked by required conditions
CI / E2E Tests (5/6) (push) Blocked by required conditions
CI / E2E Tests (6/6) (push) Blocked by required conditions
CI / Integration Tests (1/2) (push) Blocked by required conditions
CI / Integration Tests (2/2) (push) Blocked by required conditions
CI / CI Dashboard (push) Blocked by required conditions
Publish to Docker Hub / Build and Push Docker (multi-arch) (push) Waiting to run
Integrated into release/v3.8.0
1224 lines
38 KiB
TypeScript
1224 lines
38 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-batch-api-"));
|
|
process.env.DATA_DIR = TEST_DATA_DIR;
|
|
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-secret-123";
|
|
|
|
const {
|
|
createFile,
|
|
createBatch,
|
|
getBatch,
|
|
getFileContent,
|
|
updateBatch,
|
|
createProviderConnection,
|
|
createApiKey,
|
|
getFile,
|
|
listFiles,
|
|
formatFileResponse,
|
|
deleteFile,
|
|
getTerminalBatches,
|
|
} = await import("../../src/lib/localDb.ts");
|
|
const { getDbInstance } = await import("../../src/lib/db/core.ts");
|
|
const { initBatchProcessor, stopBatchProcessor, processPendingBatches } =
|
|
await import("../../open-sse/services/batchProcessor.ts");
|
|
const batchesRoute = await import("../../src/app/api/v1/batches/route.ts");
|
|
const batchByIdRoute = await import("../../src/app/api/v1/batches/[id]/route.ts");
|
|
const batchCancelRoute = await import("../../src/app/api/v1/batches/[id]/cancel/route.ts");
|
|
const filesRoute = await import("../../src/app/api/v1/files/route.ts");
|
|
const fileByIdRoute = await import("../../src/app/api/v1/files/[id]/route.ts");
|
|
const fileContentRoute = await import("../../src/app/api/v1/files/[id]/content/route.ts");
|
|
|
|
test("Batch API and Processing", async () => {
|
|
// 0. Setup environment, mock provider and API key
|
|
process.env.API_KEY_SECRET = "test-secret-123";
|
|
|
|
await createProviderConnection({
|
|
provider: "openai",
|
|
authType: "apikey",
|
|
name: "Mock OpenAI",
|
|
apiKey: "sk-mock-key",
|
|
isActive: true,
|
|
});
|
|
|
|
const apiKey = await createApiKey("Test Key", "test-machine");
|
|
|
|
// 1. Create a file with batch items
|
|
const batchItems = [
|
|
JSON.stringify({
|
|
custom_id: "request-1",
|
|
method: "POST",
|
|
url: "/v1/chat/completions",
|
|
body: { model: "gpt-4o-mini", messages: [{ role: "user", content: "Hello world" }] },
|
|
}),
|
|
JSON.stringify({
|
|
custom_id: "request-2",
|
|
method: "POST",
|
|
url: "/v1/chat/completions",
|
|
body: { model: "gpt-4o-mini", messages: [{ role: "user", content: "Goodbye world" }] },
|
|
}),
|
|
].join("\n");
|
|
|
|
const file = createFile({
|
|
bytes: Buffer.byteLength(batchItems),
|
|
filename: "test_batch.jsonl",
|
|
purpose: "batch",
|
|
content: Buffer.from(batchItems),
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
|
|
assert.ok(file.id.startsWith("file-"), "File ID should start with file-");
|
|
|
|
// 2. Create a batch
|
|
const batch = createBatch({
|
|
endpoint: "/v1/chat/completions",
|
|
completionWindow: "24h",
|
|
inputFileId: file.id,
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
|
|
assert.ok(batch.id.startsWith("batch_"), "Batch ID should start with batch_");
|
|
assert.strictEqual(batch.status, "validating");
|
|
|
|
// 3. Start the processor manually for one tick (or wait if we used initBatchProcessor)
|
|
// For testing, we might want to expose the processing functions or just wait.
|
|
// We'll use a shorter interval in the processor if we want to test polling.
|
|
// Here we'll just call processPendingBatches if it was exported, but it's not.
|
|
|
|
// Instead of polling, let's just wait a bit if we started the processor
|
|
initBatchProcessor();
|
|
|
|
console.log("Waiting for batch processing...");
|
|
|
|
// Poll for status change
|
|
let maxAttempts = 30;
|
|
let currentBatch = getBatch(batch.id);
|
|
while (
|
|
maxAttempts > 0 &&
|
|
currentBatch?.status !== "completed" &&
|
|
currentBatch?.status !== "failed" &&
|
|
currentBatch?.status !== "cancelled"
|
|
) {
|
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
currentBatch = getBatch(batch.id);
|
|
const progress = currentBatch?.requestCountsTotal
|
|
? `${currentBatch.requestCountsCompleted}/${currentBatch.requestCountsTotal}`
|
|
: "not started";
|
|
console.log(
|
|
`[TEST] Current status: ${currentBatch?.status}, completed: ${progress}, failed: ${currentBatch?.requestCountsFailed || 0}`
|
|
);
|
|
maxAttempts--;
|
|
}
|
|
|
|
// Stop the processor so the test can exit
|
|
stopBatchProcessor();
|
|
|
|
if (maxAttempts === 0) {
|
|
console.error(
|
|
"[TEST] Polling timed out. Final batch state:",
|
|
JSON.stringify(currentBatch, null, 2)
|
|
);
|
|
}
|
|
|
|
assert.ok(
|
|
currentBatch?.status === "completed" || currentBatch?.status === "failed",
|
|
"Batch should reach a terminal state"
|
|
);
|
|
|
|
// In test environment, the mock key might fail, which is fine for this test as long as it finishes
|
|
if (currentBatch?.status === "failed" || currentBatch?.requestCountsFailed > 0) {
|
|
console.warn(
|
|
"[TEST] Batch finished with failures (likely due to mock credentials). This is acceptable for this test."
|
|
);
|
|
assert.strictEqual(currentBatch?.requestCountsTotal, 2, "Total requests should be 2");
|
|
assert.strictEqual(
|
|
(currentBatch?.requestCountsCompleted || 0) + (currentBatch?.requestCountsFailed || 0),
|
|
2,
|
|
"Total processed should be 2"
|
|
);
|
|
return;
|
|
}
|
|
|
|
assert.strictEqual(currentBatch?.status, "completed", "Batch should be completed");
|
|
assert.strictEqual(currentBatch?.requestCountsTotal, 2);
|
|
assert.strictEqual(currentBatch?.requestCountsCompleted, 2);
|
|
assert.ok(currentBatch?.outputFileId, "Should have output file ID");
|
|
|
|
const inputFileAfter = getFile(file.id);
|
|
assert.ok(inputFileAfter, "Input file should exist");
|
|
|
|
const outputFile = getFile(currentBatch.outputFileId!);
|
|
assert.ok(outputFile, "Output file should exist");
|
|
|
|
// 4. Check output file content
|
|
if (currentBatch?.outputFileId) {
|
|
const outputContent = getFileContent(currentBatch.outputFileId);
|
|
assert.ok(outputContent, "Output file content should exist");
|
|
const lines = outputContent
|
|
.toString()
|
|
.split("\n")
|
|
.filter((l) => l.trim());
|
|
assert.strictEqual(lines.length, 2, "Should have 2 result lines");
|
|
const firstResult = JSON.parse(lines[0]);
|
|
assert.ok(firstResult.custom_id, "Result should have custom_id");
|
|
assert.ok(firstResult.response, "Result should have response");
|
|
}
|
|
|
|
// 5. Check additional spec-compliant fields
|
|
assert.ok(currentBatch.usage, "Batch should have usage populated");
|
|
assert.strictEqual(
|
|
typeof currentBatch.usage.total_tokens,
|
|
"number",
|
|
"usage.total_tokens should be a number"
|
|
);
|
|
assert.ok(
|
|
currentBatch.model || currentBatch.requestCountsFailed > 0,
|
|
"Batch should have model populated if at least one request succeeded"
|
|
);
|
|
});
|
|
|
|
test("Batch handles and counts failures correctly", async () => {
|
|
initBatchProcessor();
|
|
try {
|
|
// 1. Create a file with a request that will fail (invalid provider/model)
|
|
const batchItems = [
|
|
JSON.stringify({
|
|
custom_id: "fail-request",
|
|
method: "POST",
|
|
url: "/v1/chat/completions",
|
|
body: {
|
|
model: "non-existent-provider/model",
|
|
messages: [{ role: "user", content: "Fail me" }],
|
|
},
|
|
}),
|
|
].join("\n");
|
|
|
|
const file = createFile({
|
|
bytes: Buffer.byteLength(batchItems),
|
|
filename: "fail_batch.jsonl",
|
|
purpose: "batch",
|
|
content: Buffer.from(batchItems),
|
|
apiKeyId: null,
|
|
});
|
|
|
|
// 2. Create a batch
|
|
const batch = createBatch({
|
|
endpoint: "/v1/chat/completions",
|
|
completionWindow: "24h",
|
|
inputFileId: file.id,
|
|
apiKeyId: null,
|
|
});
|
|
|
|
// 3. Poll for completion
|
|
let maxAttempts = 20;
|
|
let currentBatch = getBatch(batch.id);
|
|
while (
|
|
maxAttempts > 0 &&
|
|
currentBatch?.status !== "completed" &&
|
|
currentBatch?.status !== "failed"
|
|
) {
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
currentBatch = getBatch(batch.id);
|
|
maxAttempts--;
|
|
}
|
|
|
|
// 4. Verify failure counts
|
|
assert.strictEqual(currentBatch?.requestCountsTotal, 1, "Total should be 1");
|
|
assert.strictEqual(currentBatch?.requestCountsCompleted, 0, "Completed should be 0");
|
|
assert.strictEqual(currentBatch?.requestCountsFailed, 1, "Failed should be 1");
|
|
assert.ok(currentBatch?.errorFileId, "Should have error file for failures");
|
|
assert.ok(!currentBatch?.outputFileId, "Should NOT have output file if no successes");
|
|
|
|
if (currentBatch?.errorFileId) {
|
|
const errorContent = getFileContent(currentBatch.errorFileId);
|
|
const result = JSON.parse(errorContent.toString());
|
|
assert.ok(
|
|
result.response.status_code >= 400,
|
|
`Status code ${result.response.status_code} should be >= 400`
|
|
);
|
|
assert.ok(result.response.body.error, "Should contain error in body");
|
|
}
|
|
} finally {
|
|
stopBatchProcessor();
|
|
}
|
|
});
|
|
|
|
test("Batch dispatches non-chat endpoints through the matching route handler", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
object: "list",
|
|
data: [{ object: "embedding", embedding: [0.1, 0.2], index: 0 }],
|
|
usage: { prompt_tokens: 2, total_tokens: 2 },
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}
|
|
);
|
|
|
|
initBatchProcessor();
|
|
try {
|
|
await createProviderConnection({
|
|
provider: "openai",
|
|
authType: "apikey",
|
|
name: "Mock OpenAI Embeddings",
|
|
apiKey: "sk-mock-embeddings-key",
|
|
isActive: true,
|
|
});
|
|
|
|
const batchItems = [
|
|
JSON.stringify({
|
|
custom_id: "embed-request",
|
|
method: "POST",
|
|
url: "/v1/embeddings",
|
|
body: {
|
|
model: "openai/text-embedding-3-small",
|
|
input: "Hello embeddings",
|
|
},
|
|
}),
|
|
].join("\n");
|
|
|
|
const file = createFile({
|
|
bytes: Buffer.byteLength(batchItems),
|
|
filename: "embeddings_batch.jsonl",
|
|
purpose: "batch",
|
|
content: Buffer.from(batchItems),
|
|
apiKeyId: null,
|
|
});
|
|
|
|
const batch = createBatch({
|
|
endpoint: "/v1/embeddings",
|
|
completionWindow: "24h",
|
|
inputFileId: file.id,
|
|
apiKeyId: null,
|
|
});
|
|
|
|
let maxAttempts = 20;
|
|
let currentBatch = getBatch(batch.id);
|
|
while (
|
|
maxAttempts > 0 &&
|
|
currentBatch?.status !== "completed" &&
|
|
currentBatch?.status !== "failed"
|
|
) {
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
currentBatch = getBatch(batch.id);
|
|
maxAttempts--;
|
|
}
|
|
|
|
assert.strictEqual(currentBatch?.status, "completed", "embedding batch should complete");
|
|
assert.strictEqual(currentBatch?.requestCountsCompleted, 1);
|
|
assert.ok(currentBatch?.outputFileId, "embedding batch should produce an output file");
|
|
|
|
const outputContent = getFileContent(currentBatch.outputFileId!);
|
|
const result = JSON.parse(outputContent.toString());
|
|
assert.strictEqual(result.response.status_code, 200);
|
|
assert.ok(Array.isArray(result.response.body.data));
|
|
assert.strictEqual(result.response.body.object, "list");
|
|
} finally {
|
|
stopBatchProcessor();
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test("Batch rejects input lines whose url does not match the batch endpoint", async () => {
|
|
initBatchProcessor();
|
|
try {
|
|
const batchItems = [
|
|
JSON.stringify({
|
|
custom_id: "wrong-endpoint",
|
|
method: "POST",
|
|
url: "/v1/embeddings",
|
|
body: {
|
|
model: "openai/text-embedding-3-small",
|
|
input: "Wrong endpoint",
|
|
},
|
|
}),
|
|
].join("\n");
|
|
|
|
const file = createFile({
|
|
bytes: Buffer.byteLength(batchItems),
|
|
filename: "wrong_endpoint_batch.jsonl",
|
|
purpose: "batch",
|
|
content: Buffer.from(batchItems),
|
|
apiKeyId: null,
|
|
});
|
|
|
|
const batch = createBatch({
|
|
endpoint: "/v1/chat/completions",
|
|
completionWindow: "24h",
|
|
inputFileId: file.id,
|
|
apiKeyId: null,
|
|
});
|
|
|
|
await processPendingBatches();
|
|
|
|
const currentBatch = getBatch(batch.id);
|
|
assert.strictEqual(currentBatch?.status, "failed");
|
|
assert.match(
|
|
String(currentBatch?.errors?.[0]?.message || ""),
|
|
/does not match batch endpoint/i
|
|
);
|
|
} finally {
|
|
stopBatchProcessor();
|
|
}
|
|
});
|
|
|
|
test("Batch forces stream: false for all requests", async () => {
|
|
initBatchProcessor();
|
|
try {
|
|
const batchItems = [
|
|
JSON.stringify({
|
|
custom_id: "stream-request",
|
|
method: "POST",
|
|
url: "/v1/chat/completions",
|
|
body: {
|
|
model: "gpt-4o-mini",
|
|
messages: [{ role: "user", content: "Hello" }],
|
|
stream: true,
|
|
},
|
|
}),
|
|
].join("\n");
|
|
|
|
const file = createFile({
|
|
bytes: Buffer.byteLength(batchItems),
|
|
filename: "stream_force_batch.jsonl",
|
|
purpose: "batch",
|
|
content: Buffer.from(batchItems),
|
|
apiKeyId: null,
|
|
});
|
|
|
|
const batch = createBatch({
|
|
endpoint: "/v1/chat/completions",
|
|
completionWindow: "24h",
|
|
inputFileId: file.id,
|
|
apiKeyId: null,
|
|
});
|
|
|
|
let maxAttempts = 20;
|
|
let currentBatch = getBatch(batch.id);
|
|
while (
|
|
maxAttempts > 0 &&
|
|
currentBatch?.status !== "completed" &&
|
|
currentBatch?.status !== "failed"
|
|
) {
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
currentBatch = getBatch(batch.id);
|
|
maxAttempts--;
|
|
}
|
|
|
|
assert.strictEqual(currentBatch?.status, "completed", "Batch should be completed");
|
|
const outputFileId = currentBatch?.outputFileId || currentBatch?.errorFileId;
|
|
assert.ok(outputFileId, "Should have output or error file ID");
|
|
const outputContent = getFileContent(outputFileId!);
|
|
const result = JSON.parse(outputContent.toString());
|
|
|
|
// It shouldn't have "Unexpected token d" error which happens if it tries to parse SSE stream as JSON
|
|
assert.ok(
|
|
result.response.status_code !== 200 || result.response.body.choices,
|
|
"Should be a valid chat completion response"
|
|
);
|
|
if (result.response.body.error) {
|
|
assert.ok(
|
|
!result.response.body.error.message.includes("Unexpected token"),
|
|
"Should not have JSON parsing error from SSE stream"
|
|
);
|
|
}
|
|
} finally {
|
|
stopBatchProcessor();
|
|
}
|
|
});
|
|
|
|
test("Batch API response format is spec-compliant", async () => {
|
|
// This test doesn't need the processor to run as it checks the object structure returned by endpoints
|
|
const apiKey = await createApiKey("Spec Test Key", "test-machine");
|
|
|
|
// Create a mock file first to satisfy foreign key constraint
|
|
const file = createFile({
|
|
bytes: 10,
|
|
filename: "mock.jsonl",
|
|
purpose: "batch",
|
|
content: Buffer.from("{}"),
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
|
|
// Create a mock batch directly
|
|
const batch = createBatch({
|
|
endpoint: "/v1/chat/completions",
|
|
completionWindow: "24h",
|
|
inputFileId: file.id,
|
|
apiKeyId: apiKey.id,
|
|
metadata: { test: "meta" },
|
|
});
|
|
|
|
// Mock an update with some counts and usage
|
|
updateBatch(batch.id, {
|
|
status: "completed",
|
|
requestCountsTotal: 10,
|
|
requestCountsCompleted: 8,
|
|
requestCountsFailed: 2,
|
|
model: "gpt-4o-mini",
|
|
usage: {
|
|
input_tokens: 100,
|
|
output_tokens: 50,
|
|
total_tokens: 150,
|
|
input_tokens_details: { cached_tokens: 10 },
|
|
output_tokens_details: { reasoning_tokens: 5 },
|
|
},
|
|
});
|
|
|
|
const updatedBatch = getBatch(batch.id)!;
|
|
|
|
// Test the formatter used in API routes (simulate the route's response)
|
|
function formatBatchResponse(batch: any) {
|
|
return {
|
|
id: batch.id,
|
|
object: "batch",
|
|
endpoint: batch.endpoint,
|
|
errors: batch.errors || null,
|
|
input_file_id: batch.inputFileId,
|
|
completion_window: batch.completionWindow,
|
|
status: batch.status,
|
|
output_file_id: batch.outputFileId || null,
|
|
error_file_id: batch.errorFileId || null,
|
|
created_at: batch.createdAt,
|
|
in_progress_at: batch.inProgressAt || null,
|
|
expires_at: batch.expiresAt || null,
|
|
finalizing_at: batch.finalizingAt || null,
|
|
completed_at: batch.completedAt || null,
|
|
failed_at: batch.failedAt || null,
|
|
expired_at: batch.expiredAt || null,
|
|
cancelling_at: batch.cancellingAt || null,
|
|
cancelled_at: batch.cancelledAt || null,
|
|
request_counts: {
|
|
total: batch.requestCountsTotal || 0,
|
|
completed: batch.requestCountsCompleted || 0,
|
|
failed: batch.requestCountsFailed || 0,
|
|
},
|
|
metadata: batch.metadata || null,
|
|
model: batch.model || null,
|
|
usage: batch.usage || null,
|
|
};
|
|
}
|
|
|
|
const response = formatBatchResponse(updatedBatch);
|
|
|
|
// Verify all required spec fields are present and structured correctly
|
|
assert.strictEqual(response.id, batch.id);
|
|
assert.strictEqual(response.object, "batch");
|
|
assert.strictEqual(response.endpoint, "/v1/chat/completions");
|
|
assert.strictEqual(response.completion_window, "24h");
|
|
assert.strictEqual(response.status, "completed");
|
|
assert.ok(response.request_counts, "Should have request_counts");
|
|
assert.strictEqual(response.request_counts.total, 10);
|
|
assert.strictEqual(response.request_counts.completed, 8);
|
|
assert.strictEqual(response.request_counts.failed, 2);
|
|
assert.ok(response.usage, "Should have usage");
|
|
assert.strictEqual(response.usage.total_tokens, 150);
|
|
assert.strictEqual(response.usage.input_tokens_details.cached_tokens, 10);
|
|
assert.strictEqual(response.usage.output_tokens_details.reasoning_tokens, 5);
|
|
assert.strictEqual(response.model, "gpt-4o-mini");
|
|
assert.deepStrictEqual(response.metadata, { test: "meta" });
|
|
});
|
|
|
|
test("List batches pagination and response format", async () => {
|
|
const apiKey = await createApiKey("List Test Key", "test-machine");
|
|
|
|
// 1. Create multiple batches
|
|
const file = createFile({
|
|
bytes: 10,
|
|
filename: "list_mock.jsonl",
|
|
purpose: "batch",
|
|
content: Buffer.from("{}"),
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
|
|
const batchOrder: Array<{ createdAt: number; id: string }> = [];
|
|
const baseCreatedAt = Math.floor(Date.now() / 1000) - 10_000;
|
|
for (let i = 0; i < 5; i++) {
|
|
const b = createBatch({
|
|
endpoint: "/v1/chat/completions",
|
|
completionWindow: "24h",
|
|
inputFileId: file.id,
|
|
apiKeyId: apiKey.id,
|
|
metadata: { index: i },
|
|
});
|
|
updateBatch(b.id, { createdAt: baseCreatedAt + i });
|
|
batchOrder.push({ createdAt: baseCreatedAt + i, id: b.id });
|
|
}
|
|
|
|
batchOrder.sort((a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id));
|
|
const batchIds = batchOrder.map((entry) => entry.id);
|
|
|
|
// 2. Test listBatches logic (direct DB call)
|
|
const { listBatches } = await import("../../src/lib/localDb");
|
|
const allBatches = listBatches(apiKey.id, 10);
|
|
assert.strictEqual(allBatches.length, 5);
|
|
assert.strictEqual(allBatches[0].id, batchIds[0]);
|
|
|
|
// 3. Test pagination logic (as implemented in the route)
|
|
const limit = 2;
|
|
const batchesPage1 = listBatches(apiKey.id, limit + 1);
|
|
const hasMore1 = batchesPage1.length > limit;
|
|
const data1 = hasMore1 ? batchesPage1.slice(0, limit) : batchesPage1;
|
|
|
|
assert.strictEqual(data1.length, 2);
|
|
assert.strictEqual(hasMore1, true);
|
|
assert.strictEqual(data1[0].id, batchIds[0]);
|
|
assert.strictEqual(data1[1].id, batchIds[1]);
|
|
|
|
const after = data1[1].id;
|
|
const batchesPage2 = listBatches(apiKey.id, limit + 1, after);
|
|
const hasMore2 = batchesPage2.length > limit;
|
|
const data2 = hasMore2 ? batchesPage2.slice(0, limit) : batchesPage2;
|
|
|
|
assert.strictEqual(data2.length, 2);
|
|
assert.strictEqual(hasMore2, true);
|
|
assert.strictEqual(data2[0].id, batchIds[2]);
|
|
assert.strictEqual(data2[1].id, batchIds[3]);
|
|
|
|
const after2 = data2[1].id;
|
|
const batchesPage3 = listBatches(apiKey.id, limit + 1, after2);
|
|
const hasMore3 = batchesPage3.length > limit;
|
|
const data3 = hasMore3 ? batchesPage3.slice(0, limit) : batchesPage3;
|
|
|
|
assert.strictEqual(data3.length, 1);
|
|
assert.strictEqual(hasMore3, false);
|
|
assert.strictEqual(data3[0].id, batchIds[4]);
|
|
});
|
|
|
|
test("Batch cleanup honors output_expires_after for output artifacts", async () => {
|
|
const apiKey = await createApiKey("Batch Retention Key", "test-machine");
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
const inputFile = createFile({
|
|
bytes: 10,
|
|
filename: "retention_input.jsonl",
|
|
purpose: "batch",
|
|
content: Buffer.from("{}"),
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
const outputFile = createFile({
|
|
bytes: 10,
|
|
filename: "retention_output.jsonl",
|
|
purpose: "batch_output",
|
|
content: Buffer.from("{}"),
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
const errorFile = createFile({
|
|
bytes: 10,
|
|
filename: "retention_error.jsonl",
|
|
purpose: "batch_output",
|
|
content: Buffer.from("{}"),
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
|
|
const batch = createBatch({
|
|
endpoint: "/v1/chat/completions",
|
|
completionWindow: "24h",
|
|
inputFileId: inputFile.id,
|
|
apiKeyId: apiKey.id,
|
|
outputExpiresAfterSeconds: 3600,
|
|
outputExpiresAfterAnchor: "created_at",
|
|
});
|
|
|
|
updateBatch(batch.id, {
|
|
status: "completed",
|
|
createdAt: now - 3700,
|
|
completedAt: now - 30,
|
|
outputFileId: outputFile.id,
|
|
errorFileId: errorFile.id,
|
|
});
|
|
|
|
await processPendingBatches();
|
|
|
|
assert.ok(getFile(inputFile.id), "input file should still follow completion_window retention");
|
|
assert.equal(getFile(outputFile.id), null);
|
|
assert.equal(getFile(errorFile.id), null);
|
|
});
|
|
|
|
test("Batch processor fails orphaned finalizing batches during startup recovery", async () => {
|
|
const apiKey = await createApiKey("Finalizing Recovery Key", "test-machine");
|
|
const inputFile = createFile({
|
|
bytes: 2,
|
|
filename: "finalizing.jsonl",
|
|
purpose: "batch",
|
|
content: Buffer.from("{}"),
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
|
|
const batch = createBatch({
|
|
endpoint: "/v1/chat/completions",
|
|
completionWindow: "24h",
|
|
inputFileId: inputFile.id,
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
|
|
updateBatch(batch.id, {
|
|
status: "finalizing",
|
|
finalizingAt: Math.floor(Date.now() / 1000),
|
|
});
|
|
|
|
initBatchProcessor();
|
|
|
|
try {
|
|
const recoveredBatch = getBatch(batch.id);
|
|
assert.strictEqual(recoveredBatch?.status, "failed");
|
|
assert.match(
|
|
String(recoveredBatch?.errors?.[0]?.message || ""),
|
|
/interrupted during finalization/i
|
|
);
|
|
} finally {
|
|
stopBatchProcessor();
|
|
}
|
|
});
|
|
|
|
test("Files upload route stores multipart content", async () => {
|
|
const fileContent = '{"custom_id":"req-1"}\n';
|
|
const formData = new FormData();
|
|
formData.set("purpose", "batch");
|
|
formData.set(
|
|
"file",
|
|
new File([Buffer.from(fileContent)], "upload.jsonl", { type: "application/json" })
|
|
);
|
|
|
|
const response = await filesRoute.POST(
|
|
new Request("http://localhost/api/v1/files", {
|
|
method: "POST",
|
|
body: formData,
|
|
})
|
|
);
|
|
const json = await response.json();
|
|
|
|
assert.strictEqual(response.status, 200);
|
|
assert.ok(json.id);
|
|
assert.strictEqual(getFileContent(json.id)?.toString(), fileContent);
|
|
});
|
|
|
|
test("Files and batches routes expose explicit CORS preflight handlers", async () => {
|
|
const routes = [
|
|
batchesRoute,
|
|
batchByIdRoute,
|
|
batchCancelRoute,
|
|
filesRoute,
|
|
fileByIdRoute,
|
|
fileContentRoute,
|
|
];
|
|
|
|
for (const route of routes) {
|
|
assert.strictEqual(typeof route.OPTIONS, "function");
|
|
const response = await route.OPTIONS();
|
|
assert.strictEqual(response.status, 204);
|
|
assert.strictEqual(response.headers.get("Access-Control-Allow-Origin"), null);
|
|
assert.match(
|
|
String(response.headers.get("Access-Control-Allow-Headers") || ""),
|
|
/Authorization/i
|
|
);
|
|
}
|
|
});
|
|
|
|
test("Batch Cancel API", async () => {
|
|
const apiKey = await createApiKey("Cancel Test Key", "test-machine");
|
|
|
|
const file = createFile({
|
|
bytes: 10,
|
|
filename: "cancel_mock.jsonl",
|
|
purpose: "batch",
|
|
content: Buffer.from("{}"),
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
|
|
const batch = createBatch({
|
|
endpoint: "/v1/chat/completions",
|
|
completionWindow: "24h",
|
|
inputFileId: file.id,
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
|
|
// 1. Initially validating
|
|
assert.strictEqual(batch.status, "validating");
|
|
|
|
// 2. Cancel it
|
|
const cancellingAt = Math.floor(Date.now() / 1000);
|
|
updateBatch(batch.id, {
|
|
status: "cancelling",
|
|
cancellingAt,
|
|
});
|
|
|
|
const updatedBatch = getBatch(batch.id)!;
|
|
assert.strictEqual(updatedBatch.status, "cancelling");
|
|
assert.strictEqual(updatedBatch.cancellingAt, cancellingAt);
|
|
|
|
// 3. Test that it can't be cancelled if already terminal
|
|
updateBatch(batch.id, { status: "completed" });
|
|
const terminalBatch = getBatch(batch.id)!;
|
|
assert.strictEqual(terminalBatch.status, "completed");
|
|
|
|
// In actual API this would return 400, here we just verify state logic
|
|
const canCancel = !["completed", "failed", "cancelled", "expired"].includes(terminalBatch.status);
|
|
assert.strictEqual(canCancel, false);
|
|
});
|
|
|
|
test("Batch processor keeps cancelled status for in-flight batches", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
const apiKey = await createApiKey("In Flight Cancel Key", "test-machine");
|
|
|
|
await createProviderConnection({
|
|
provider: "openai",
|
|
authType: "apikey",
|
|
name: "Cancelable OpenAI",
|
|
apiKey: "sk-cancel-batch",
|
|
isActive: true,
|
|
});
|
|
|
|
const batchItems = [
|
|
JSON.stringify({
|
|
custom_id: "cancel-mid-flight",
|
|
method: "POST",
|
|
url: "/v1/chat/completions",
|
|
body: {
|
|
model: "openai/gpt-4o-mini",
|
|
messages: [{ role: "user", content: "cancel me" }],
|
|
},
|
|
}),
|
|
].join("\n");
|
|
|
|
const file = createFile({
|
|
bytes: Buffer.byteLength(batchItems),
|
|
filename: "cancel_mid_flight.jsonl",
|
|
purpose: "batch",
|
|
content: Buffer.from(batchItems),
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
|
|
const batch = createBatch({
|
|
endpoint: "/v1/chat/completions",
|
|
completionWindow: "24h",
|
|
inputFileId: file.id,
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
|
|
globalThis.fetch = async () => {
|
|
updateBatch(batch.id, {
|
|
status: "cancelled",
|
|
cancelledAt: Math.floor(Date.now() / 1000),
|
|
});
|
|
|
|
return Response.json({
|
|
id: "chatcmpl-batch-cancelled",
|
|
object: "chat.completion",
|
|
model: "gpt-4o-mini",
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: { role: "assistant", content: "ok" },
|
|
finish_reason: "stop",
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 1,
|
|
completion_tokens: 1,
|
|
total_tokens: 2,
|
|
completion_tokens_details: { reasoning_tokens: 0 },
|
|
},
|
|
});
|
|
};
|
|
|
|
try {
|
|
await processPendingBatches();
|
|
|
|
let currentBatch = getBatch(batch.id);
|
|
let remainingAttempts = 40;
|
|
while (
|
|
remainingAttempts > 0 &&
|
|
currentBatch &&
|
|
!["cancelled", "completed", "failed", "expired"].includes(currentBatch.status)
|
|
) {
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
currentBatch = getBatch(batch.id);
|
|
remainingAttempts--;
|
|
}
|
|
|
|
assert.strictEqual(currentBatch?.status, "cancelled");
|
|
assert.ok(!currentBatch?.outputFileId, "Cancelled batch must not emit an output file");
|
|
assert.ok(!currentBatch?.errorFileId, "Cancelled batch must not emit an error file");
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test("List files pagination and response format", async () => {
|
|
const apiKey = await createApiKey("File List Test Key", "test-machine");
|
|
|
|
// 1. Create multiple files
|
|
const fileIds: string[] = [];
|
|
for (let i = 0; i < 5; i++) {
|
|
const f = createFile({
|
|
bytes: 10 + i,
|
|
filename: `file_${i}.jsonl`,
|
|
purpose: i % 2 === 0 ? "batch" : "fine-tune",
|
|
content: Buffer.from("{}"),
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
fileIds.push(f.id);
|
|
}
|
|
|
|
// Default order is DESC (by created_at, then ID)
|
|
const allFilesSorted = listFiles({ apiKeyId: apiKey.id, order: "desc" });
|
|
const sortedFileIds = allFilesSorted.map((f) => f.id);
|
|
|
|
// 2. Test listFiles options
|
|
assert.strictEqual(allFilesSorted.length, 5);
|
|
assert.strictEqual(allFilesSorted[0].id, sortedFileIds[0]);
|
|
|
|
// 3. Test filtering by purpose
|
|
const batchFiles = listFiles({ apiKeyId: apiKey.id, purpose: "batch" });
|
|
assert.strictEqual(batchFiles.length, 3); // 0, 2, 4
|
|
assert.ok(batchFiles.every((f) => f.purpose === "batch"));
|
|
|
|
// 4. Test pagination
|
|
const limit = 2;
|
|
const page1 = listFiles({ apiKeyId: apiKey.id, limit });
|
|
assert.strictEqual(page1.length, 2);
|
|
assert.strictEqual(page1[0].id, sortedFileIds[0]);
|
|
assert.strictEqual(page1[1].id, sortedFileIds[1]);
|
|
|
|
const after = page1[1].id;
|
|
const page2 = listFiles({ apiKeyId: apiKey.id, limit, after });
|
|
assert.strictEqual(page2.length, 2);
|
|
assert.strictEqual(page2[0].id, sortedFileIds[2]);
|
|
assert.strictEqual(page2[1].id, sortedFileIds[3]);
|
|
|
|
const after2 = page2[1].id;
|
|
const page3 = listFiles({ apiKeyId: apiKey.id, limit, after: after2 });
|
|
assert.strictEqual(page3.length, 1);
|
|
assert.strictEqual(page3[0].id, sortedFileIds[4]);
|
|
|
|
// 5. Test sorting
|
|
const ascFiles = listFiles({ apiKeyId: apiKey.id, order: "asc" });
|
|
assert.strictEqual(ascFiles.length, 5);
|
|
assert.strictEqual(ascFiles[0].id, [...sortedFileIds].reverse()[0]);
|
|
});
|
|
|
|
test("File upload with expiration and spec-compliant response", async () => {
|
|
const apiKey = await createApiKey("File Upload Test Key", "test-machine");
|
|
|
|
// Simulate File object (Next.js File)
|
|
const content = Buffer.from("test content");
|
|
const mockFile = {
|
|
size: content.length,
|
|
name: "test.txt",
|
|
type: "text/plain",
|
|
};
|
|
|
|
// We'll test the DB logic and the formatting logic separately
|
|
// as it's hard to call the Next.js route directly in this unit test.
|
|
|
|
const expiresAfterSeconds = 3600;
|
|
const expiresAt = Math.floor(Date.now() / 1000) + expiresAfterSeconds;
|
|
|
|
const record = createFile({
|
|
bytes: mockFile.size,
|
|
filename: mockFile.name,
|
|
purpose: "batch",
|
|
content: content,
|
|
mimeType: mockFile.type,
|
|
apiKeyId: apiKey.id,
|
|
expiresAt: expiresAt,
|
|
});
|
|
|
|
assert.strictEqual(record.expiresAt, expiresAt);
|
|
|
|
const response = formatFileResponse(record);
|
|
assert.strictEqual(response.id, record.id);
|
|
assert.strictEqual(response.object, "file");
|
|
assert.strictEqual(response.expires_at, expiresAt);
|
|
assert.strictEqual(
|
|
"status" in response,
|
|
false,
|
|
`Response should not contain status (marker: ${new Error().stack})`
|
|
);
|
|
assert.ok(!("content" in response), "Response should not contain content");
|
|
assert.ok(!("apiKeyId" in response), "Response should not contain apiKeyId");
|
|
});
|
|
|
|
test("Retrieve file spec compliance", async () => {
|
|
const apiKey = await createApiKey("File Retrieve Test Key", "test-machine");
|
|
|
|
const record = createFile({
|
|
bytes: 123,
|
|
filename: "retrieve_test.jsonl",
|
|
purpose: "batch",
|
|
content: Buffer.from("{}"),
|
|
apiKeyId: apiKey.id,
|
|
expiresAt: null,
|
|
});
|
|
|
|
const response = formatFileResponse(record);
|
|
|
|
// Check all required fields from the spec
|
|
assert.strictEqual(response.id, record.id);
|
|
assert.strictEqual(response.bytes, 123);
|
|
assert.strictEqual(typeof response.created_at, "number");
|
|
assert.strictEqual(response.filename, "retrieve_test.jsonl");
|
|
assert.strictEqual(response.object, "file");
|
|
assert.strictEqual(response.purpose, "batch");
|
|
assert.strictEqual(response.expires_at, null);
|
|
|
|
// Ensure no internal fields leak
|
|
assert.ok(!("content" in (response as any)));
|
|
assert.ok(!("apiKeyId" in (response as any)));
|
|
assert.ok(!("mimeType" in (response as any)));
|
|
});
|
|
|
|
test("File deletion", async () => {
|
|
const apiKey = await createApiKey("File Delete Test Key", "test-machine");
|
|
|
|
const record = createFile({
|
|
bytes: 123,
|
|
filename: "delete_test.jsonl",
|
|
purpose: "batch",
|
|
content: Buffer.from("{}"),
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
|
|
const fileBefore = getFile(record.id);
|
|
assert.ok(fileBefore !== null);
|
|
assert.strictEqual(fileBefore.id, record.id);
|
|
|
|
const deleted = deleteFile(record.id);
|
|
assert.ok(deleted);
|
|
|
|
const fileAfter = getFile(record.id);
|
|
assert.strictEqual(fileAfter, null);
|
|
|
|
// Verify deletion of content for security
|
|
const db = getDbInstance();
|
|
const row = db
|
|
.prepare("SELECT content, deleted_at FROM files WHERE id = ?")
|
|
.get(record.id) as any;
|
|
assert.ok(row !== undefined);
|
|
assert.strictEqual(row.content, null);
|
|
assert.ok(row.deleted_at !== null);
|
|
});
|
|
|
|
test("Retrieve file content spec compliance", async () => {
|
|
const apiKey = await createApiKey("File Content Test Key", "test-machine");
|
|
const content = Buffer.from(
|
|
'{"id":"req_1","custom_id":"request-1","response":{"status_code":200,"body":{"choices":[{"message":{"content":"Hello"}}]}}}'
|
|
);
|
|
|
|
const record = createFile({
|
|
bytes: content.length,
|
|
filename: "content_test.jsonl",
|
|
purpose: "batch",
|
|
content: content,
|
|
mimeType: "application/jsonl",
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
|
|
const retrievedContent = getFileContent(record.id);
|
|
assert.ok(retrievedContent !== null);
|
|
assert.deepStrictEqual(retrievedContent, content);
|
|
|
|
// Verify ownership check logic (similar to route.ts)
|
|
const file = getFile(record.id);
|
|
assert.ok(file !== null);
|
|
assert.strictEqual(file.apiKeyId, apiKey.id);
|
|
|
|
// Verify cross-key access failure
|
|
const otherApiKey = await createApiKey("Other Key", "other-machine");
|
|
assert.ok(file.apiKeyId !== otherApiKey.id);
|
|
|
|
// In the route, this would return 404
|
|
const unauthorized = file.apiKeyId !== null && file.apiKeyId !== otherApiKey.id;
|
|
assert.ok(unauthorized);
|
|
});
|
|
|
|
test("File metadata helpers do not load content blobs", async () => {
|
|
const apiKey = await createApiKey("File Metadata Test Key", "test-machine");
|
|
const content = Buffer.from("large-content-placeholder");
|
|
|
|
const record = createFile({
|
|
bytes: content.length,
|
|
filename: "metadata_only.jsonl",
|
|
purpose: "batch",
|
|
content,
|
|
mimeType: "application/jsonl",
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
|
|
const file = getFile(record.id);
|
|
const files = listFiles({ apiKeyId: apiKey.id });
|
|
const listedFile = files.find((candidate) => candidate.id === record.id);
|
|
|
|
assert.ok(file !== null);
|
|
assert.ok(listedFile);
|
|
assert.equal("content" in file, false);
|
|
assert.equal("content" in listedFile, false);
|
|
assert.deepEqual(getFileContent(record.id), content);
|
|
});
|
|
|
|
test("Batch dispatches to embeddings handler for /v1/embeddings URL", async () => {
|
|
initBatchProcessor();
|
|
try {
|
|
const batchItems = [
|
|
JSON.stringify({
|
|
custom_id: "embed-request-1",
|
|
method: "POST",
|
|
url: "/v1/embeddings",
|
|
body: { model: "mistral/mistral-embed", input: "The food was delicious." },
|
|
}),
|
|
].join("\n");
|
|
|
|
const file = createFile({
|
|
bytes: Buffer.byteLength(batchItems),
|
|
filename: "embed_batch.jsonl",
|
|
purpose: "batch",
|
|
content: Buffer.from(batchItems),
|
|
apiKeyId: null,
|
|
});
|
|
|
|
const batch = createBatch({
|
|
endpoint: "/v1/embeddings",
|
|
completionWindow: "24h",
|
|
inputFileId: file.id,
|
|
apiKeyId: null,
|
|
});
|
|
|
|
let maxAttempts = 20;
|
|
let currentBatch = getBatch(batch.id);
|
|
while (
|
|
maxAttempts > 0 &&
|
|
currentBatch?.status !== "completed" &&
|
|
currentBatch?.status !== "failed"
|
|
) {
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
currentBatch = getBatch(batch.id);
|
|
maxAttempts--;
|
|
}
|
|
|
|
assert.ok(
|
|
currentBatch?.status === "completed" || currentBatch?.status === "failed",
|
|
"Batch should reach a terminal state"
|
|
);
|
|
assert.strictEqual(currentBatch?.requestCountsTotal, 1);
|
|
|
|
// Verify the batch item was dispatched to the embeddings handler, not the chat handler.
|
|
// The chat handler would return errors about missing "messages", "Missing model", etc.
|
|
// The embeddings handler returns errors about missing credentials or invalid embedding models.
|
|
const outputFileId = currentBatch?.outputFileId || currentBatch?.errorFileId;
|
|
assert.ok(outputFileId, "Should have an output or error file");
|
|
const outputContent = getFileContent(outputFileId!);
|
|
assert.ok(outputContent, "Output file should have content");
|
|
const result = JSON.parse(outputContent.toString());
|
|
const errorMsg = result.response?.body?.error?.message || "";
|
|
assert.ok(
|
|
!errorMsg.includes("messages") && !errorMsg.includes("Missing model"),
|
|
`Error should not be a chat-specific error. Got: ${errorMsg}`
|
|
);
|
|
} finally {
|
|
stopBatchProcessor();
|
|
}
|
|
});
|
|
|
|
test("getTerminalBatches returns only terminal statuses ordered oldest first", async () => {
|
|
const apiKey = await createApiKey("Terminal Batches Test Key", "test-machine");
|
|
|
|
const file = createFile({
|
|
bytes: 10,
|
|
filename: "terminal_mock.jsonl",
|
|
purpose: "batch",
|
|
content: Buffer.from("{}"),
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
|
|
// Create batches in different terminal and non-terminal states
|
|
const completedBatch = createBatch({
|
|
endpoint: "/v1/chat/completions",
|
|
completionWindow: "24h",
|
|
inputFileId: file.id,
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
updateBatch(completedBatch.id, {
|
|
status: "completed",
|
|
completedAt: Math.floor(Date.now() / 1000),
|
|
});
|
|
|
|
const failedBatch = createBatch({
|
|
endpoint: "/v1/chat/completions",
|
|
completionWindow: "24h",
|
|
inputFileId: file.id,
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
updateBatch(failedBatch.id, { status: "failed", failedAt: Math.floor(Date.now() / 1000) });
|
|
|
|
const cancelledBatch = createBatch({
|
|
endpoint: "/v1/chat/completions",
|
|
completionWindow: "24h",
|
|
inputFileId: file.id,
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
updateBatch(cancelledBatch.id, {
|
|
status: "cancelled",
|
|
cancelledAt: Math.floor(Date.now() / 1000),
|
|
});
|
|
|
|
const expiredBatch = createBatch({
|
|
endpoint: "/v1/chat/completions",
|
|
completionWindow: "24h",
|
|
inputFileId: file.id,
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
updateBatch(expiredBatch.id, { status: "expired", expiredAt: Math.floor(Date.now() / 1000) });
|
|
|
|
// This one should NOT appear in terminal batches
|
|
const pendingBatch = createBatch({
|
|
endpoint: "/v1/chat/completions",
|
|
completionWindow: "24h",
|
|
inputFileId: file.id,
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
|
|
const terminalIds = new Set([
|
|
completedBatch.id,
|
|
failedBatch.id,
|
|
cancelledBatch.id,
|
|
expiredBatch.id,
|
|
]);
|
|
const terminal = getTerminalBatches();
|
|
|
|
// All returned batches must be terminal
|
|
for (const b of terminal) {
|
|
assert.ok(
|
|
["completed", "failed", "cancelled", "expired"].includes(b.status),
|
|
`Unexpected status: ${b.status}`
|
|
);
|
|
}
|
|
|
|
// Our four terminal batches must all be present
|
|
for (const id of terminalIds) {
|
|
assert.ok(
|
|
terminal.some((b) => b.id === id),
|
|
`Missing terminal batch ${id}`
|
|
);
|
|
}
|
|
|
|
// The pending batch must not appear
|
|
assert.ok(
|
|
!terminal.some((b) => b.id === pendingBatch.id),
|
|
"Pending batch should not be in terminal list"
|
|
);
|
|
|
|
// Results must be ordered oldest first (created_at ASC)
|
|
for (let i = 1; i < terminal.length; i++) {
|
|
assert.ok(
|
|
terminal[i].createdAt >= terminal[i - 1].createdAt,
|
|
"Results should be ordered oldest first"
|
|
);
|
|
}
|
|
});
|