pediatric-ai-scribe-v3/test/clinical-conversation.test.js

434 lines
26 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, 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('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', {
url: 'https://example.test/',
resources: { interceptors: [requestInterceptor(request => {
assert.equal(new URL(request.url).pathname, '/css/assistant.css');
return new Response(read('public/css/assistant.css'), { headers: { 'Content-Type': 'text/css' } });
})] }
});
const calls = { stream: [], save: [] };
const escapeHtml = text => String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const context = {
window: dom.window, document: dom.window.document, navigator: dom.window.navigator,
console: quiet, AbortController, TextDecoder, TextEncoder, URL, Blob, crypto: require("node:crypto").webcrypto,
setTimeout() {}, showToast() {}, escapeHtml, escapeAttr: escapeHtml,
renderAssistantMarkdown: text => escapeHtml(text), renderSourcesList: () => '', ...options.renderers, EMPTY_PROMPT_SETS: [[]],
createAssistantExporter: () => ({ invalidate() {}, exportAnswerPdf() {} }),
createAssistantImageStore: () => ({ renderGeneratedImage: src => '<img src="' + src + '">', clear() {} }),
isImageRequest: () => false,
fetchAssistantStatus: async () => ({ success: true, conversationChars: options.limit || 120000 }),
fetchAssistantExamples: async () => ({}), fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
openAssistantStream: async payload => {
calls.stream.push(payload);
if (options.stream) return options.stream(payload);
return new Response('event: done\ndata: ' + JSON.stringify({ success: true, answer: 'Complete response.', sources: [] }) + '\n\n');
},
saveAssistantChat: async payload => { calls.save.push(payload); return { success: true }; }
};
vm.createContext(context);
vm.runInContext(read('public/js/clinicalAssistant.js').replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, ''), context);
context.bindEvents();
context.conversationChars = options.limit || 120000;
return { context, document: dom.window.document, calls, dom };
}
test('actual UI blocks over-budget input without clearing draft/history', async () => {
const ui = browserUI({ limit: 1000 });
ui.context.restoreSavedChat({ messages: [{ role: 'user', content: 'x'.repeat(999) }] });
ui.document.getElementById('assistant-input').value = 'xx';
await ui.context.onAsk();
assert.equal(ui.calls.stream.length, 0);
assert.equal(ui.context.messages.length, 1);
assert.equal(ui.document.getElementById('assistant-input').value, 'xx');
assert.equal(ui.document.getElementById('assistant-context-warning').hidden, false);
ui.dom.window.close();
});
test('actual UI sends complete prior history exactly once and saves the sidebar image', async () => {
const ui = browserUI();
const messages = Array.from({ length: 20 }, (_, i) => ({ role: i % 2 ? 'assistant' : 'user', content: 'Turn ' + i + (i === 0 ? 'x'.repeat(1500) : ''), sources: [] }));
ui.context.restoreSavedChat({ messages, lastAnswer: 'Previous answer.', generatedImage: png });
ui.document.getElementById('assistant-input').value = 'New question';
await ui.context.onAsk();
assert.equal(ui.calls.stream[0].history.length, 20);
assert.equal(ui.calls.stream[0].history[0].content, messages[0].content);
assert.equal(ui.context.messages.length, 22);
assert.equal(ui.context.messages[20].content, 'New question');
assert.equal(ui.document.getElementById('assistant-input').value, '');
ui.context.performAutosave();
assert.equal(ui.calls.save[0].generatedImage, png);
assert.equal(ui.calls.save[0].messages.length, 22);
assert.equal(ui.context.messages.length, 22);
ui.dom.window.close();
});
test('actual UI preserves draft on authoritative server rejection and ignores a late cancelled response', async () => {
const ui = browserUI({ stream: () => new Response(JSON.stringify({ error: 'Limit reached', code: 'CONVERSATION_LIMIT', budget: { limit: 1000 } }), { status: 413 }) });
ui.document.getElementById('assistant-input').value = 'Draft must survive';
await ui.context.onAsk();
assert.equal(ui.context.messages.length, 0);
assert.equal(ui.document.getElementById('assistant-input').value, 'Draft must survive');
assert.equal(ui.context.conversationChars, 1000);
ui.dom.window.close();
let release;
const delayed = browserUI({ stream: () => new Promise(resolve => { release = resolve; }) });
delayed.document.getElementById('assistant-input').value = 'Pending question';
const request = delayed.context.onAsk();
delayed.context.clearConversation();
release(new Response('event: done\ndata: {"success":true,"answer":"Old answer"}\n\n'));
await request;
assert.equal(delayed.context.messages.length, 0);
assert.doesNotMatch(delayed.document.getElementById('assistant-messages').textContent, /Old answer/);
delayed.dom.window.close();
});
test('dose follow-up rewrites from the drug/diagnosis AND a separate late age correction', async () => {
for (const endpoint of ['/clinical-assistant/chat', '/clinical-assistant/chat/stream']) {
const app = server();
const history = [
{ role: 'user', content: 'Discuss acyclovir for suspected neonatal herpes in a three-month-old.' },
{ role: 'assistant', content: 'Confirm the age before deciding on therapy.' },
{ role: 'user', content: 'Correction: three weeks, not three months.' }
];
const response = await app.request('post', endpoint, { history, message: 'dose?' });
assert.equal(response.statusCode, 200);
assert.equal(app.calls.ai.length, 2, 'Rewrite must not shortcut to only the latest turn');
for (const turn of history) assert.ok(app.calls.ai[0].messages[1].content.includes(turn.content));
assert.ok(app.calls.ai[0].messages[1].content.includes('dose?'));
assert.equal(app.calls.search[0], 'Complete supported answer. [1]', 'Retrieval uses the full-history rewrite result');
}
});
test('real save/reopen and UI renderer contain malicious legacy numbers without changing citation identity', async () => {
const renderers = {
...await import(pathToFileURL(path.join(root, 'public/js/assistant/citations.js')).href),
...await import(pathToFileURL(path.join(root, 'public/js/assistant/sources.js')).href)
};
const ui = browserUI({ renderers });
ui.dom.window.DOMPurify = require('dompurify')(ui.dom.window);
const app = server();
const malicious = '1"><img src=x onerror=alert(1)><span data-injected="yes';
const sources = [{ number: '1', title: 'Valid legacy string' }, { number: malicious, title: 'Legacy markup' }];
const body = { messages: [{ role: 'assistant', content: 'Retained citation [1].', sources }], sources };
assert.equal((await app.request('post', '/clinical-assistant/chats', body)).statusCode, 200);
const reopened = await app.request('get', '/clinical-assistant/chats/:id', {});
assert.equal(reopened.statusCode, 200);
const payload = reopened.body.chat.payload;
assert.equal(payload.sources[0].number, '1');
assert.equal(payload.sources[1].number, malicious);
assert.equal(payload.messages[0].sources[1].number, malicious);
ui.context.restoreSavedChat(payload);
const cards = ui.document.querySelectorAll('.assistant-source');
assert.equal(cards.length, 2);
assert.equal(cards[1].id, 'assistant-source-' + malicious);
assert.equal(cards[1].querySelector('strong').textContent, '[' + malicious + '] Legacy markup');
assert.equal(ui.document.querySelector('img, script, [onerror], [data-injected]'), null);
assert.equal(ui.document.querySelector('.assistant-cite').getAttribute('href'), '#assistant-source-1');
assert.equal(ui.document.getElementById('assistant-source-1'), cards[0]);
await new Promise(resolve => ui.dom.window.addEventListener('load', resolve, { once: true }));
assert.equal(ui.dom.window.getComputedStyle(ui.document.getElementById('assistant-messages')).maxHeight, '65vh');
ui.dom.window.close();
});
function directAdapter(provider, mode, reason) {
const responses = [];
const sdkCalls = [];
class InvokeModelCommand { constructor(input) { this.input = input; } }
class ConverseCommand { constructor(input) { this.input = input; } }
const mocks = {
openai: { OpenAI: class { constructor() { throw Error('Unexpected OpenAI client'); } } },
'./models': { ...require('../src/utils/models'), async getAllowedModelIds() { return new Set(['anthropic.claude-synthetic', 'amazon/nova-lite', 'google/gemini-2.5-flash']); } },
'./generationOptions': require('../src/utils/generationOptions'),
'./logger': { apiCall() {}, error() {} },
'../db/database': { async getSetting() { return null; } },
'@aws-sdk/client-bedrock-runtime': {
InvokeModelCommand, ConverseCommand,
BedrockRuntimeClient: class {
async send(command) {
sdkCalls.push(command);
if (mode === 'invoke') {
assert.ok(command instanceof InvokeModelCommand);
return { body: Buffer.from(JSON.stringify({ content: [{ type: 'text', text: 'Synthetic handoff.' }], stop_reason: reason })) };
}
assert.ok(command instanceof ConverseCommand);
return { output: { message: { content: [{ text: 'Synthetic handoff.' }] } }, stopReason: reason };
}
}
},
'@google-cloud/vertexai': {
VertexAI: class {
getGenerativeModel() {
return { async generateContent(request) {
sdkCalls.push(request);
return { response: { candidates: [{ content: { parts: [{ text: 'Synthetic handoff.' }] }, finishReason: reason }] } };
} };
}
}
}
};
const module = { exports: {} };
vm.runInNewContext(read('src/utils/ai.js'), {
module, exports: module.exports, console: quiet, TextDecoder,
process: { env: provider === 'bedrock' ? { AI_PROVIDER: provider, AWS_BEDROCK_REGION: 'synthetic' } : { AI_PROVIDER: provider, GOOGLE_VERTEX_PROJECT: 'synthetic' } },
require(name) { assert.ok(Object.hasOwn(mocks, name), 'Unexpected adapter import: ' + name); return mocks[name]; }
});
return {
responses, sdkCalls,
async callAI(...args) { const result = await module.exports.callAI(...args); responses.push(result); return result; }
};
}
test('actual direct AI adapters propagate completion status without any route (fake SDK only)', async () => {
for (const [provider, mode, model] of [
['bedrock', 'invoke', 'anthropic.claude-synthetic'],
['bedrock', 'converse', 'amazon/nova-lite'],
['vertex', 'vertex', 'google/gemini-2.5-flash']
]) {
for (const [reason, normalized] of [
['max_tokens', 'length'], ['MAX_TOKENS', 'length'],
['end_turn', 'stop'], ['stop_sequence', 'stop'], ['stop', 'stop'], ['STOP', 'stop'],
['content_filtered', 'content_filtered'], ['SAFETY', 'SAFETY'], ['tool_use', 'tool_use'],
['future_status', 'future_status'], ['', ''], [null, null], [undefined, null]
]) {
const adapter = directAdapter(provider, mode, reason);
await adapter.callAI([{ role: 'user', content: 'Synthetic facts.' }], { model });
assert.equal(adapter.sdkCalls.length, 1, provider + '/' + mode + '/' + reason);
assert.equal(adapter.responses[0].finishReason, normalized);
}
}
});
test('ENV/default metadata and exact UTF16 boundary ignore legacy DB budget and DB failures', async () => {
for (const [limit, source, expected] of [[undefined, 'default', 120000], ['', 'default', 120000], ['1000', 'environment', 1000], ['1000000', 'environment', 1000000]]) {
const app = server({ limit, legacyLimit: '999999', dbError: true });
const status = await app.request('get', '/clinical-assistant/status');
assert.equal(status.statusCode, 200);
assert.equal(status.body.conversationChars, expected);
assert.equal(status.body.conversationSource, source);
assert.equal(status.body.conversationEnv, 'CLINICAL_ASSISTANT_CONVERSATION_CHARS');
assert.equal(status.body.conversationMeasure, 'UTF-16 code units');
assert.equal(status.body.conversationUnit, 'characters');
}
const invalid = server({ limit: 'invalid' });
assert.equal((await invalid.request('get', '/clinical-assistant/status')).statusCode, 503);
assert.equal(invalid.calls.health.length, 0);
for (const endpoint of ['/clinical-assistant/chat', '/clinical-assistant/chat/stream']) {
const app = server({ limit: '1000', legacyLimit: '1' });
const history = [{ role: 'user', content: '😀'.repeat(499) }];
assert.equal((await app.request('post', endpoint, { history, message: '😀' })).statusCode, 200);
assert.equal(app.calls.search.length, 1);
assert.equal((await app.request('post', endpoint, { history, message: '😀x' })).statusCode, 413);
assert.equal(app.calls.search.length, 1);
}
});
test('both actual image routes delegate to the shared pipeline with full input (poster snapshot covered in PG integration)', async () => {
const prompts = require('../src/utils/clinicalPrompts');
for (const imageBehavior of [undefined, ' Synthetic override.', 'Override without leading space.']) {
const app = server({ imageBehavior });
for (const endpoint of ['/clinical-assistant/image', '/clinical-assistant/image/jobs']) {
const response = await app.request('post', endpoint, { prompt: ' flowchart comparison ' });
assert.equal(response.statusCode, 200);
for (let i = 0; i < 8; i++) await new Promise(resolve => setImmediate(resolve));
const payload = app.calls.images.at(-1);
assert.equal(payload.prompt, prompts.imagePromptForCanvas(' flowchart comparison ', imageBehavior) + '\nRequested layout: auto.');
assert.match(payload.prompt, /tall portrait layout.*wide landscape layout/);
assert.ok(payload.prompt.startsWith(' flowchart comparison '));
if (imageBehavior) assert.doesNotMatch(payload.prompt, /single complete medical teaching poster/);
}
assert.equal(app.calls.images.length, 2);
}
});
test('both actual image routes use the editable poster instruction before unchanged layout suffixes', async () => {
const images = require('../src/utils/generatedImages');
const prompts = require('../src/utils/clinicalPrompts');
for (const behavior of [undefined, ' Synthetic override.', 'Override without leading space.']) {
const snapshots = [];
const real = images.createImageService({
db: { query: async () => ({ rows: [{ key: 'clinical_assistant.image_behavior', value: behavior, revision: 7 }] }) },
encryption: {}, generate: async () => { throw new Error('No provider allowed in this check'); }
});
const app = server({ images: { ...images, service: () => ({
async enqueue(owner, workflow, input) {
assert.equal(workflow, 'clinical_assistant');
snapshots.push(await real.snapshot(workflow, input));
return { success: true, jobId: 'synthetic', status: 'pending' };
},
async get() { return { success: true, status: 'done', jobId: 'synthetic' }; }
}) } });
for (const path of ['/clinical-assistant/image', '/clinical-assistant/image/jobs']) {
const response = await app.request('post', path, { prompt: ' flowchart comparison ' });
assert.equal(response.statusCode, 200);
const snapshot = snapshots.at(-1);
const prefix = prompts.imagePromptForCanvas('Original image request:\n flowchart comparison ', behavior) + '\nRequested layout: auto.';
assert.ok(snapshot.rendered.startsWith(prefix));
assert.match(snapshot.rendered, /tall portrait layout.*wide landscape layout/);
assert.ok(snapshot.rendered.endsWith(images.IMAGE_OUTPUT_RULE));
assert.equal(snapshot.revision, 7);
if (behavior) assert.doesNotMatch(snapshot.rendered, /single complete medical teaching poster/);
}
assert.equal(snapshots.length, 2);
}
});