pediatric-ai-scribe-v3/test/patient-takehome.test.js

255 lines
16 KiB
JavaScript

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('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { 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, /<strong>Rest<\/strong>/, 'take-home renders through the shared markdown pipeline');
assert.match(modal.querySelector('.assistant-takehome-result').innerHTML, /<ul>/, 'bullets render too');
assert.match(modal.querySelector('.assistant-takehome-result').textContent, /Rest and drink fluids/);
assert.ok(modal.querySelector('[data-assistant-takehome-copy]'));
assert.ok(modal.querySelector('[data-assistant-takehome-export]'));
assert.ok(modal.querySelector('[data-assistant-takehome-send]'));
const call = app.calls.find(c => c.url === '/api/clinical-assistant/patient-takehome');
assert.equal(JSON.parse(call.options.body).answer, 'Answer [1].');
});
test('take home refuses honestly when there is no answer yet', async t => {
const app = client(t);
app.document.getElementById('btn-assistant-takehome').click();
await new Promise(r => setImmediate(r));
assert.equal(app.document.getElementById('assistant-takehome-modal'), null);
assert.equal(app.calls.filter(c => c.url === '/api/clinical-assistant/patient-takehome').length, 0);
assert.match(app.toasts[0][0], /Ask a question first/);
});
test('email send succeeds and clears the field; failure shows the server message', async t => {
const app = client(t);
const c = app.context;
c.appendMessage('assistant', 'Answer.', []);
c.lastAnswer = 'Answer.';
app.document.getElementById('btn-assistant-takehome').click();
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
const email = app.document.getElementById('assistant-takehome-email');
email.value = 'parent@example.com';
app.document.querySelector('[data-assistant-takehome-send]').click();
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
assert.equal(email.value, '', 'field cleared after success');
assert.ok(app.toasts.some(t => /sent to parent@example\.com/.test(t[0])));
});
test('Create image dialog: describe it or pick a chat; the latest chat is one tap away', async t => {
const app = client(t);
const c = app.context;
c.fetchSavedAssistantChats = async () => ({ success: true, chats: [{ id: 2, title: 'Older chat', updated_at: new Date().toISOString() }, { id: 9, title: 'Newest chat', updated_at: new Date().toISOString() }] });
await c.loadSavedChats();
app.document.getElementById('btn-assistant-create-image').click();
const modal = app.document.getElementById('assistant-create-image-modal');
assert.ok(modal, 'dialog opens');
assert.ok(modal.querySelector('#create-image-description'));
const options = [...modal.querySelectorAll('#create-image-chat option')].map(o => o.textContent);
assert.deepEqual(options, ['This chat', 'Older chat', 'Newest chat'], 'current chat default, saved chats newest first');
modal.querySelector('#btn-create-image-generate').click();
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
const job = app.calls.find(c => c.url === 'image-job');
assert.ok(job, 'generation requested');
assert.equal(job.prompt, 'Create a pediatric teaching visual from this conversation.');
// picking an older chat loads its messages
app.document.getElementById('btn-assistant-create-image').click();
app.document.getElementById('create-image-chat').value = '2';
app.document.getElementById('btn-create-image-generate').click();
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
const jobs = app.calls.filter(c => c.url === 'image-job');
assert.equal(jobs.length, 2);
assert.equal(jobs[1].history.length, 1);
assert.equal(jobs[1].history[0].role, 'user');
assert.equal(jobs[1].history[0].content, 'Old question', 'generates from the selected chat');
});
test('translate picker filters languages by the local provider availability', async t => {
const app = client(t);
const c = app.context;
c.appendMessage('assistant', 'Answer.', []);
const row = app.document.querySelector('.assistant-msg');
row.querySelector('[data-assistant-msg-translate]').click();
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
const pop = app.document.querySelector('[data-assistant-translate-pop]');
assert.ok(pop, 'picker opened');
const langs = [...pop.querySelectorAll('[data-assistant-translate-lang]')].map(b => b.getAttribute('data-assistant-translate-lang'));
assert.deepEqual(langs, ['en', 'es', 'de'], 'only languages the local LibreTranslate supports are offered');
});