205 lines
12 KiB
JavaScript
205 lines
12 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 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, 'unknown providers are refused before contact');
|
|
// html format passes through to the local provider unchanged
|
|
const htmlStub = axiosStub(() => ({ data: { translatedText: '<p>Ok</p>' } }));
|
|
const html = await translate(baseOptions({ format: 'html', axios: htmlStub.axios }));
|
|
assert.equal(html.translated, '<p>Ok</p>');
|
|
assert.equal(htmlStub.calls[0].payload.format, 'html');
|
|
});
|
|
|
|
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('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 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: { translatedText: '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, 'libretranslate', 'local provider is the only provider');
|
|
assert.match(calls[0], /libretranslate:5000\/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('<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-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: [[]],
|
|
getAuthHeaders: () => ({ 'Content-Type': 'application/json' }),
|
|
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
|
|
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
|
|
saveAssistantChat: async () => ({ success: true }),
|
|
fetchAssistantStatus: async () => ({ success: true, translateProvider: 'libretranslate' }),
|
|
translateAssistantMessage: (message, target, provider, format) => apiFetch('/api/clinical-assistant/translate', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, target, provider, format: format || 'text' }), 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.equal(pop.querySelector('[data-assistant-translate-provider]'), null, 'no provider choice — users pick a language only');
|
|
const es = pop.querySelector('[data-assistant-translate-lang="es"]');
|
|
assert.ok(es);
|
|
es.click();
|
|
await new Promise(r => setImmediate(r));
|
|
const translateCalls = app.calls.filter(c => c.url === '/api/clinical-assistant/translate');
|
|
assert.equal(translateCalls.length, 1);
|
|
assert.equal(translateCalls[0].url, '/api/clinical-assistant/translate');
|
|
const sent = JSON.parse(translateCalls[0].options.body);
|
|
assert.equal(sent.target, 'es');
|
|
assert.equal(sent.provider, 'libretranslate');
|
|
assert.equal(sent.format, 'html');
|
|
assert.match(sent.message, /<p>Chest pain in a four year old\.<\/p>/);
|
|
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.');
|
|
});
|