// ============================================================ // AI ENDPOINT CONTRACT TESTS — hit the REAL server handlers // ============================================================ // The UI smoke tests mock AI responses via page.route() so they never // exercise the server-side handler. That meant a route whose require() // statement was wrong (undefined PROMPTS → 500) shipped to prod without // any test failing. This spec calls each AI generation endpoint // through the Playwright request fixture (bypasses page.route) with a // minimal valid payload and only checks the server didn't crash with a // ReferenceError / TypeError. A mock model is installed on the server // side via MOCK_AI=1 env var (if configured) so we don't spend API // credits; otherwise the server still processes the request and returns // a structured error (which is fine — we're guarding against 500s from // bad imports, not end-to-end AI generation). // // A non-500 response (200 OK or 4xx with structured JSON) means the // handler at least ran — that's the contract we're verifying. // ============================================================ const { test, expect, E2E_BASE, getAuthToken } = require('../fixtures'); test.describe('AI endpoint contracts — handler loads + accepts POST', () => { let token; test.beforeAll(async ({ request }) => { token = await getAuthToken(request); }); // Each entry: path, minimal body that should make the handler run past // its import statements. We don't need a valid AI key — a 500 from // a require() bug will still fail, but a 4xx from "missing API key" // is acceptable because it proves the route loaded. const endpoints = [ { path: '/api/generate-pe-narrative', body: { steps: [{ component: 'Inspection', label: 'General', method: 'Observed', status: 'normal' }], ageGroup: 'School-Age (6-11 yr)', system: 'neuro', patientAge: '8 years', patientGender: 'male', format: 'narrative', }, }, { path: '/api/generate-milestone-narrative', body: { milestones: [{ domain: 'Motor', label: 'Walks', status: 'achieved' }], ageGroup: '12 months', patientAge: '12 months', patientGender: 'male', }, }, { path: '/api/generate-hpi-encounter', body: { transcript: 'Patient with cough for 3 days.', setting: 'outpatient' }, }, { path: '/api/sick-visit/note', body: { chiefComplaint: 'Cough', transcript: 'Cough x 3 days.' }, }, { path: '/api/generate-soap', body: { transcript: 'Patient with cough x 3 days.' }, }, { path: '/api/generate-chart-review', body: { pmh: 'None', outpatientVisits: [], edVisits: [], subspecialtyVisits: [], labs: [] }, }, { path: '/api/refine', body: { currentDocument: 'MOCK doc', instructions: 'Add severity.' }, }, { path: '/api/well-visit/shadess', body: { answers: { home: 'lives with parents' } }, }, ]; // Signatures of runtime bugs that mean the handler crashed before it // could reach its try/catch (i.e. the exact class of bug being guarded). const CRASH_SIGNATURES = [ /Cannot read properties of undefined/i, /is not a function/i, /is not defined/i, /ReferenceError/i, /TypeError/i, ]; for (const { path, body } of endpoints) { test(`${path} — handler loads, returns structured JSON, no import-bug crash`, async ({ request }) => { const r = await request.post(E2E_BASE + path, { headers: { Authorization: 'Bearer ' + token }, data: body, }); // Must always be JSON — a 500 HTML page means the express error handler // caught an unhandled exception (our bug class). let json = null; try { json = await r.json(); } catch (_) { /* remains null */ } expect(json, `${path} did not return JSON (HTTP ${r.status()})`).toBeTruthy(); // If the response leaked a JS error message through to the client, // that's the import/destructure-bug signature we want to catch. const errText = (json && (json.error || json.message)) || ''; for (const sig of CRASH_SIGNATURES) { expect(errText, `${path} leaked a runtime error: ${errText}`).not.toMatch(sig); } // Errors should be plain strings — never raw error objects. if (json && json.success === false) { expect(typeof json.error).toBe('string'); } }); } });