diff --git a/tests/integration/playground-improve-prompt.test.ts b/tests/integration/playground-improve-prompt.test.ts new file mode 100644 index 0000000000..aaa67d67cc --- /dev/null +++ b/tests/integration/playground-improve-prompt.test.ts @@ -0,0 +1,303 @@ +/** + * Integration tests for POST /api/playground/improve-prompt + * + * Tests import the route handler directly and call it with mock Request objects. + * Fetch to /v1/chat/completions is mocked via globalThis.fetch. + * + * Coverage areas: + * - Happy path (system + prompt, only system, only prompt) + * - Invalid bodies → 400 + * - Missing model → 400 + * - Upstream error → sanitized error (no stack trace leak) + * - Hard Rule #12: error.message never contains " at /" + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Set up a temp DATA_DIR so getDbInstance() initialises cleanly +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-improve-prompt-") +); +process.env.DATA_DIR = TEST_DATA_DIR; +// Disable mandatory auth for most tests +process.env.REQUIRE_API_KEY = "false"; + +const { POST, OPTIONS } = await import( + "../../src/app/api/playground/improve-prompt/route.ts" +); + +const BASE_URL = "http://localhost:20128"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeUpstreamOkFetch(content: string, promptTokens = 10, completionTokens = 5) { + return async (_url: unknown, _opts: unknown) => { + const body = JSON.stringify({ + choices: [{ message: { content } }], + usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens }, + }); + return new Response(body, { status: 200, headers: { "Content-Type": "application/json" } }); + }; +} + +function makeUpstreamErrorFetch(status: number, text: string) { + return async (_url: unknown, _opts: unknown) => { + return new Response(text, { status, headers: { "Content-Type": "text/plain" } }); + }; +} + +function postRequest(body: unknown): Request { + return new Request(`${BASE_URL}/api/playground/improve-prompt`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +// ─── Cleanup ───────────────────────────────────────────────────────────────── + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ─── OPTIONS ───────────────────────────────────────────────────────────────── + +test("OPTIONS returns 200 with CORS headers", async () => { + const res = await OPTIONS(); + assert.equal(res.status, 200); + assert.ok(res.headers.get("Access-Control-Allow-Methods")?.includes("POST")); +}); + +// ─── Happy paths ───────────────────────────────────────────────────────────── + +test("happy path: system + prompt both provided", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = makeUpstreamOkFetch( + "<>\nimproved system\n\n<>\nimproved prompt", + 10, + 5 + ) as typeof fetch; + + const res = await POST( + postRequest({ system: "You are a helper.", prompt: "Tell me about AI.", model: "gpt-4o-mini" }) + ); + assert.equal(res.status, 200); + + const body = (await res.json()) as { + improvedSystem?: string; + improvedPrompt?: string; + tokensIn: number; + tokensOut: number; + }; + assert.equal(body.improvedSystem, "improved system"); + assert.equal(body.improvedPrompt, "improved prompt"); + assert.equal(body.tokensIn, 10); + assert.equal(body.tokensOut, 5); + // CORS header present + assert.ok(res.headers.get("Access-Control-Allow-Methods")?.includes("POST")); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("happy path: only system provided", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = makeUpstreamOkFetch("improved system content only") as typeof fetch; + + const res = await POST( + postRequest({ system: "You are a helpful assistant.", model: "gpt-4o" }) + ); + assert.equal(res.status, 200); + + const body = (await res.json()) as { + improvedSystem?: string; + improvedPrompt?: string; + tokensIn: number; + tokensOut: number; + }; + // When only system was sent and no markers, content assigned to improvedSystem + assert.ok(typeof body.improvedSystem === "string" || body.improvedSystem === undefined); + assert.strictEqual(body.improvedPrompt, undefined); + assert.equal(typeof body.tokensIn, "number"); + assert.equal(typeof body.tokensOut, "number"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("happy path: only prompt provided", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = makeUpstreamOkFetch("improved prompt content only") as typeof fetch; + + const res = await POST( + postRequest({ prompt: "What is machine learning?", model: "claude-3-5-sonnet-20241022" }) + ); + assert.equal(res.status, 200); + + const body = (await res.json()) as { + improvedSystem?: string; + improvedPrompt?: string; + tokensIn: number; + tokensOut: number; + }; + assert.strictEqual(body.improvedSystem, undefined); + assert.ok(typeof body.improvedPrompt === "string" || body.improvedPrompt === undefined); + assert.equal(typeof body.tokensIn, "number"); + assert.equal(typeof body.tokensOut, "number"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("happy path: usage defaults to 0 when not in upstream response", async () => { + const originalFetch = globalThis.fetch; + try { + // Return response without usage field + globalThis.fetch = (async (_url: unknown, _opts: unknown) => { + return new Response( + JSON.stringify({ choices: [{ message: { content: "improved" } }] }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + + const res = await POST( + postRequest({ prompt: "Hello world", model: "gpt-4o-mini" }) + ); + assert.equal(res.status, 200); + + const body = (await res.json()) as { tokensIn: number; tokensOut: number }; + assert.equal(body.tokensIn, 0); + assert.equal(body.tokensOut, 0); + } finally { + globalThis.fetch = originalFetch; + } +}); + +// ─── Validation errors → 400 ───────────────────────────────────────────────── + +test("400 when body has neither system nor prompt", async () => { + const res = await POST(postRequest({ model: "gpt-4o-mini" })); + assert.equal(res.status, 400); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error, "should have error field"); + assert.equal(typeof body.error.message, "string"); + // Hard Rule #12: no stack trace + assert.ok(!body.error.message.match(/\sat\s\//), "should not contain stack trace"); +}); + +test("400 when body has both system and prompt as empty strings", async () => { + const res = await POST(postRequest({ system: " ", prompt: " ", model: "gpt-4o-mini" })); + assert.equal(res.status, 400); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error, "should have error field"); + assert.ok(!body.error.message.match(/\sat\s\//)); +}); + +test("400 when model is missing", async () => { + const res = await POST(postRequest({ system: "You are a helper." })); + assert.equal(res.status, 400); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error, "should have error field"); + assert.ok(!body.error.message.match(/\sat\s\//)); +}); + +test("400 when model is empty string", async () => { + const res = await POST(postRequest({ prompt: "Tell me something.", model: "" })); + assert.equal(res.status, 400); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error, "should have error field"); + assert.ok(!body.error.message.match(/\sat\s\//)); +}); + +test("400 when JSON body is malformed", async () => { + const req = new Request(`${BASE_URL}/api/playground/improve-prompt`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "NOT_JSON", + }); + const res = await POST(req); + assert.equal(res.status, 400); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error, "should have error field"); + assert.ok(!body.error.message.match(/\sat\s\//)); +}); + +// ─── Upstream error handling ────────────────────────────────────────────────── + +test("upstream error returns sanitized error message — no stack trace in body", async () => { + const originalFetch = globalThis.fetch; + try { + // Simulate upstream returning 500 with a message that looks like stack trace + globalThis.fetch = makeUpstreamErrorFetch( + 500, + "Internal error\n at /home/user/project/src/handler.ts:42:10\n at process.nextTick" + ) as typeof fetch; + + const res = await POST( + postRequest({ prompt: "Hello", model: "gpt-4o-mini" }) + ); + // Should be an error response (not 200) + assert.ok(res.status >= 400); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error, "should have error field"); + // Hard Rule #12: stack trace must be stripped + assert.ok( + !body.error.message.match(/\sat\s\//), + "error message must not contain stack trace paths" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("upstream network error is sanitized", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async () => { + throw new Error("ECONNREFUSED connect ECONNREFUSED 127.0.0.1:20128"); + }) as typeof fetch; + + const res = await POST( + postRequest({ prompt: "Hello", model: "gpt-4o-mini" }) + ); + assert.ok(res.status >= 500); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assert.ok(!body.error.message.match(/\sat\s\//)); + } finally { + globalThis.fetch = originalFetch; + } +}); + +// ─── Auth ───────────────────────────────────────────────────────────────────── + +test("401 when REQUIRE_API_KEY=true and no key provided", async () => { + const originalRequired = process.env.REQUIRE_API_KEY; + try { + process.env.REQUIRE_API_KEY = "true"; + const res = await POST( + postRequest({ prompt: "Test", model: "gpt-4o-mini" }) + ); + assert.equal(res.status, 401); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assert.ok(!body.error.message.match(/\sat\s\//)); + } finally { + process.env.REQUIRE_API_KEY = originalRequired; + } +}); diff --git a/tests/integration/playground-presets-crud.test.ts b/tests/integration/playground-presets-crud.test.ts new file mode 100644 index 0000000000..655fdfdc53 --- /dev/null +++ b/tests/integration/playground-presets-crud.test.ts @@ -0,0 +1,337 @@ +/** + * Integration tests for GET/POST /api/playground/presets + * and GET/PUT/DELETE /api/playground/presets/[id] + * + * Tests exercise the full CRUD lifecycle using a temporary SQLite DB. + * + * Coverage areas: + * - POST preset → 201 with UUID id + * - GET list → includes the created item + * - GET [id] → returns the item + * - PUT partial patch (name only) + * - DELETE → 204 + * - GET [id] after delete → 404 + * - DELETE non-existent id → 404 + * - UUID validation: non-UUID id → 400 + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Isolated DB per test file +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-presets-crud-") +); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.REQUIRE_API_KEY = "false"; + +const core = await import("../../src/lib/db/core.ts"); + +// Import route handlers +const { GET: listGet, POST: createPost, OPTIONS: listOptions } = await import( + "../../src/app/api/playground/presets/route.ts" +); +const { GET: idGet, PUT: idPut, DELETE: idDelete, OPTIONS: idOptions } = await import( + "../../src/app/api/playground/presets/[id]/route.ts" +); + +const BASE_URL = "http://localhost:20128"; + +const UUID_V4_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function postReq(body: unknown): Request { + return new Request(`${BASE_URL}/api/playground/presets`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function getReq(): Request { + return new Request(`${BASE_URL}/api/playground/presets`, { method: "GET" }); +} + +function idGetReq(id: string): Request { + return new Request(`${BASE_URL}/api/playground/presets/${id}`, { method: "GET" }); +} + +function putReq(id: string, body: unknown): Request { + return new Request(`${BASE_URL}/api/playground/presets/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function deleteReq(id: string): Request { + return new Request(`${BASE_URL}/api/playground/presets/${id}`, { method: "DELETE" }); +} + +async function resolveParams(id: string): Promise<{ params: Promise<{ id: string }> }> { + return { params: Promise.resolve({ id }) }; +} + +// ─── Cleanup ───────────────────────────────────────────────────────────────── + +test.beforeEach(async () => { + // Reset DB between tests for isolation + core.resetDbInstance(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ─── OPTIONS ───────────────────────────────────────────────────────────────── + +test("OPTIONS /presets returns CORS with GET, POST, OPTIONS", async () => { + const res = await listOptions(); + assert.equal(res.status, 200); + const methods = res.headers.get("Access-Control-Allow-Methods") ?? ""; + assert.ok(methods.includes("GET")); + assert.ok(methods.includes("POST")); + assert.ok(methods.includes("OPTIONS")); +}); + +test("OPTIONS /presets/[id] returns CORS with GET, PUT, DELETE, OPTIONS", async () => { + const res = await idOptions(); + assert.equal(res.status, 200); + const methods = res.headers.get("Access-Control-Allow-Methods") ?? ""; + assert.ok(methods.includes("GET")); + assert.ok(methods.includes("PUT")); + assert.ok(methods.includes("DELETE")); +}); + +// ─── Full CRUD lifecycle ────────────────────────────────────────────────────── + +test("POST /presets → 201 with valid UUID id and correct shape", async () => { + const res = await createPost( + postReq({ + name: "My Test Preset", + endpoint: "chat.completions", + model: "gpt-4o", + system: "You are a helpful assistant.", + params: { temperature: 0.7, max_tokens: 1024 }, + }) + ); + assert.equal(res.status, 201); + assert.ok(res.headers.get("Access-Control-Allow-Methods")?.includes("POST")); + + const body = (await res.json()) as { + id: string; + name: string; + endpoint: string; + model: string; + system: string | null; + params: Record; + created_at: string; + }; + assert.ok(UUID_V4_REGEX.test(body.id), "id should be a valid UUID v4"); + assert.equal(body.name, "My Test Preset"); + assert.equal(body.endpoint, "chat.completions"); + assert.equal(body.model, "gpt-4o"); + assert.equal(body.system, "You are a helpful assistant."); + assert.deepEqual(body.params, { temperature: 0.7, max_tokens: 1024 }); + assert.equal(typeof body.created_at, "string"); +}); + +test("GET /presets list returns the created item", async () => { + // Create one preset + const createRes = await createPost( + postReq({ name: "Preset A", endpoint: "chat.completions", model: "gpt-4o" }) + ); + assert.equal(createRes.status, 201); + const created = (await createRes.json()) as { id: string }; + + // List + const listRes = await listGet(getReq()); + assert.equal(listRes.status, 200); + const listBody = (await listRes.json()) as { presets: Array<{ id: string; name: string }> }; + assert.ok(Array.isArray(listBody.presets)); + const found = listBody.presets.find((p) => p.id === created.id); + assert.ok(found, "created preset should appear in list"); + assert.equal(found.name, "Preset A"); +}); + +test("GET /presets/[id] returns the correct preset", async () => { + const createRes = await createPost( + postReq({ name: "Fetch Me", endpoint: "embeddings", model: "text-embedding-3-small" }) + ); + const created = (await createRes.json()) as { id: string; name: string }; + + const getRes = await idGet(idGetReq(created.id), await resolveParams(created.id)); + assert.equal(getRes.status, 200); + + const body = (await getRes.json()) as { id: string; name: string }; + assert.equal(body.id, created.id); + assert.equal(body.name, "Fetch Me"); +}); + +test("PUT /presets/[id] partial patch (name only) updates correctly", async () => { + const createRes = await createPost( + postReq({ name: "Original Name", endpoint: "chat.completions", model: "gpt-4o" }) + ); + const created = (await createRes.json()) as { id: string; endpoint: string; model: string }; + + const putRes = await idPut( + putReq(created.id, { name: "Updated Name" }), + await resolveParams(created.id) + ); + assert.equal(putRes.status, 200); + + const updated = (await putRes.json()) as { id: string; name: string; endpoint: string; model: string }; + assert.equal(updated.id, created.id); + assert.equal(updated.name, "Updated Name"); + // Other fields should be preserved + assert.equal(updated.endpoint, created.endpoint); + assert.equal(updated.model, created.model); +}); + +test("PUT /presets/[id] can update params", async () => { + const createRes = await createPost( + postReq({ + name: "Params Test", + endpoint: "chat.completions", + model: "gpt-4o", + params: { temperature: 0.5 }, + }) + ); + const created = (await createRes.json()) as { id: string }; + + const putRes = await idPut( + putReq(created.id, { params: { temperature: 0.9, max_tokens: 2048 } }), + await resolveParams(created.id) + ); + assert.equal(putRes.status, 200); + + const updated = (await putRes.json()) as { params: Record }; + assert.deepEqual(updated.params, { temperature: 0.9, max_tokens: 2048 }); +}); + +test("DELETE /presets/[id] returns 204", async () => { + const createRes = await createPost( + postReq({ name: "Delete Me", endpoint: "chat.completions", model: "gpt-4o" }) + ); + const created = (await createRes.json()) as { id: string }; + + const delRes = await idDelete(deleteReq(created.id), await resolveParams(created.id)); + assert.equal(delRes.status, 204); +}); + +test("GET /presets/[id] after DELETE returns 404", async () => { + const createRes = await createPost( + postReq({ name: "Gone Soon", endpoint: "chat.completions", model: "gpt-4o" }) + ); + const created = (await createRes.json()) as { id: string }; + + await idDelete(deleteReq(created.id), await resolveParams(created.id)); + + const getRes = await idGet(idGetReq(created.id), await resolveParams(created.id)); + assert.equal(getRes.status, 404); + + const body = (await getRes.json()) as { error: { message: string } }; + assert.ok(body.error); + assert.ok(!body.error.message.match(/\sat\s\//)); +}); + +test("DELETE /presets/[id] with non-existent id returns 404", async () => { + const fakeId = "00000000-0000-4000-8000-000000000000"; + const delRes = await idDelete(deleteReq(fakeId), await resolveParams(fakeId)); + assert.equal(delRes.status, 404); + + const body = (await delRes.json()) as { error: { message: string } }; + assert.ok(body.error); + assert.ok(!body.error.message.match(/\sat\s\//)); +}); + +// ─── params JSON round-trip ─────────────────────────────────────────────────── + +test("params object is serialized and deserialized correctly", async () => { + const complexParams = { + temperature: 0.7, + max_tokens: 2048, + top_p: 0.9, + presence_penalty: 0.1, + frequency_penalty: 0.2, + stop: ["<|end|>", "\n\n"], + }; + + const createRes = await createPost( + postReq({ + name: "Complex Params", + endpoint: "chat.completions", + model: "gpt-4o", + params: complexParams, + }) + ); + assert.equal(createRes.status, 201); + const created = (await createRes.json()) as { id: string; params: Record }; + assert.deepEqual(created.params, complexParams); + + // GET again and verify + const getRes = await idGet(idGetReq(created.id), await resolveParams(created.id)); + const fetched = (await getRes.json()) as { params: Record }; + assert.deepEqual(fetched.params, complexParams); +}); + +// ─── UUID validation ───────────────────────────────────────────────────────── + +test("GET /presets/[id] with non-UUID id → 400", async () => { + const badId = "not-a-uuid"; + const res = await idGet(idGetReq(badId), await resolveParams(badId)); + assert.equal(res.status, 400); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assert.ok(!body.error.message.match(/\sat\s\//)); +}); + +test("PUT /presets/[id] with non-UUID id → 400", async () => { + const badId = "also-not-a-uuid"; + const res = await idPut( + putReq(badId, { name: "Whatever" }), + await resolveParams(badId) + ); + assert.equal(res.status, 400); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assert.ok(!body.error.message.match(/\sat\s\//)); +}); + +test("DELETE /presets/[id] with non-UUID id → 400", async () => { + const badId = "bad-id-not-uuid"; + const res = await idDelete(deleteReq(badId), await resolveParams(badId)); + assert.equal(res.status, 400); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assert.ok(!body.error.message.match(/\sat\s\//)); +}); + +// ─── Null system ───────────────────────────────────────────────────────────── + +test("POST with null system stores null correctly", async () => { + const res = await createPost( + postReq({ name: "No System", endpoint: "chat.completions", model: "gpt-4o", system: null }) + ); + assert.equal(res.status, 201); + const body = (await res.json()) as { system: string | null }; + assert.strictEqual(body.system, null); +}); + +test("POST without system defaults to null", async () => { + const res = await createPost( + postReq({ name: "No System Either", endpoint: "chat.completions", model: "gpt-4o" }) + ); + assert.equal(res.status, 201); + const body = (await res.json()) as { system: string | null }; + assert.strictEqual(body.system, null); +}); diff --git a/tests/integration/playground-presets-zod.test.ts b/tests/integration/playground-presets-zod.test.ts new file mode 100644 index 0000000000..3150eaed15 --- /dev/null +++ b/tests/integration/playground-presets-zod.test.ts @@ -0,0 +1,299 @@ +/** + * Integration tests for Zod validation in playground presets routes. + * + * Tests focus on invalid bodies and edge cases that must return 400. + * + * Coverage areas: + * - POST with empty name → 400 + * - POST with missing required fields (endpoint, model) → 400 + * - POST with system > 50000 chars → 400 + * - PUT with invalid body → 400 + * - GET/PUT/DELETE with valid UUID format but wrong format string → 400 + * - All error responses must NOT contain stack trace fragments (Hard Rule #12) + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Isolated DB per test file +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-presets-zod-") +); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.REQUIRE_API_KEY = "false"; + +const core = await import("../../src/lib/db/core.ts"); + +const { POST: createPost } = await import( + "../../src/app/api/playground/presets/route.ts" +); +const { GET: idGet, PUT: idPut, DELETE: idDelete } = await import( + "../../src/app/api/playground/presets/[id]/route.ts" +); + +const BASE_URL = "http://localhost:20128"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function postReq(body: unknown): Request { + return new Request(`${BASE_URL}/api/playground/presets`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function putReq(id: string, body: unknown): Request { + return new Request(`${BASE_URL}/api/playground/presets/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function getReq(id: string): Request { + return new Request(`${BASE_URL}/api/playground/presets/${id}`, { method: "GET" }); +} + +function deleteReq(id: string): Request { + return new Request(`${BASE_URL}/api/playground/presets/${id}`, { method: "DELETE" }); +} + +async function resolveParams(id: string): Promise<{ params: Promise<{ id: string }> }> { + return { params: Promise.resolve({ id }) }; +} + +// Sanitization assertion helper +function assertNoStackTrace(body: { error?: { message?: string } }): void { + const msg = body.error?.message ?? ""; + assert.ok( + !msg.match(/\sat\s\//), + `error message must not contain stack trace paths; got: ${msg}` + ); +} + +// ─── Cleanup ───────────────────────────────────────────────────────────────── + +test.beforeEach(() => { + core.resetDbInstance(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ─── POST validation ───────────────────────────────────────────────────────── + +test("POST with empty name → 400 with Zod error", async () => { + const res = await createPost( + postReq({ name: "", endpoint: "chat.completions", model: "gpt-4o" }) + ); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("POST with name exceeding 100 chars → 400", async () => { + const res = await createPost( + postReq({ name: "x".repeat(101), endpoint: "chat.completions", model: "gpt-4o" }) + ); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("POST with missing endpoint → 400", async () => { + const res = await createPost(postReq({ name: "Test", model: "gpt-4o" })); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("POST with empty endpoint → 400", async () => { + const res = await createPost(postReq({ name: "Test", endpoint: "", model: "gpt-4o" })); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("POST with missing model → 400", async () => { + const res = await createPost(postReq({ name: "Test", endpoint: "chat.completions" })); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("POST with empty model → 400", async () => { + const res = await createPost( + postReq({ name: "Test", endpoint: "chat.completions", model: "" }) + ); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("POST with system > 50000 chars → 400", async () => { + const longSystem = "x".repeat(50001); + const res = await createPost( + postReq({ name: "Big System", endpoint: "chat.completions", model: "gpt-4o", system: longSystem }) + ); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("POST with system exactly 50000 chars → 201 (boundary: valid)", async () => { + const maxSystem = "x".repeat(50000); + const res = await createPost( + postReq({ name: "Max System", endpoint: "chat.completions", model: "gpt-4o", system: maxSystem }) + ); + assert.equal(res.status, 201); +}); + +test("POST with invalid JSON body → 400", async () => { + const req = new Request(`${BASE_URL}/api/playground/presets`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "GARBAGE", + }); + const res = await createPost(req); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("POST with missing name entirely → 400", async () => { + const res = await createPost(postReq({ endpoint: "chat.completions", model: "gpt-4o" })); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +// ─── PUT validation ─────────────────────────────────────────────────────────── + +test("PUT with invalid body (name is number) → 400", async () => { + const validId = "00000000-0000-4000-8000-000000000001"; + const res = await idPut(putReq(validId, { name: 42 }), await resolveParams(validId)); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("PUT with empty name → 400", async () => { + const validId = "00000000-0000-4000-8000-000000000001"; + const res = await idPut(putReq(validId, { name: "" }), await resolveParams(validId)); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("PUT with system > 50000 chars → 400", async () => { + const validId = "00000000-0000-4000-8000-000000000001"; + const longSystem = "y".repeat(50001); + const res = await idPut( + putReq(validId, { system: longSystem }), + await resolveParams(validId) + ); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("PUT with invalid JSON body → 400", async () => { + const validId = "00000000-0000-4000-8000-000000000001"; + const req = new Request(`${BASE_URL}/api/playground/presets/${validId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: "GARBAGE", + }); + const res = await idPut(req, await resolveParams(validId)); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +// ─── UUID format validation ─────────────────────────────────────────────────── + +test("GET /presets/[id] with numeric string → 400", async () => { + const badId = "12345"; + const res = await idGet(getReq(badId), await resolveParams(badId)); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("GET /presets/[id] with plain word id → 400", async () => { + const badId = "my-preset"; + const res = await idGet(getReq(badId), await resolveParams(badId)); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("PUT /presets/[id] with numeric string id → 400", async () => { + const badId = "12345"; + const res = await idPut(putReq(badId, { name: "x" }), await resolveParams(badId)); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("DELETE /presets/[id] with numeric string id → 400", async () => { + const badId = "99999"; + const res = await idDelete(deleteReq(badId), await resolveParams(badId)); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("GET /presets/[id] with UUID v1 format → 404 (valid UUID format, not found in DB)", async () => { + // z.string().uuid() accepts any RFC-4122 UUID (v1, v4, etc). + // UUID v1 passes UUID validation → route proceeds to DB lookup → 404 (not found). + const uuidV1 = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"; + const res = await idGet(getReq(uuidV1), await resolveParams(uuidV1)); + assert.equal(res.status, 404); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +// ─── Error response shape validation ───────────────────────────────────────── + +test("all 400 responses have consistent error shape (message string)", async () => { + const cases = [ + createPost(postReq({ name: "" })), + createPost(postReq({ endpoint: "chat" })), + createPost(postReq({})), + ]; + + for (const pending of cases) { + const res = await pending; + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string; type?: string } }; + assert.ok(body.error, "should have error object"); + assert.equal(typeof body.error.message, "string", "error.message must be a string"); + assert.ok(body.error.message.length > 0, "error.message must not be empty"); + assertNoStackTrace(body); + } +});