mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
Merge branch 'feat/playground-api-F3' into chore/playground-search-audit-F10
This commit is contained in:
151
src/app/api/playground/improve-prompt/route.ts
Normal file
151
src/app/api/playground/improve-prompt/route.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* POST /api/playground/improve-prompt
|
||||
*
|
||||
* Improves a user-supplied system prompt and/or user prompt by calling
|
||||
* /v1/chat/completions internally with a meta-prompt (D8 — uses the model
|
||||
* chosen by the user, never forces a cheap model).
|
||||
*
|
||||
* Request: ImprovePromptRequestSchema { system?, prompt?, model, tone? }
|
||||
* Response: { improvedSystem?, improvedPrompt?, tokensIn, tokensOut }
|
||||
*
|
||||
* Auth: optional (mirrors /v1/web/fetch pattern — required only when
|
||||
* REQUIRE_API_KEY=true; always validated when supplied).
|
||||
*
|
||||
* Hard Rule #12: ALL error paths route through buildErrorBody.
|
||||
*/
|
||||
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import {
|
||||
ImprovePromptRequestSchema,
|
||||
buildImproveChatBody,
|
||||
parseImprovedContent,
|
||||
} from "@/lib/playground/promptImprover";
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
};
|
||||
|
||||
function errorResp(status: number, message: string, upstreamDetails?: unknown): Response {
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(status, sanitizeErrorMessage(message), upstreamDetails)),
|
||||
{
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function OPTIONS(): Promise<Response> {
|
||||
return new Response(null, { headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
// 1. Parse JSON body
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return errorResp(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
|
||||
}
|
||||
|
||||
// 2. Validate via Zod (Hard Rule #7)
|
||||
const parsed = ImprovePromptRequestSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
const firstIssue = parsed.error.issues[0];
|
||||
const message = firstIssue
|
||||
? `${firstIssue.path.join(".") || "body"}: ${firstIssue.message}`
|
||||
: "Invalid request body";
|
||||
return errorResp(HTTP_STATUS.BAD_REQUEST, message);
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
// 3. Optional auth (mirrors /v1/web/fetch pattern)
|
||||
const apiKeyRaw = extractApiKey(request);
|
||||
if (process.env.REQUIRE_API_KEY === "true" && !apiKeyRaw) {
|
||||
return errorResp(HTTP_STATUS.UNAUTHORIZED, "Authentication required");
|
||||
}
|
||||
if (apiKeyRaw && !(await isValidApiKey(apiKeyRaw))) {
|
||||
return errorResp(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
|
||||
}
|
||||
|
||||
// 4. Build the meta-prompt chat body
|
||||
const chatBody = buildImproveChatBody(body);
|
||||
|
||||
// 5. Call /v1/chat/completions on ourselves (D8)
|
||||
const port = process.env.PORT ?? "20128";
|
||||
const baseUrl =
|
||||
process.env.OMNIROUTE_BASE_URL ?? `http://127.0.0.1:${port}`;
|
||||
const upstreamUrl = `${baseUrl}/v1/chat/completions`;
|
||||
|
||||
let upstreamResponse: Response;
|
||||
try {
|
||||
upstreamResponse = await fetch(upstreamUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKeyRaw ?? ""}`,
|
||||
},
|
||||
body: JSON.stringify(chatBody),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const safeMsg = sanitizeErrorMessage(
|
||||
err instanceof Error ? err.message : "Failed to reach upstream"
|
||||
);
|
||||
return errorResp(HTTP_STATUS.SERVER_ERROR, `Improve-prompt failed: ${safeMsg}`);
|
||||
}
|
||||
|
||||
if (!upstreamResponse.ok) {
|
||||
let upstreamText = "";
|
||||
try {
|
||||
upstreamText = await upstreamResponse.text();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return errorResp(
|
||||
upstreamResponse.status >= 400 && upstreamResponse.status < 600
|
||||
? upstreamResponse.status
|
||||
: HTTP_STATUS.BAD_GATEWAY,
|
||||
"Improve-prompt upstream request failed",
|
||||
sanitizeErrorMessage(upstreamText)
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Parse response
|
||||
let data: {
|
||||
choices?: Array<{ message?: { content?: string | null } }>;
|
||||
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
||||
};
|
||||
try {
|
||||
data = (await upstreamResponse.json()) as typeof data;
|
||||
} catch {
|
||||
return errorResp(HTTP_STATUS.BAD_GATEWAY, "Invalid JSON from upstream improve-prompt call");
|
||||
}
|
||||
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
if (typeof content !== "string") {
|
||||
return errorResp(HTTP_STATUS.BAD_GATEWAY, "Empty or missing content from upstream");
|
||||
}
|
||||
|
||||
// 7. Parse improved content back into system/prompt parts
|
||||
const hadSystem = Boolean(body.system?.trim());
|
||||
const hadPrompt = Boolean(body.prompt?.trim());
|
||||
const improved = parseImprovedContent(content, hadSystem, hadPrompt);
|
||||
|
||||
const tokensIn = data.usage?.prompt_tokens ?? 0;
|
||||
const tokensOut = data.usage?.completion_tokens ?? 0;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
...improved,
|
||||
tokensIn,
|
||||
tokensOut,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
}
|
||||
);
|
||||
}
|
||||
189
src/app/api/playground/presets/[id]/route.ts
Normal file
189
src/app/api/playground/presets/[id]/route.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* GET /api/playground/presets/[id] — get a single preset
|
||||
* PUT /api/playground/presets/[id] — update a preset (partial patch)
|
||||
* DELETE /api/playground/presets/[id] — delete a preset (204 on success)
|
||||
*
|
||||
* Auth: optional (extractApiKey + isValidApiKey; required when REQUIRE_API_KEY=true).
|
||||
* Hard Rule #5: all DB access via src/lib/db/playgroundPresets (never raw SQL).
|
||||
* Hard Rule #7: PUT body validated via PlaygroundPresetUpdateSchema.
|
||||
* Hard Rule #12: all error paths via buildErrorBody.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import {
|
||||
getPlaygroundPreset,
|
||||
updatePlaygroundPreset,
|
||||
deletePlaygroundPreset,
|
||||
} from "@/lib/db/playgroundPresets";
|
||||
import { PlaygroundPresetUpdateSchema } from "@/shared/schemas/playground";
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Methods": "GET, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
};
|
||||
|
||||
function errorResp(status: number, message: string): Response {
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(status, sanitizeErrorMessage(message))),
|
||||
{
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function checkAuth(request: Request): Promise<Response | null> {
|
||||
const apiKeyRaw = extractApiKey(request);
|
||||
if (process.env.REQUIRE_API_KEY === "true" && !apiKeyRaw) {
|
||||
return errorResp(HTTP_STATUS.UNAUTHORIZED, "Authentication required");
|
||||
}
|
||||
if (apiKeyRaw && !(await isValidApiKey(apiKeyRaw))) {
|
||||
return errorResp(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateId(id: unknown): { valid: true; id: string } | { valid: false; error: Response } {
|
||||
const result = z.string().uuid().safeParse(id);
|
||||
if (!result.success) {
|
||||
return {
|
||||
valid: false,
|
||||
error: errorResp(HTTP_STATUS.BAD_REQUEST, "Invalid preset id: must be a valid UUID"),
|
||||
};
|
||||
}
|
||||
return { valid: true, id: result.data };
|
||||
}
|
||||
|
||||
export async function OPTIONS(): Promise<Response> {
|
||||
return new Response(null, { headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/playground/presets/[id]
|
||||
* Returns the preset or 404.
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
): Promise<Response> {
|
||||
const authError = await checkAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const { id: rawId } = await params;
|
||||
const validation = validateId(rawId);
|
||||
if (!validation.valid) return validation.error;
|
||||
const { id } = validation;
|
||||
|
||||
try {
|
||||
const preset = getPlaygroundPreset(id);
|
||||
if (!preset) {
|
||||
return errorResp(HTTP_STATUS.NOT_FOUND, `Preset not found: ${id}`);
|
||||
}
|
||||
return new Response(JSON.stringify(preset), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const safeMsg = sanitizeErrorMessage(
|
||||
err instanceof Error ? err.message : "Failed to fetch preset"
|
||||
);
|
||||
return errorResp(HTTP_STATUS.SERVER_ERROR, safeMsg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/playground/presets/[id]
|
||||
* Body: PlaygroundPresetUpdateSchema (partial)
|
||||
* Returns the updated preset or 404.
|
||||
*/
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
): Promise<Response> {
|
||||
const authError = await checkAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const { id: rawId } = await params;
|
||||
const validation = validateId(rawId);
|
||||
if (!validation.valid) return validation.error;
|
||||
const { id } = validation;
|
||||
|
||||
// Parse JSON body
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return errorResp(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
|
||||
}
|
||||
|
||||
// Validate body (Hard Rule #7)
|
||||
const parsed = PlaygroundPresetUpdateSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
const firstIssue = parsed.error.issues[0];
|
||||
const message = firstIssue
|
||||
? `${firstIssue.path.join(".") || "body"}: ${firstIssue.message}`
|
||||
: "Invalid request body";
|
||||
return errorResp(HTTP_STATUS.BAD_REQUEST, message);
|
||||
}
|
||||
const patch = parsed.data;
|
||||
|
||||
try {
|
||||
const updated = updatePlaygroundPreset(id, {
|
||||
...(patch.name !== undefined ? { name: patch.name } : {}),
|
||||
...(patch.endpoint !== undefined ? { endpoint: patch.endpoint } : {}),
|
||||
...(patch.model !== undefined ? { model: patch.model } : {}),
|
||||
...("system" in patch ? { system: patch.system ?? null } : {}),
|
||||
...(patch.params !== undefined ? { params: patch.params } : {}),
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return errorResp(HTTP_STATUS.NOT_FOUND, `Preset not found: ${id}`);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(updated), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const safeMsg = sanitizeErrorMessage(
|
||||
err instanceof Error ? err.message : "Failed to update preset"
|
||||
);
|
||||
return errorResp(HTTP_STATUS.SERVER_ERROR, safeMsg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/playground/presets/[id]
|
||||
* Returns 204 on success; 404 if not found.
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
): Promise<Response> {
|
||||
const authError = await checkAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const { id: rawId } = await params;
|
||||
const validation = validateId(rawId);
|
||||
if (!validation.valid) return validation.error;
|
||||
const { id } = validation;
|
||||
|
||||
try {
|
||||
const deleted = deletePlaygroundPreset(id);
|
||||
if (!deleted) {
|
||||
return errorResp(HTTP_STATUS.NOT_FOUND, `Preset not found: ${id}`);
|
||||
}
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: CORS_HEADERS,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const safeMsg = sanitizeErrorMessage(
|
||||
err instanceof Error ? err.message : "Failed to delete preset"
|
||||
);
|
||||
return errorResp(HTTP_STATUS.SERVER_ERROR, safeMsg);
|
||||
}
|
||||
}
|
||||
116
src/app/api/playground/presets/route.ts
Normal file
116
src/app/api/playground/presets/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* GET /api/playground/presets — list presets
|
||||
* POST /api/playground/presets — create a preset
|
||||
*
|
||||
* Auth: optional (extractApiKey + isValidApiKey; required when REQUIRE_API_KEY=true).
|
||||
* Hard Rule #5: all DB access via src/lib/db/playgroundPresets (never raw SQL).
|
||||
* Hard Rule #12: all error paths via buildErrorBody.
|
||||
*/
|
||||
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import { listPlaygroundPresets, createPlaygroundPreset } from "@/lib/db/playgroundPresets";
|
||||
import { PlaygroundPresetCreateSchema } from "@/shared/schemas/playground";
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
};
|
||||
|
||||
function errorResp(status: number, message: string): Response {
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(status, sanitizeErrorMessage(message))),
|
||||
{
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function checkAuth(request: Request): Promise<Response | null> {
|
||||
const apiKeyRaw = extractApiKey(request);
|
||||
if (process.env.REQUIRE_API_KEY === "true" && !apiKeyRaw) {
|
||||
return errorResp(HTTP_STATUS.UNAUTHORIZED, "Authentication required");
|
||||
}
|
||||
if (apiKeyRaw && !(await isValidApiKey(apiKeyRaw))) {
|
||||
return errorResp(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function OPTIONS(): Promise<Response> {
|
||||
return new Response(null, { headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/playground/presets
|
||||
* Returns { presets: PlaygroundPresetListItem[] }
|
||||
*/
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const authError = await checkAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const presets = listPlaygroundPresets();
|
||||
return new Response(JSON.stringify({ presets }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const safeMsg = sanitizeErrorMessage(
|
||||
err instanceof Error ? err.message : "Failed to list presets"
|
||||
);
|
||||
return errorResp(HTTP_STATUS.SERVER_ERROR, safeMsg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/playground/presets
|
||||
* Body: PlaygroundPresetCreateSchema
|
||||
* Returns the created preset with status 201.
|
||||
*/
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const authError = await checkAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
// 1. Parse JSON body
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return errorResp(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
|
||||
}
|
||||
|
||||
// 2. Validate (Hard Rule #7)
|
||||
const parsed = PlaygroundPresetCreateSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
const firstIssue = parsed.error.issues[0];
|
||||
const message = firstIssue
|
||||
? `${firstIssue.path.join(".") || "body"}: ${firstIssue.message}`
|
||||
: "Invalid request body";
|
||||
return errorResp(HTTP_STATUS.BAD_REQUEST, message);
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
// 3. Create preset (Hard Rule #5 — via DB module)
|
||||
try {
|
||||
const created = createPlaygroundPreset({
|
||||
name: body.name,
|
||||
endpoint: body.endpoint,
|
||||
model: body.model,
|
||||
system: body.system ?? null,
|
||||
params: body.params,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(created), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const safeMsg = sanitizeErrorMessage(
|
||||
err instanceof Error ? err.message : "Failed to create preset"
|
||||
);
|
||||
return errorResp(HTTP_STATUS.SERVER_ERROR, safeMsg);
|
||||
}
|
||||
}
|
||||
303
tests/integration/playground-improve-prompt.test.ts
Normal file
303
tests/integration/playground-improve-prompt.test.ts
Normal file
@@ -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(
|
||||
"<<SYSTEM>>\nimproved system\n\n<<PROMPT>>\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;
|
||||
}
|
||||
});
|
||||
337
tests/integration/playground-presets-crud.test.ts
Normal file
337
tests/integration/playground-presets-crud.test.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<string, unknown> };
|
||||
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<string, unknown> };
|
||||
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<string, unknown> };
|
||||
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);
|
||||
});
|
||||
299
tests/integration/playground-presets-zod.test.ts
Normal file
299
tests/integration/playground-presets-zod.test.ts
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user