diff --git a/app/api/runs/route.ts b/app/api/runs/route.ts index e838cee..6320144 100644 --- a/app/api/runs/route.ts +++ b/app/api/runs/route.ts @@ -25,6 +25,7 @@ export async function GET() { }, ], }, + validation: { results: "must contain at least one result" }, returns: { ok: true, runId: "uuid", regressed: 0, flagged: 0, shouldFail: false }, }); } @@ -52,6 +53,13 @@ export async function POST(req: NextRequest) { ); } + if (body.results.length === 0) { + return NextResponse.json( + { error: "results[] must not be empty" }, + { status: 400 }, + ); + } + const supabase = db(); // Upsert suite by name. diff --git a/package.json b/package.json index 934bb7a..dba8368 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "next dev", "build": "next build", "start": "next start", + "test": "node --test test/*.test.mjs", "lint": "next lint", "cf:build": "NODE_OPTIONS=--no-deprecation opennextjs-cloudflare build", "cf:preview": "NODE_OPTIONS=--no-deprecation opennextjs-cloudflare build && NODE_OPTIONS=--no-deprecation opennextjs-cloudflare preview", diff --git a/test/runs-route.test.mjs b/test/runs-route.test.mjs new file mode 100644 index 0000000..fd9aedd --- /dev/null +++ b/test/runs-route.test.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createServer } from "node:net"; +import test from "node:test"; + +async function availablePort() { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + await new Promise((resolve) => server.close(resolve)); + return port; +} + +async function startServer(t) { + const port = await availablePort(); + const child = spawn( + process.execPath, + [ + "node_modules/next/dist/bin/next", + "dev", + "--hostname", + "127.0.0.1", + "--port", + String(port), + ], + { + env: { + ...process.env, + TRACECASE_INGEST_TOKEN: "test-token", + SUPABASE_URL: "", + SUPABASE_SERVICE_ROLE_KEY: "", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + t.after(() => child.kill("SIGTERM")); + + let output = ""; + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`Next.js did not start:\n${output}`)), + 30_000, + ); + const onData = (chunk) => { + output += chunk.toString(); + if (output.includes("Ready in")) { + clearTimeout(timeout); + resolve(); + } + }; + child.stdout.on("data", onData); + child.stderr.on("data", onData); + child.once("exit", (code) => { + clearTimeout(timeout); + reject(new Error(`Next.js exited with code ${code}:\n${output}`)); + }); + }); + + return `http://127.0.0.1:${port}`; +} + +test( + "POST /api/runs rejects an empty results array before database access", + async (t) => { + const origin = await startServer(t); + const infoResponse = await fetch(`${origin}/api/runs`); + assert.equal(infoResponse.status, 200); + assert.deepEqual((await infoResponse.json()).validation, { + results: "must contain at least one result", + }); + + const response = await fetch(`${origin}/api/runs`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-tracecase-token": "test-token", + }, + body: JSON.stringify({ + suite: "refund-agent", + label: "empty run", + results: [], + }), + }); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { + error: "results[] must not be empty", + }); + }, +);