const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const { JSDOM } = require('jsdom'); const { marked } = require('marked'); const { webcrypto } = require('node:crypto'); const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8'); // ── Server: generate + email routes ──────────────────────────────────────── function server(t, overrides = {}) { const module = { exports: {} }; const emailCalls = []; const aiCalls = []; const settings = Object.assign({ 'clinical_assistant.chat_model': 'synthetic-chat', 'models.default': '', 'clinical_assistant.system_behavior': '', 'clinical_assistant.search_limit': '8', 'clinical_assistant.context_chars': '1400', 'clinical_assistant.image_model': 'openai-gpt-image-1', 'clinical_assistant.patient_takehome_behavior': '' }, overrides.settings || {}); const quiet = { log() {}, error() {}, warn() {}, audit() {} }; const mocks = { express: require('express'), axios: { get: async () => { throw new Error('unexpected axios call'); }, post: async () => { throw new Error('unexpected axios call'); } }, '../db/database': { get: async () => null, getSetting: async key => settings[key], query: async () => ({ rows: [] }) }, '../middleware/auth': { authMiddleware: (req, res, next) => next() }, '../utils/ai': { callAI: async (messages, options) => { aiCalls.push({ messages, options }); return { content: 'Take home [1] text.', model: 'synthetic-chat' }; }, callAIStream: async () => { throw new Error('unexpected'); }, activeProvider: 'synthetic', discoverModels: async () => [], vertexClient: null, litellmClient: null, applyImageAttachments: x => x }, '../utils/generatedImages': { workflows: ['clinical_assistant'], snapshot: async () => ({}), enqueue: async () => ({}), tick: async () => {}, ready: async () => {} }, '../utils/imageTool': { tools: [], dispatch: async x => x }, '../utils/generatedImageLinks': { validateChat: () => null }, '../utils/logger': quiet, '../utils/crypto': { randomUUID: () => 'synthetic-uuid', encryptString: s => 'enc:' + s, decryptString: s => s.replace(/^enc:/, ''), encryptBuffer: b => b, decryptBuffer: b => b }, '../utils/redis': { get: async () => null, set: async () => {} }, '../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({ ready: async () => {} }) }, '../utils/clinicalMcpClient': { search: async () => ({ sources: [] }), multimodal: async () => ({ sources: [] }), warmup: async () => {} }, '../utils/clinicalRetrieval': { cleanSourceExcerpt: s => s, normalizeMcpSearchResponse: () => [], normalizeMcpMultimodalResponse: () => [], dedupeSources: v => v, isVisualSourceQuery: () => false, classifyAndRerankMultimodalResults: async () => [] }, '../utils/clinicalAnswer': require('../src/utils/clinicalAnswer'), '../utils/clinicalConversation': require('../src/utils/clinicalConversation'), '../utils/clinicalTranslation': require('../src/utils/clinicalTranslation'), '../utils/patientTakehome': require('../src/utils/patientTakehome'), '../utils/clinicalPrompts': require('../src/utils/clinicalPrompts'), './auth': { __sendEmail: async (to, subject, html) => { emailCalls.push({ to, subject, html }); return overrides.smtpConfigured !== false; } }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) } }; vm.runInNewContext(read('src/routes/clinicalAssistant.js'), { module, exports: module.exports, console: quiet, Buffer, Map, TextEncoder, process: { env: Object.assign({ CLINICAL_ASSISTANT_MCP_WARMUP: 'false' }, overrides.env || {}) }, setTimeout() {}, require(name) { assert.ok(Object.hasOwn(mocks, name), 'Unexpected import: ' + name); return mocks[name]; } }); async function request(method, routePath, body) { const layer = module.exports.stack.find(layer => layer.route && layer.route.path === routePath && layer.route.methods[method]); assert.ok(layer, 'route missing: ' + method + ' ' + routePath); const handler = layer.route.stack.find(layer => layer.method === method).handle; const res = { statusCode: 200, headers: {}, body: null, status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; return this; }, setHeader() {}, write() {}, end() {}, flushHeaders() {} }; await handler({ body, user: { id: 7 }, ip: 'synthetic' }, res); return res; } return { request, aiCalls, emailCalls, quiet }; } test('POST /clinical-assistant/patient-takehome rewrites the answer in plain language without citations', async t => { const s = server(t); const res = await s.request('post', '/clinical-assistant/patient-takehome', { answer: 'Dose: 10 mg/kg [1].' }); assert.equal(res.statusCode, 200); assert.equal(res.body.success, true); assert.equal(res.body.text, 'Take home text.', 'citation tokens are scrubbed from the result'); assert.equal(s.aiCalls.length, 1); assert.match(s.aiCalls[0].messages[0].content, /plain language/, 'default patient-language behavior'); assert.match(s.aiCalls[0].messages[1].content, /Dose: 10 mg\/kg/); assert.equal(s.aiCalls[0].options.model, 'synthetic-chat', 'chat model resolved from settings'); }); test('patient take-home refuses empty answers without contacting the model', async t => { const s = server(t); const res = await s.request('post', '/clinical-assistant/patient-takehome', { answer: ' ' }); assert.equal(res.statusCode, 400); assert.equal(s.aiCalls.length, 0); }); test('patient take-home honors an admin behavior override', async t => { const s = server(t, { settings: { 'clinical_assistant.patient_takehome_behavior': 'Custom parent-friendly rewrite.' } }); const res = await s.request('post', '/clinical-assistant/patient-takehome', { answer: 'Anything.' }); assert.equal(res.statusCode, 200); assert.match(s.aiCalls[0].messages[0].content, /Custom parent-friendly rewrite/); }); test('POST /clinical-assistant/patient-takehome/email validates the address and the body', async t => { const s = server(t); const badEmail = await s.request('post', '/clinical-assistant/patient-takehome/email', { to: 'not-an-email', text: 'Take home' }); assert.equal(badEmail.statusCode, 400); assert.equal(s.emailCalls.length, 0); const badBody = await s.request('post', '/clinical-assistant/patient-takehome/email', { to: 'parent@example.com', text: ' ' }); assert.equal(badBody.statusCode, 400); assert.equal(s.emailCalls.length, 0); }); test('patient take-home email reports honestly when SMTP is not configured', async t => { const s = server(t, { smtpConfigured: false }); const res = await s.request('post', '/clinical-assistant/patient-takehome/email', { to: 'parent@example.com', text: 'Take home' }); assert.equal(res.statusCode, 503); assert.equal(res.body.code, 'SMTP_NOT_CONFIGURED'); }); test('patient take-home email sends plain text wrapped in a simple caregiver note', async t => { const s = server(t); const res = await s.request('post', '/clinical-assistant/patient-takehome/email', { to: 'parent@example.com', text: 'Give fluids & rest.' }); assert.equal(res.statusCode, 200); assert.equal(s.emailCalls.length, 1); assert.equal(s.emailCalls[0].to, 'parent@example.com'); assert.match(s.emailCalls[0].subject, /Patient Take Home/); assert.match(s.emailCalls[0].html, /Give fluids & rest\./, 'HTML-escaped plain text'); }); // ── Frontend: button → modal → copy/export/email ─────────────────────────── function client(t, options = {}) { const dom = new JSDOM('
' + read('public/components/assistant.html') + '
', { url: 'https://example.test', runScripts: 'outside-only' }); const window = dom.window; window.eval(read('public/js/accountBoundary.js')); assert.equal(window.AccountBoundary.enter({ id: 'synthetic-takehome-owner' }, true), true); window.marked = marked; window.DOMPurify = require('dompurify')(window); window.matchMedia = () => ({ matches: true }); const calls = []; const toasts = []; const copies = []; const downloads = []; window.URL.createObjectURL = blob => { downloads.push(blob); return 'blob:synthetic'; }; window.URL.revokeObjectURL = () => {}; const apiFetch = async (url, options) => { calls.push({ url, options }); if (url === '/api/clinical-assistant/patient-takehome') { return new Response(JSON.stringify({ success: true, text: options.takehomeText || '**Rest** and drink fluids.\n\n- Give medicine as directed' })); } if (url === '/api/clinical-assistant/patient-takehome/email') { if (options.failEmail) return new Response(JSON.stringify({ error: 'Email is not configured on this server yet' }), { status: 503 }); return new Response(JSON.stringify({ success: true })); } if (url === '/api/clinical-assistant/translate/languages') { return new Response(JSON.stringify({ success: true, languages: { libretranslate: ['en', 'es', 'de'], deepl: ['en', 'es', 'fr'] } })); } return new Response(JSON.stringify({ success: true, chats: [] })); }; const context = { window, document: window.document, console, URL: window.URL, Blob, TextDecoder, AbortController, crypto: webcrypto, setTimeout() {}, clearTimeout() {}, showToast: (...args) => toasts.push(args), EMPTY_PROMPT_SETS: [[]], createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }), fetchSavedAssistantChats: async () => ({ success: true, chats: [] }), saveAssistantChat: async () => ({ success: true }), fetchAssistantStatus: async () => ({ success: true, translateProvider: 'libretranslate' }), translateAssistantMessage: (message, target, provider) => apiFetch('/api/clinical-assistant/translate', { method: 'POST', headers: {}, body: JSON.stringify({ message, target, provider }) }).then(r => r.json()), getAuthHeaders: () => ({ 'X-Synthetic': '1' }), fetchSavedAssistantChat: async id => ({ success: true, chat: { id, payload: { version: 2, messages: [{ role: 'user', content: 'Old question' }] } } }), startAssistantImageJob: async (prompt, history) => { calls.push({ url: 'image-job', prompt, history }); return { success: true, jobId: 'job-1' }; }, fetch: apiFetch }; vm.createContext(context); for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) { vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context); } context.bindEvents(); t.after(() => window.close()); return { context, document: window.document, window, calls, toasts, copies, downloads }; } test('Patient take home button opens the modal, generates, and offers Copy/Export/Send', async t => { const app = client(t); const c = app.context; c.appendMessage('assistant', 'Answer [1].', []); c.lastAnswer = 'Answer [1].'; app.document.getElementById('btn-assistant-takehome').click(); await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r)); const modal = app.document.getElementById('assistant-takehome-modal'); assert.ok(modal, 'modal opened'); assert.match(modal.querySelector('.assistant-takehome-result').innerHTML, /Rest<\/strong>/, 'take-home renders through the shared markdown pipeline'); assert.match(modal.querySelector('.assistant-takehome-result').innerHTML, /