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 express = require('express'); const { JSDOM } = require('jsdom'); const { marked } = require('marked'); const policy = require('../src/utils/clinicalConversation'); const answer = require('../src/utils/clinicalAnswer'); const root = path.join(__dirname, '..'); const read = file => fs.readFileSync(path.join(root, file), 'utf8'); const quiet = { log() {}, warn() {}, error() {}, info() {} }; const canonical = (length, fill = 7) => Buffer.alloc(length, fill).toString('base64'); const png = { dataBase64: canonical(16), mimeType: 'image/png', name: 'chest.png' }; const jpeg = { dataBase64: canonical(32), mimeType: 'image/jpeg', name: 'knee.jpeg' }; const asset = '/api/generated-images/12345678-1234-1234-1234-123456789abc'; function server(options = {}) { const calls = { ai: [], search: [], writes: [], images: [] }; let saved; const db = { async getSetting(key) { if (options.dbError) throw Error('private diagnostic'); if (key.endsWith('conversation_chars')) return options.legacyLimit ?? 'broken'; if (key === 'clinical_assistant.translate_provider') return 'libretranslate'; return options.model || null; }, async get(sql, params) { if (sql.includes('COUNT')) return { cnt: 0 }; if (sql.includes('FROM clinical_assistant_chats') && params) return saved && params[0] === 1 && params[1] === 7 ? { id: params[0], title: saved[1], payload: saved[2] } : null; return saved && params[1] === 7 ? { id: 1, title: saved[1], payload: saved[2] } : null; }, async run(sql, params) { calls.writes.push(sql); saved = params; return { lastInsertRowid: 1 }; }, async query(sql, params) { return { rows: (params[0] || []).map(id => ({ id })) }; } }; const ai = async (messages, settings) => { calls.ai.push({ messages, settings }); return { content: 'Complete supported answer. [1]', finishReason: 'stop', model: 'synthetic' }; }; const source = { number: 1, title: 'Synthetic source', excerpt: 'Synthetic reference.', page: 9 }; const mocks = { express, axios: { async post(url, payload) { if (String(url).includes('/translate')) return { data: { translatedText: 'Synthetic translation.' } }; calls.images.push(payload); return { data: { data: [{ b64_json: 'c3ludGhldGlj' }] } }; } }, crypto: require('node:crypto'), '../db/database': db, '../middleware/auth': { authMiddleware() {} }, './auth': { __sendEmail: async () => false }, '../utils/ai': options.ai || { callAI: ai, callAIStream: ai }, '../utils/errors': { gatewayUrl: path => 'http://synthetic.invalid' + path }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {} }, '../utils/crypto': { encryptString: value => 'encrypted:' + value, decryptString: value => value.replace(/^encrypted:/, '') }, '../utils/redis': { async getJson() { return null; }, async setJson() {} }, '../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) }, '../utils/generatedImages': { ...require('../src/utils/generatedImages'), service: () => ({ async get() { return { jobId: 'synthetic', status: 'done', success: true }; }, async enqueue() { return { success: true, jobId: 'synthetic', status: 'pending' }; } }) }, '../utils/generatedImageLinks': require('../src/utils/generatedImageLinks'), '../utils/imageTool': require('../src/utils/imageTool'), '../utils/clinicalPrompts': require('../src/utils/clinicalPrompts'), '../utils/clinicalMcpClient': { async semanticSearch(query) { calls.search.push(query); return {}; }, async getMcpHealth() { calls.health = calls.health || []; calls.health.push('health'); return {}; } }, '../utils/clinicalRetrieval': { cleanSourceExcerpt: require('../src/utils/clinicalRetrieval').cleanSourceExcerpt, normalizeMcpSearchResponse: () => options.noSources ? [] : [source], normalizeMcpMultimodalResponse: () => [], dedupeSources: value => value, isVisualSourceQuery: () => false, classifyAndRerankMultimodalResults: async () => [] }, '../utils/clinicalAnswer': answer, '../utils/clinicalConversation': policy, '../utils/clinicalTranslation': require('../src/utils/clinicalTranslation'), '../utils/patientTakehome': require('../src/utils/patientTakehome') }; const module = { exports: {} }; vm.runInNewContext(read('src/routes/clinicalAssistant.js'), { module, exports: module.exports, console: quiet, Buffer, Map, TextEncoder, process: { env: { CLINICAL_ASSISTANT_MCP_WARMUP: 'false', CLINICAL_ASSISTANT_CONVERSATION_CHARS: options.limit, LITELLM_API_BASE: 'http://synthetic.invalid' } }, setTimeout() {}, require(name) { assert.ok(Object.hasOwn(mocks, name), 'Unexpected import: ' + name); return mocks[name]; } }); async function request(method, endpoint, body, userId = 7) { const handler = module.exports.stack.find(layer => layer.route && layer.route.path === endpoint && layer.route.methods[method]).route.stack.find(layer => layer.method === method).handle; const res = { statusCode: 200, headers: {}, events: '', body: null, status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; return this; }, setHeader(k, v) { this.headers[k] = v; }, write(text) { this.events += text; }, end() {}, flushHeaders() {} }; await handler({ body, params: { id: 1 }, user: { id: userId } }, res); return res; } return { calls, request }; } test('savedChatPayload persists bounded attachments and the generated image; invalid input is rejected before any write', () => { const messages = [{ role: 'user', content: 'Question', attachments: [png, jpeg] }, { role: 'assistant', content: 'Answer [1]', sources: [{ number: 1, title: 'S' }] }]; const payload = policy.savedChatPayload({ messages, lastAnswer: 'Answer [1]', sources: [], generatedImage: asset }); assert.deepEqual(payload.messages[0].attachments, [png, jpeg], 'normalized roundtrip incl. name'); assert.equal(payload.generatedImage, asset, 'sidebar image now persists'); assert.equal(payload.messages[1].attachments, undefined, 'absent attachments stay absent'); const invalid = [ [{ dataBase64: png.dataBase64, mimeType: 'image/svg+xml' }], [{ dataBase64: 'not base64!!!', mimeType: 'image/png' }], [{ dataBase64: canonical(5 * 1024 * 1024 + 1), mimeType: 'image/png' }], [png, jpeg, png, jpeg, png], 'nope' ]; for (const attachments of invalid) { assert.throws(() => policy.savedChatPayload({ messages: [{ role: 'user', content: 'Q', attachments }] }), error => error.statusCode === 400); } const longName = { dataBase64: png.dataBase64, mimeType: png.mimeType, name: 'n'.repeat(400) }; const trimmed = policy.savedChatPayload({ messages: [{ role: 'user', content: 'Q', attachments: [longName] }] }); assert.equal(trimmed.messages[0].attachments[0].name.length, 255, 'names clip at 255 characters'); for (const generatedImage of ['data:image/svg+xml;base64,PHN2Zz4=', 'javascript:alert(1)']) { assert.throws(() => policy.savedChatPayload({ messages: [], generatedImage }), error => error.statusCode === 400); } assert.throws(() => policy.savedChatPayload({ messages: [{ role: 'user', content: 'x'.repeat(policy.MAX_SAVED_CHAT_BYTES) }] }), error => error.statusCode === 413); }); test('POST /chats stores and reopens attachments; the same id updates in place instead of duplicating', async () => { const app = server(); const body = { messages: [{ role: 'user', content: 'Question with image', attachments: [png] }, { role: 'assistant', content: 'Answer [1]' }], lastAnswer: 'Answer [1]', sources: [] }; const saved = await app.request('post', '/clinical-assistant/chats', body); assert.equal(saved.statusCode, 200); const reopened = await app.request('get', '/clinical-assistant/chats/:id', {}); assert.deepEqual(JSON.parse(JSON.stringify(reopened.body.chat.payload.messages[0].attachments)), [png]); const updated = await app.request('post', '/clinical-assistant/chats', { id: 1, messages: [{ role: 'user', content: 'Edited', attachments: [jpeg] }], lastAnswer: '' }); assert.equal(updated.statusCode, 200); assert.equal(updated.body.id, 1); assert.ok(app.calls.writes.some(sql => /UPDATE clinical_assistant_chats/.test(sql)), 'update path used'); const foreign = await app.request('post', '/clinical-assistant/chats', { id: 1, messages: [{ role: 'user', content: 'x' }] }, 8); assert.equal(foreign.statusCode, 404, 'updates are owner-bound'); const missing = await app.request('post', '/clinical-assistant/chats', { id: 99, messages: [{ role: 'user', content: 'x' }] }); assert.equal(missing.statusCode, 404); const badId = await app.request('post', '/clinical-assistant/chats', { id: 'not-a-number', messages: [{ role: 'user', content: 'x' }] }); assert.equal(badId.statusCode, 400); }); test('client restores attachments as thumbnails and saves them plus the generated image', t => { const dom = new JSDOM('