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 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() {} }; async function translate(options) { const { translateMessage } = require('../src/utils/clinicalTranslation'); return translateMessage(options); } function baseOptions(overrides = {}) { return { message: 'Chest pain in a four year old.', target: 'es', userId: 7, env: { LIBRETRANSLATE_URL: 'http://libretranslate:5000', DEEPL_API_KEY: 'synthetic-key', DEEPL_API_BASE: 'https://api.deepl.com/v2' }, getSetting: async () => null, cache: new Map(), ...overrides }; } function axiosStub(behavior) { const calls = []; return { calls, axios: { post: async (url, payload, opts) => { calls.push({ url: String(url), payload, opts }); return behavior({ url: String(url), payload, opts }, calls.length); } } }; } test('translation validates message, target and provider before any provider contact', async () => { for (const target of ['xx', '', 'xy', 'javascript']) { const stub = axiosStub(() => { throw new Error('must not be called'); }); await assert.rejects(translate(baseOptions({ target, axios: stub.axios })), e => e.statusCode === 400); assert.equal(stub.calls.length, 0); } await assert.rejects(translate(baseOptions({ message: '' })), e => e.statusCode === 400); await assert.rejects(translate(baseOptions({ message: 'x'.repeat(20001) })), e => e.statusCode === 400); const stub = axiosStub(() => { throw new Error('must not be called'); }); await assert.rejects(translate(baseOptions({ provider: 'google', axios: stub.axios })), e => e.statusCode === 400); assert.equal(stub.calls.length, 0); }); test('libretranslate is the local-first default and caches per provider+message+lang', async () => { const stub = axiosStub(() => ({ data: { translatedText: 'Synthetic Spanish.' } })); const cache = new Map(); const opts = baseOptions({ axios: stub.axios, cache }); const first = await translate(opts); assert.equal(first.translated, 'Synthetic Spanish.'); assert.equal(first.provider, 'libretranslate'); assert.equal(stub.calls.length, 1); assert.match(stub.calls[0].url, /^http:\/\/libretranslate:5000\/translate$/); assert.deepEqual(stub.calls[0].payload, { q: 'Chest pain in a four year old.', source: 'auto', target: 'es', format: 'text' }); await translate(opts); assert.equal(stub.calls.length, 1, 'cache hit'); // a different language or provider is a separate cache entry await translate(baseOptions({ axios: stub.axios, cache, target: 'fr' })); assert.equal(stub.calls.length, 2); }); test('deepL posts form-encoded with the auth key header and uppercase target', async () => { const stub = axiosStub(({ url, payload, opts }) => { assert.equal(url, 'https://api.deepl.com/v2/translate'); assert.equal(opts.headers.Authorization, 'DeepL-Auth-Key synthetic-key'); assert.match(payload, /^text=Chest%20pain/); assert.match(payload, /target_lang=ES$/); return { data: { translations: [{ text: 'Dolor de pecho.' }] } }; }); const result = await translate(baseOptions({ provider: 'deepl', axios: stub.axios })); assert.equal(result.translated, 'Dolor de pecho.'); assert.equal(result.provider, 'deepl'); }); test('deepl refuses languages it cannot map and is never contacted for them', async () => { const stub = axiosStub(() => { throw new Error('must not be called'); }); await assert.rejects(translate(baseOptions({ provider: 'deepl', target: 'fa', axios: stub.axios })), e => e.statusCode === 400); assert.equal(stub.calls.length, 0); // libretranslate still accepts the same language const ok = axiosStub(() => ({ data: { translatedText: 'ترجمه' } })); const result = await translate(baseOptions({ target: 'fa', axios: ok.axios })); assert.equal(result.provider, 'libretranslate'); }); test('transient provider failure falls back once; validation and 4xx never fall back', async () => { const stub = axiosStub(({ url }) => { if (url.startsWith('http://libretranslate')) throw new Error('ECONNREFUSED'); return { data: { translations: [{ text: 'DeepL fallback.' }] } }; }); const result = await translate(baseOptions({ axios: stub.axios })); assert.equal(result.translated, 'DeepL fallback.'); assert.equal(result.provider, 'deepl'); assert.equal(stub.calls.length, 2); const fourxx = axiosStub(({ url }) => { if (url.startsWith('http://libretranslate')) { const err = new Error('Bad request'); err.response = { status: 400 }; throw err; } throw new Error('must not fall back'); }); await assert.rejects(translate(baseOptions({ axios: fourxx.axios })), e => e.statusCode === 400 && /Bad request/.test(e.message)); assert.equal(fourxx.calls.length, 1, 'no fallback on provider 4xx'); const deepL403 = axiosStub(({ url }) => { if (url.startsWith('https://api.deepl')) { const err = new Error('DeepL auth failed'); err.response = { status: 403 }; throw err; } throw new Error('must not fall back'); }); await assert.rejects(translate(baseOptions({ provider: 'deepl', axios: deepL403.axios })), e => e.statusCode === 403); assert.equal(deepL403.calls.length, 1, 'no fallback on DeepL 403'); // deepl down (429) falls back to local libretranslate const quota = axiosStub(({ url }) => { if (url.startsWith('https://api.deepl')) { const err = new Error('Too many requests'); err.response = { status: 429 }; throw err; } return { data: { translatedText: 'Local fallback.' } }; }); const back = await translate(baseOptions({ provider: 'deepl', axios: quota.axios })); assert.equal(back.provider, 'libretranslate'); assert.equal(back.translated, 'Local fallback.'); }); test('deepl without configuration surfaces 503 and never falls back when explicitly requested', async () => { const stub = axiosStub(() => { throw new Error('must not be called'); }); await assert.rejects(translate(baseOptions({ provider: 'deepl', env: { LIBRETRANSLATE_URL: 'http://libretranslate:5000' }, axios: stub.axios })), e => e.statusCode === 503 && e.code === 'DEEPL_NOT_CONFIGURED'); assert.equal(stub.calls.length, 0); // default libretranslate down + deepl unconfigured → provider-side 502 const down = axiosStub(() => { throw new Error('ECONNREFUSED'); }); await assert.rejects(translate(baseOptions({ env: { LIBRETRANSLATE_URL: 'http://libretranslate:5000' }, axios: down.axios })), e => e.statusCode === 502); }); test('translate route is owner-bound, validated and cached; admin default provider is honored', async () => { const module = { exports: {} }; const calls = []; const db = { async getSetting(key) { return key === 'clinical_assistant.translate_provider' ? 'deepl' : null; }, async get() { return null; }, async run() { return { lastInsertRowid: 1 }; }, async query() { return { rows: [] }; } }; const mocks = { express, axios: { async post(url, payload) { calls.push(String(url)); return { data: { translations: [{ text: 'Traducción sintética.' }] } }; } }, crypto: require('node:crypto'), '../db/database': db, '../middleware/auth': { authMiddleware() {} }, '../utils/ai': { callAI: async () => ({}), callAIStream: async () => ({}) }, '../utils/errors': { gatewayUrl: p => 'http://synthetic.invalid' + p }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {} }, '../utils/crypto': { encryptString: v => 'encrypted:' + v, decryptString: v => v.replace(/^encrypted:/, '') }, '../utils/redis': { async getJson() { return null; }, async setJson() {} }, '../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) }, '../utils/generatedImages': { ...require('../src/utils/generatedImages'), service: () => ({ async get() { return {}; }, async enqueue() { return {}; } }) }, '../utils/generatedImageLinks': require('../src/utils/generatedImageLinks'), '../utils/imageTool': require('../src/utils/imageTool'), '../utils/clinicalPrompts': require('../src/utils/clinicalPrompts'), '../utils/clinicalMcpClient': { async semanticSearch() { return {}; }, async getMcpHealth() { return {}; } }, '../utils/clinicalRetrieval': { cleanSourceExcerpt: require('../src/utils/clinicalRetrieval').cleanSourceExcerpt, normalizeMcpSearchResponse: () => [], normalizeMcpMultimodalResponse: () => [], dedupeSources: v => v, isVisualSourceQuery: () => false, classifyAndRerankMultimodalResults: async () => [] }, '../utils/clinicalAnswer': answer, '../utils/clinicalConversation': require('../src/utils/clinicalConversation'), '../utils/clinicalTranslation': require('../src/utils/clinicalTranslation'), '../utils/patientTakehome': require('../src/utils/patientTakehome') }; vm.runInNewContext(read('src/routes/clinicalAssistant.js'), { module, exports: module.exports, console: quiet, Buffer, Map, TextEncoder, process: { env: { CLINICAL_ASSISTANT_MCP_WARMUP: 'false', DEEPL_API_KEY: 'synthetic-key', DEEPL_API_BASE: 'https://api.deepl.com/v2' } }, setTimeout() {}, require(name) { assert.ok(Object.hasOwn(mocks, name), 'Unexpected import: ' + name); return mocks[name]; } }); async function request(body) { const handler = module.exports.stack.find(layer => layer.route && layer.route.path === '/clinical-assistant/translate' && layer.route.methods.post).route.stack.find(layer => layer.method === 'post').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 } }, res); return res; } const ok = await request({ message: 'Chest pain.', target: 'es' }); assert.equal(ok.statusCode, 200); assert.equal(ok.body.translated, 'Traducción sintética.'); assert.equal(ok.body.provider, 'deepl', 'admin default provider honored'); assert.match(calls[0], /api\.deepl\.com\/v2\/translate$/); const cached = await request({ message: 'Chest pain.', target: 'es' }); assert.equal(calls.length, 1, 'route cache hit'); const bad = await request({ message: 'Chest pain.', target: 'nope' }); assert.equal(bad.statusCode, 400); assert.equal(calls.length, 1, 'validation never contacts a provider'); }); 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-rendering-owner' }, true), true); window.marked = marked; window.DOMPurify = require('dompurify')(window); window.matchMedia = () => ({ matches: true }); const calls = []; const toasts = []; const apiFetch = async (url, options) => { calls.push({ url, options }); if (url === '/api/clinical-assistant/translate') { const body = JSON.parse(options.body); if (options.failEs || body.target === 'bad') return new Response(JSON.stringify({ error: 'Unsupported translation language.' }), { status: 400 }); return new Response(JSON.stringify({ success: true, translated: 'Traducción de "' + body.message + '"', provider: body.provider })); } return new Response(JSON.stringify({ success: true, chats: [] })); }; const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController, 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: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, target, provider }), failEs: options.failEs }).then(function(r) { return r.json(); }), 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 }; } test('per-message translate picker offers a provider choice and leaves the raw transcript untouched', async t => { const app = client(t); const c = app.context; c.appendMessage('assistant', 'Chest pain in a four year old.', []); const row = app.document.querySelector('.assistant-msg.assistant'); const translateBtn = row.querySelector('[data-assistant-msg-translate]'); translateBtn.click(); const pop = app.document.querySelector('[data-assistant-translate-pop]'); assert.ok(pop, 'language picker opens'); assert.ok(pop.querySelector('[data-assistant-translate-provider="libretranslate"]')); const deepl = pop.querySelector('[data-assistant-translate-provider="deepl"]'); assert.ok(deepl); deepl.click(); const es = pop.querySelector('[data-assistant-translate-lang="es"]'); assert.ok(es); es.click(); await new Promise(r => setImmediate(r)); assert.equal(app.calls.length, 1); assert.equal(app.calls[0].url, '/api/clinical-assistant/translate'); assert.deepEqual(JSON.parse(app.calls[0].options.body), { message: 'Chest pain in a four year old.', target: 'es', provider: 'deepl' }); const bubble = row.querySelector('.assistant-bubble'); assert.match(bubble.textContent, /Traducción de "Chest pain in a four year old\."/); assert.equal(c.messages[0].content, 'Chest pain in a four year old.', 'canonical transcript unchanged'); const showOriginal = bubble.querySelector('[data-assistant-msg-show-original]'); assert.ok(showOriginal); showOriginal.click(); assert.match(bubble.innerHTML, /Chest pain in a four year old\./); assert.equal(bubble.querySelector('[data-assistant-msg-show-original]'), null, 'back to the original view'); }); test('translation failures toast once and keep the original rendering', async t => { const app = client(t, { failEs: true }); const c = app.context; c.appendMessage('assistant', 'Original content.', []); const row = app.document.querySelector('.assistant-msg.assistant'); row.querySelector('[data-assistant-msg-translate]').click(); const pop = app.document.querySelector('[data-assistant-translate-pop]'); pop.querySelector('[data-assistant-translate-lang="es"]').click(); await new Promise(r => setImmediate(r)); assert.equal(app.toasts.length, 1); assert.match(app.toasts[0][0], /Unsupported translation language/); assert.match(row.querySelector('.assistant-bubble').innerHTML, /Original content\./); assert.equal(c.messages[0].content, 'Original content.'); });