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, requestInterceptor } = require('jsdom'); const { pathToFileURL } = require('node:url'); 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 png = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL9sAAAAASUVORK5CYII='; const quiet = { log() {}, warn() {}, error() {}, info() {} }; function server(options = {}) { const calls = { ai: [], search: [], writes: [], images: [], health: [] }; 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.image_behavior') return options.imageBehavior ?? null; return options.model || null; }, async get(sql, params) { if (sql.includes('COUNT')) return { cnt: 0 }; 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 }; } }; const ai = async (messages, settings) => { calls.ai.push({ messages, settings }); return { content: options.emptyAI ? '' : 'Complete supported answer. [1]', finishReason: Object.hasOwn(options, 'finishReason') ? options.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': options.images || { ...require('../src/utils/generatedImages'), service: () => ({ async get() { return { jobId: 'synthetic', status: 'done', success: true }; }, async enqueue(owner, workflow, input) { calls.images.push({ prompt: require('../src/utils/clinicalPrompts').imagePromptForCanvas(input.prompt, options.imageBehavior) + '\nRequested layout: auto.' }); 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.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, 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('conversation budget validates exact boundaries, Unicode, malformed roles and configuration', () => { assert.equal(policy.conversationLimit(null), 120000); for (const value of ['oops', '999', '1000001', '1.5', NaN, false, [1000], {}]) assert.throws(() => policy.conversationLimit(value)); const history = [{ role: 'user', content: '😀\n'.repeat(333) }]; assert.equal(policy.checkConversation(history, 'x', 1000).budget.used, 1000); assert.throws(() => policy.checkConversation(history, 'xx', 1000), error => error.statusCode === 413); for (const history of [null, {}, [{ role: 'system', content: 'x' }], [{ role: 'user', content: 123 }]]) { assert.throws(() => policy.checkConversation(history, 'x', 1000), error => error.statusCode === 400); } }); test('real chat and stream endpoints reject before all paid/retrieval calls; invalid ENV closes access', async () => { for (const endpoint of ['/clinical-assistant/chat', '/clinical-assistant/chat/stream']) { const app = server({ limit: '1000' }); const result = await app.request('post', endpoint, { message: 'hello', history: [{ role: 'user', content: 'x'.repeat(1001) }] }); assert.equal(result.statusCode, 413); assert.equal(result.body.code, 'CONVERSATION_LIMIT'); assert.equal(result.events, ''); assert.equal(app.calls.ai.length + app.calls.search.length, 0); } for (const options of [{ limit: '999' }, { limit: 'broken' }, { limit: ' ' }]) { const app = server(options); const result = await app.request('post', '/clinical-assistant/chat/stream', { message: 'Question', history: [] }); assert.equal(result.statusCode, 503); assert.equal(app.calls.ai.length + app.calls.search.length, 0); assert.doesNotMatch(JSON.stringify(result.body), /private diagnostic/); } }); test('real accepted route retains early turns and late corrections in rewrite and answer, with fresh retrieval', async () => { const app = server(); const history = Array.from({ length: 12 }, (_, i) => ({ role: i % 2 ? 'assistant' : 'user', content: 'Turn ' + i + '\n' })); history[0].content = 'Original age: 3 years.\n' + 'detail '.repeat(250) + '\nCORRECTION: age 3 months, not years.\n| Dose | Unit |\n|---|---|\n| 0.25 | mg/kg |'; const result = await app.request('post', '/clinical-assistant/chat', { message: 'What about monitoring?', history }); assert.equal(result.statusCode, 200); assert.equal(app.calls.search.length, 1); assert.equal(app.calls.ai.length, 2); for (const call of app.calls.ai) { assert.ok(call.messages[1].content.includes(history[0].content)); assert.ok(call.messages[1].content.includes('Turn 11')); } assert.match(app.calls.ai[1].messages[1].content, /prior AI output is not evidence/); const second = await app.request('post', '/clinical-assistant/chat', { message: 'Explain the differential diagnosis and diagnostic workup in detail for this presentation.', history }); assert.equal(second.statusCode, 200); assert.equal(app.calls.search.length, 2); const long = await server({ noSources: true }).request('post', '/clinical-assistant/chat', { message: 'x'.repeat(4001), history: [] }); assert.equal(long.statusCode, 200, 'The old independent 4,000-character clipping boundary is gone'); }); test('real save/reopen keeps 101 turns, Markdown, Unicode and full source metadata; the sidebar image persists', async () => { const app = server(); const content = ' Preserve indent\n\n| Item | Unit |\n|---|---|\n| 0.25 | mg/kg |\n' + '保留'.repeat(6500); const sources = Array.from({ length: 31 }, (_, index) => ({ number: index + 1, title: 'Title ' + index, excerpt: content, resource: 'source-' + index, page: index + 1 })); const messages = Array.from({ length: 101 }, (_, index) => ({ role: index % 2 ? 'assistant' : 'user', content: index === 0 ? content : 'Turn ' + index, sources: index === 1 ? sources : [] })); const body = { messages, sources, lastAnswer: content, generatedImage: png }; 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)), messages); assert.equal(reopened.body.chat.payload.lastAnswer, content); assert.equal(reopened.body.chat.payload.generatedImage, png, 'The sidebar image persists with the chat'); assert.equal(reopened.body.chat.payload.sources.length, 31); assert.equal((await app.request('get', '/clinical-assistant/chats/:id', {}, 8)).statusCode, 404); const oversized = await app.request('post', '/clinical-assistant/chats', { messages: [{ role: 'user', content: 'x'.repeat(policy.MAX_SAVED_CHAT_BYTES) }] }); assert.equal(oversized.statusCode, 413); const invalidImage = await app.request('post', '/clinical-assistant/chats', { messages: [], generatedImage: 'data:image/svg+xml;base64,PHN2Zz4=' }); assert.equal(invalidImage.statusCode, 400); for (const generatedImage of ['data:image/png;base64,SGVsbG8=', png.replace('image/png', 'image/jpeg')]) { assert.equal((await app.request('post', '/clinical-assistant/chats', { messages: [], generatedImage })).statusCode, 400); } assert.equal(app.calls.writes.length, 1, 'Rejected saves must not insert truncated records'); }); test('handoff route is gone; save/download wording covers the budget limit', async () => { const app = server(); await assert.rejects(app.request('post', '/clinical-assistant/handoff', { history: [{ role: 'user', content: 'Known facts.' }] }), /route/, 'route layer no longer exists'); assert.throws(() => policy.checkConversation([{ role: 'user', content: 'x' }], 'y'.repeat(1000), 1000), error => error.statusCode === 413 && !/handoff/i.test(error.message)); }); function browserUI(options = {}) { const dom = new JSDOM('