From 97c0330af8b85a4f2a66f120a3e91457a39ddf2b Mon Sep 17 00:00:00 2001 From: sergiopesch Date: Tue, 21 Apr 2026 12:30:35 +0200 Subject: [PATCH] test(api/chat): add DI seam for OpenAI client and mocked route tests Introduces a minimal dependency-injection seam so POST /api/chat can be driven in tests without a live OpenAI API key: app/api/chat/openai-client.ts export type ChatCompletionClient = { chat: { completions: { create } } } export function getChatClient(): ChatCompletionClient export function __setChatClientForTests(client | null): void Production behavior is unchanged: getChatClient() lazily constructs a real OpenAI client when no test override is set. The route.ts handler now imports getChatClient() instead of constructing OpenAI inline. New tests (tests/chat-route-mocked.test.ts, 7 tests): - Returns a validated spec card from a canned LLM response - Voice mode attaches voice.spokenSummary - Unparseable LLM output -> FALLBACK_TEXT_RESPONSE - Partially-valid payload is rescued via toSafeTextResponse - AbortError from the client -> FALLBACK_TEXT_RESPONSE (not 500) - Non-timeout upstream error -> 500 with generic error message - Post-normalize: requestedImageCount > 1 overrides LLM's imageCount Together with the pure-function tests added earlier, POST /api/chat now has end-to-end coverage for every major branch except the happy-path forced-photo fast path (already covered). Total: 17 -> 24 tests. All pass with validate + build. --- app/api/chat/openai-client.ts | 48 ++++++ app/api/chat/route.ts | 10 +- tests/chat-route-mocked.test.ts | 296 ++++++++++++++++++++++++++++++++ 3 files changed, 346 insertions(+), 8 deletions(-) create mode 100644 app/api/chat/openai-client.ts create mode 100644 tests/chat-route-mocked.test.ts diff --git a/app/api/chat/openai-client.ts b/app/api/chat/openai-client.ts new file mode 100644 index 0000000..d7946cd --- /dev/null +++ b/app/api/chat/openai-client.ts @@ -0,0 +1,48 @@ +/** + * Dependency-injection seam for the OpenAI chat-completions client. + * + * The route handler calls `getChatClient()` to obtain a minimal client that + * exposes only the shape we actually use: + * + * client.chat.completions.create(body, options) + * + * Production code ignores this module's test hooks and gets a lazily + * constructed real OpenAI client. Tests call `setChatClientForTests(fake)` + * to inject a fake that records calls and returns canned responses, without + * needing to network out or hold an API key. + * + * This file intentionally has no behavioral knowledge -- it is a pure + * factory. All policy (timeouts, prompts, rate limits) lives in route.ts. + */ +import OpenAI from 'openai'; + +type ChatCompletionsCreate = OpenAI['chat']['completions']['create']; + +export type ChatCompletionClient = { + chat: { + completions: { + create: ChatCompletionsCreate; + }; + }; +}; + +let _realClient: OpenAI | null = null; +let _override: ChatCompletionClient | null = null; + +export function getChatClient(): ChatCompletionClient { + if (_override) return _override; + if (!_realClient) _realClient = new OpenAI(); + return _realClient; +} + +/** + * Test hook: inject a fake chat client. Pass `null` to clear. + * + * The double-underscore prefix signals that production code MUST NOT call + * this. In a stricter codebase we'd split it into a separate entrypoint to + * keep the prod bundle clean; for this repo's size, a naming convention is + * enough and we verified no prod callsite reaches for it. + */ +export function __setChatClientForTests(client: ChatCompletionClient | null): void { + _override = client; +} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 4b16398..45c41d2 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,5 +1,4 @@ import { NextResponse } from 'next/server'; -import OpenAI from 'openai'; import { SYSTEM_PROMPT } from '@/lib/ai/system-prompt'; import { cleanTranscriptLight } from '@/lib/ai/transcript-utils'; import { @@ -11,6 +10,7 @@ import { RENDER_FALLBACK_TEXT_RESPONSE, } from './constants'; import { logger } from './logger'; +import { getChatClient } from './openai-client'; import { checkRateLimit } from './rate-limit'; import { parseAndNormalizeResponse } from './response-normalizer'; import { validateStructuredResponse } from './response-validator'; @@ -26,12 +26,6 @@ export const dynamic = 'force-dynamic'; // fallback rather than having the platform kill the request. const OPENAI_REQUEST_TIMEOUT_MS = 8_000; -let _openai: OpenAI | null = null; -function getClient() { - if (!_openai) _openai = new OpenAI(); - return _openai; -} - function jsonResponse(payload: unknown, init?: ResponseInit): Response { return new Response(JSON.stringify(payload), { headers: { 'Content-Type': 'application/json' }, @@ -321,7 +315,7 @@ export async function POST(req: Request) { } try { - const completion = await getClient().chat.completions.create( + const completion = await getChatClient().chat.completions.create( { model: 'gpt-4o-mini', response_format: { type: 'json_object' }, diff --git a/tests/chat-route-mocked.test.ts b/tests/chat-route-mocked.test.ts new file mode 100644 index 0000000..c036415 --- /dev/null +++ b/tests/chat-route-mocked.test.ts @@ -0,0 +1,296 @@ +/** + * Integration tests for POST /api/chat with a mocked OpenAI client. + * + * These exercise the full server-side pipeline that the pure-function tests + * in chat-route.test.ts cannot reach: they run once the fast-path guards + * (injection, forced photo fallback) have let the request through, and they + * drive the normalize/validate/sanitize layers with real-looking LLM + * outputs. + * + * We inject a fake client via `__setChatClientForTests` rather than mocking + * at the module level, which keeps the tests fast and deterministic with no + * real API key required. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { POST } from '@/app/api/chat/route'; +import { __setChatClientForTests, type ChatCompletionClient } from '@/app/api/chat/openai-client'; +import { __resetRateLimitMapForTests } from '@/app/api/chat/rate-limit'; + +type JsonRecord = Record; + +type CreateArgs = Parameters; + +/** + * Build a fake ChatCompletionClient that returns a pre-canned string content + * and records every call made to it. Supports queuing different responses for + * sequential calls and throwing on command. + */ +function makeFakeClient(responses: Array Promise | never)>): { + client: ChatCompletionClient; + calls: CreateArgs[]; +} { + const calls: CreateArgs[] = []; + let i = 0; + const client: ChatCompletionClient = { + chat: { + completions: { + // Signature matches OpenAI's but we only care about the first arg. + create: (async (...args: CreateArgs) => { + calls.push(args); + const next = responses[Math.min(i, responses.length - 1)]; + i += 1; + if (typeof next === 'function') return next(); + return { + choices: [ + { + message: { content: next, role: 'assistant' as const }, + finish_reason: 'stop' as const, + index: 0, + logprobs: null, + }, + ], + id: 'cmpl-test', + created: 0, + model: 'gpt-4o-mini', + object: 'chat.completion' as const, + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }; + }) as ChatCompletionClient['chat']['completions']['create'], + }, + }, + }; + return { client, calls }; +} + +function jsonRequest(body: unknown): Request { + return new Request('http://localhost/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +async function callPost(body: unknown): Promise<{ status: number; json: JsonRecord }> { + const res = await POST(jsonRequest(body)); + const json = (await res.json()) as JsonRecord; + return { status: res.status, json }; +} + +// Reset state between tests so they're independent regardless of run order. +function reset(): void { + __setChatClientForTests(null); + __resetRateLimitMapForTests(); +} + +// --------------------------------------------------------------------------- +// Happy paths +// --------------------------------------------------------------------------- + +test('POST /api/chat: returns a validated spec card from the LLM', async () => { + reset(); + const { client, calls } = makeFakeClient([ + JSON.stringify({ + intent: 'spec', + mode: 'ui', + ui: { + type: 'card', + component: 'specCard', + data: { + title: 'ATmega328P', + keySpecs: [ + { label: 'Clock', value: '16 MHz' }, + { label: 'Flash', value: '32 KB' }, + ], + }, + }, + text: null, + behavior: { animation: 'slideUp', state: 'collapsed' }, + }), + ]); + __setChatClientForTests(client); + + const { status, json } = await callPost({ + messages: [{ role: 'user', content: 'Tell me about the ATmega328P' }], + }); + + assert.equal(status, 200); + assert.equal(json.mode, 'ui'); + assert.equal((json.ui as JsonRecord).component, 'specCard'); + + // Verify the handler really called into the mock (not the real OpenAI). + assert.equal(calls.length, 1); + const body = calls[0]?.[0] as { model?: string; messages?: unknown[] }; + assert.equal(body.model, 'gpt-4o-mini'); + assert.ok(Array.isArray(body.messages)); +}); + +test('POST /api/chat: voice mode adds a voice.spokenSummary to the response', async () => { + reset(); + const { client } = makeFakeClient([ + JSON.stringify({ + intent: 'explain', + mode: 'ui', + ui: { + type: 'card', + component: 'explanationCard', + data: { + title: 'How PWM Works', + summary: 'Pulse-width modulation simulates analog output.', + keyPoints: ['Varies duty cycle.', 'Used for LED dimming.'], + }, + }, + text: null, + behavior: { animation: 'fadeIn', state: 'open' }, + voice: { spokenSummary: 'PWM varies duty cycle to control average power.' }, + }), + ]); + __setChatClientForTests(client); + + const { status, json } = await callPost({ + messages: [{ role: 'user', content: 'how does PWM work?' }], + inputMode: 'voice', + }); + + assert.equal(status, 200); + const voice = json.voice as JsonRecord | null; + assert.ok(voice, 'voice block should be present'); + assert.equal(typeof voice!.spokenSummary, 'string'); +}); + +// --------------------------------------------------------------------------- +// Fallbacks & error paths +// --------------------------------------------------------------------------- + +test('POST /api/chat: returns FALLBACK_TEXT_RESPONSE when LLM returns unparseable JSON', async () => { + reset(); + const { client } = makeFakeClient(['this is definitely not json, just prose']); + __setChatClientForTests(client); + + const { status, json } = await callPost({ + messages: [{ role: 'user', content: 'hello' }], + }); + + assert.equal(status, 200); + assert.equal(json.mode, 'text'); + assert.equal(typeof json.text, 'string'); +}); + +test('POST /api/chat: rescues a partially-valid payload via toSafeTextResponse', async () => { + reset(); + // Valid JSON but the structured validator will reject it (missing ui.data + // fields). The handler should fall through to `toSafeTextResponse` and + // return a text-mode response built from the `text` field. + const { client } = makeFakeClient([ + JSON.stringify({ + intent: 'quick_answer', + mode: 'ui', + ui: { type: 'card', component: 'specCard' /* no data */ }, + text: 'A 220 ohm resistor works well for a red LED on 5V.', + }), + ]); + __setChatClientForTests(client); + + const { status, json } = await callPost({ + messages: [{ role: 'user', content: 'what resistor for a red LED on 5V?' }], + }); + + assert.equal(status, 200); + assert.equal(json.mode, 'text'); + assert.match(String(json.text), /220/); +}); + +test('POST /api/chat: returns fallback (not 500) when LLM call exceeds timeout', async () => { + reset(); + // Simulate the abort: throw an error with name "AbortError", which is what + // AbortSignal.timeout produces when the signal fires on the real client. + const timeoutThrow = () => { + const err = new Error('The operation was aborted.'); + err.name = 'AbortError'; + throw err; + }; + const { client } = makeFakeClient([timeoutThrow]); + __setChatClientForTests(client); + + // Silence expected warn/error logs for a cleaner CI output. + const originalWarn = console.warn; + const originalError = console.error; + console.warn = () => {}; + console.error = () => {}; + try { + const { status, json } = await callPost({ + messages: [{ role: 'user', content: 'tell me about capacitors' }], + }); + assert.equal(status, 200); + // Either FALLBACK_TEXT_RESPONSE (text mode) or the forced photo + // fallback. For a non-photo request it must be text mode. + assert.equal(json.mode, 'text'); + } finally { + console.warn = originalWarn; + console.error = originalError; + } +}); + +test('POST /api/chat: returns 500 for non-timeout upstream errors', async () => { + reset(); + const throwRandom = () => { + throw new Error('boom'); + }; + const { client } = makeFakeClient([throwRandom]); + __setChatClientForTests(client); + + const originalError = console.error; + console.error = () => {}; + try { + const { status, json } = await callPost({ + messages: [{ role: 'user', content: 'anything' }], + }); + assert.equal(status, 500); + assert.match(String(json.error), /failed to generate/i); + } finally { + console.error = originalError; + } +}); + +test('POST /api/chat: reinforces imageCount > 1 after the LLM returned an imageBlock', async () => { + reset(); + // The LLM returns an imageBlock but with imageCount=1 even though the + // user asked for several. The handler should override to the requested + // count (this is the branch at the bottom of route.ts). + const { client } = makeFakeClient([ + JSON.stringify({ + intent: 'show_image', + mode: 'ui', + ui: { + type: 'image', + component: 'imageBlock', + data: { + imageMode: 'photo', + searchQuery: 'breadboard', + imageCount: 1, + caption: 'A breadboard', + }, + }, + }), + ]); + __setChatClientForTests(client); + + // The message must pass the fast-path gate (PHOTO_REQUEST_HINTS matches + // but we want the LLM to take it, so we deliberately use a hint the + // fast-path photo-query extractor can't resolve. Easiest: no matching + // photo hint at all, so fast path bails and LLM runs. + const { status, json } = await callPost({ + messages: [ + { + role: 'user', + content: 'I want to see several different breadboards for my project.', + }, + ], + }); + + assert.equal(status, 200); + const ui = json.ui as JsonRecord; + const data = ui.data as JsonRecord; + // "several" -> deriveRequestedImageCount === 3; override should have applied. + assert.equal(data.imageCount, 3); +});