pediatric-ai-scribe-v3/test/session-quiz-ed-regressions.test.js
Daniel 025290d64a
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m1s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: retire Learning Hub
My Resources generates better slides than Learning Hub ever did — a typed deck
the model fills in, rendered by python-pptx with fit-to-slide text, figures, a
vision review and themes, against Learning Hub's markdown-through-pandoc — and
the articles and quizzes now live in the quiz app. Keeping a second, weaker
generator and a whole CMS beside it was not earning its maintenance.

Removed: three routers, the Learning Hub and Content Manager tabs, their
components and frontend modules, the five database tables, the WebDAV browser,
the content embedding column and its vector index.

Content was exported first — every article as markdown plus a full SQL dump of
all five tables — to ops-backups/learning-hub-export-*. That export is the
restore path; the migration's down() can recreate the shape but never the rows,
and says so.

Two things this simplifies rather than merely deletes:

generated_image_links existed only to record which published content an image
appeared in, and it was the sole reason a generated image could be read by
someone who did not make it. Images are now owner-only — the visibility rule is
one WHERE clause instead of a join across two tables and a published flag.

embeddings.js keeps the model discovery the admin panel uses and loses
searchSimilar and generateContentEmbedding, which queried a table that no longer
exists.

Kept deliberately: Nextcloud connect, disconnect and export, which are how a
generated note reaches a real filesystem and have nothing to do with Learning
Hub; learningRetrieval, which despite its name is the clinical corpus search My
Resources depends on; and the pandoc reference deck, still the fallback when the
python renderer fails, moved from assets/learning to assets/deck now that the
old name misleads.

Tests: four Learning-Hub-only files removed, and the individual cases inside
shared files that asserted its behaviour. Where a test used a Learning endpoint
only as a convenient example — the account-boundary token test, the policy
matrix — it now uses one that still exists, so the property it proves is
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 20:14:20 +02:00

277 lines
13 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 { pathToFileURL } = require('node:url');
const { JSDOM, VirtualConsole } = require('jsdom');
const { Console } = require('node:console');
// Page console goes to stderr: node:test parses the child's stdout.
const pageConsole = () => new VirtualConsole().forwardTo(new Console({ stdout: process.stderr, stderr: process.stderr }));
const express = require('express');
const jwt = require('jsonwebtoken');
const sessions = require('../src/utils/sessions');
const platform = require('../src/utils/platform');
const root = path.join(__dirname, '..');
const secret = 'isolated-regression-secret';
// Execute real modules with explicit boundary fakes; no database or network access.
function load(file, mocks) {
const module = { exports: {} };
vm.runInNewContext(fs.readFileSync(path.join(root, file), 'utf8'), {
module, exports: module.exports, console,
process: { env: { JWT_SECRET: secret } },
require(name) {
assert.ok(Object.hasOwn(mocks, name), 'Unexpected import: ' + name);
return mocks[name];
}
}, { filename: file });
return module.exports;
}
function response() {
return {
code: 200, body: null, cookies: [], cleared: [],
status(code) { this.code = code; return this; },
json(body) { this.body = body; return this; },
cookie(...args) { this.cookies.push(args); },
clearCookie(...args) { this.cleared.push(args); }
};
}
function endpoint(router, method, route) {
return router.stack.find(layer => layer.route && layer.route.path === route && layer.route.methods[method])
.route.stack.find(layer => layer.method === method).handle;
}
const authStub = { authMiddleware() {}, moderatorMiddleware() {} };
test('auth rejects missing last session and session-store errors, preserving valid web/mobile activity', async () => {
const token = jwt.sign({ userId: 7 }, secret, { expiresIn: '1h' });
for (const scenario of [
{ mode: 'missing', code: 401 },
{ mode: 'missing', mobile: true, code: 401 },
{ mode: 'error', code: 503 },
{ mode: 'valid', code: 200 },
{ mode: 'valid', method: 'POST', minutes: 11, refresh: true, code: 200 },
{ mode: 'valid', minutes: 11, code: 200 },
{ mode: 'valid', minutes: 1500, code: 401 },
{ mode: 'valid', minutes: 1500, mobile: true, method: 'POST', code: 200 }
]) {
const writes = [];
const db = {
async get(sql, params) {
if (sql.includes('FROM users')) return { id: 7, role: 'user', disabled: false };
if (sql.includes('COUNT')) return { count: 0 }; // Last session was revoked.
assert.equal(params[0], sessions.hashToken(token));
if (scenario.mode === 'error') throw new Error('private database diagnostic');
return scenario.mode === 'missing' ? null : {
id: 'session-7', last_activity: new Date(Date.now() - (scenario.minutes || 0) * 60000)
};
},
async run(sql) { writes.push(sql); }
};
const { authMiddleware } = load('src/middleware/auth.js', {
jsonwebtoken: jwt, '../db/database': db, '../utils/sessions': sessions, '../utils/platform': platform
});
const req = {
headers: scenario.mobile ? { 'x-client': 'mobile', authorization: 'Bearer ' + token } : {},
cookies: scenario.mobile ? {} : { ped_auth: token }, method: scenario.method || 'GET'
};
const res = response();
let next = 0;
await authMiddleware(req, res, () => next++);
assert.equal(res.code, scenario.code);
assert.equal(next, scenario.code === 200 ? 1 : 0);
assert.equal(res.cookies.length, scenario.refresh ? 1 : 0);
if (scenario.code === 200) assert.equal(req.sessionId, 'session-7');
assert.equal(writes.some(sql => sql.startsWith('UPDATE')),
scenario.method === 'POST' && scenario.code === 200 && scenario.minutes > 10);
if (scenario.mobile) assert.ok(!writes.some(sql => sql.startsWith('DELETE')));
if (scenario.mode === 'error') assert.equal(res.body.error, 'Authentication temporarily unavailable');
}
});
function encounterServer() {
const rows = [];
const db = {
async getSetting() { return '7'; },
async get(sql, p) {
if (sql.includes('idempotency_key')) return rows.find(row => row.key === p[1]);
if (sql.includes('LOWER(label)')) return rows.find(row => row.label.toLowerCase() === p[1].toLowerCase());
if (sql.includes('SELECT id, version')) return rows.find(row => String(row.id) === String(p[0]));
throw new Error('Unexpected query: ' + sql);
},
async run(sql, p) {
if (sql.startsWith('INSERT')) {
const row = { id: rows.length + 1, label: p[1], transcript: p[3], note: p[4], key: p[7], version: 1 };
rows.push(row);
return { lastInsertRowid: row.id };
}
assert.ok(sql.startsWith('UPDATE'));
const versioned = sql.includes('version=$6');
const row = rows.find(row => String(row.id) === String(p[versioned ? 6 : 5]));
assert.ok(row);
Object.assign(row, { label: p[0], transcript: p[1], note: p[2] });
if (versioned) row.version = p[5];
return { changes: 1 };
}
};
const router = load('src/routes/encounters.js', {
express, '../db/database': db, '../middleware/auth': authStub,
'../utils/logger': { audit() {}, error() {} }, '../utils/crypto': { encryptString: value => value }
});
const save = endpoint(router, 'post', '/encounters/saved');
return { rows, async post(body) {
const res = response();
await save({ user: { id: 7 }, body }, res);
return res;
} };
}
function edBrowser(server, storage = {}) {
const dom = new JSDOM('<input id="ed-label"><div id="ed-transcript"></div>' +
'<div id="ed-stages-container"></div><button id="btn-ed-finalize"></button>' +
'<button id="btn-ed-save"></button><button id="btn-ed-new"></button>', {
url: 'https://isolated.invalid', runScripts: 'outside-only', virtualConsole: pageConsole()
});
const w = dom.window;
for (const [key, value] of Object.entries(storage.session || {})) w.sessionStorage.setItem(key, value);
for (const [key, value] of Object.entries(storage.local || {})) w.localStorage.setItem(key, value);
w.showToast = () => {};
w.showBusy = () => { w.busy = true; };
w.hideBusy = () => { w.busy = false; };
w.HTMLElement.prototype.scrollIntoView = () => {};
w.getAuthHeaders = () => ({});
const pending = [];
w.fetch = (url, opts = {}) => {
assert.ok(['/api/encounters/saved', '/api/ed-encounters/finalize'].includes(url));
if (opts.method !== 'POST') return Promise.resolve({ json: async () => ({ encounters: server.rows }) });
return new Promise((resolve, reject) => pending.push({ url, body: JSON.parse(opts.body), resolve, reject }));
};
w.eval(fs.readFileSync(path.join(root, 'public/js/accountBoundary.js'), 'utf8'));
w.AccountBoundary.enter({ id: 7 }, true);
w.eval(fs.readFileSync(path.join(root, 'public/js/encounters.js'), 'utf8'));
// ED is a module in production: isolate its lexical state, keeping real event handlers.
w.eval('(function() {\n' + fs.readFileSync(path.join(root, 'public/js/ed-encounters.js'), 'utf8') + '\n})();');
w.document.dispatchEvent(new w.CustomEvent('tabChanged', { detail: { tab: 'ed' } }));
return {
w, pending, close: () => w.close(),
save(label) { w.document.getElementById('ed-label').value = label; w.document.getElementById('btn-ed-save').click(); },
newPatient() { w.document.getElementById('btn-ed-new').click(); },
snapshot() { return { session: { ...w.sessionStorage }, local: { ...w.localStorage } }; },
async complete(index = 0, loseResponse = false) {
const request = pending.splice(index, 1)[0];
assert.ok(request, 'Expected a save request');
const res = request.url === '/api/ed-encounters/finalize'
? { code: 200, body: { success: true, finalNote: 'Consolidated note', mdm: null } }
: await server.post(request.body);
if (loseResponse) request.reject(new Error('Simulated lost response'));
else request.resolve({ status: res.code, json: async () => res.body });
await new Promise(resolve => setImmediate(resolve));
return res;
}
};
}
const draftStorage = { local: { 'ped_ed_draft_v1:owner:7': JSON.stringify({
label: 'Patient A', state: { stage: 1, stages: [{ transcript: 'First transcript', note: 'First stage note' }], finalized: false }
}) } };
test('two new ED drafts get distinct UUIDs, preserving the first; same draft retry/reload keeps identity', async () => {
const server = encounterServer();
let browser = edBrowser(server, draftStorage);
try {
browser.save('Patient A');
const keyA = browser.pending[0].body.idempotency_key;
assert.match(keyA, /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/);
browser.save('Patient A');
assert.equal(browser.pending.length, 1, 'Double-click is suppressed');
await browser.complete(0, true);
assert.equal(browser.w._savedEncId_ed, undefined);
browser.save('Patient A');
assert.equal(browser.pending[0].body.idempotency_key, keyA);
await browser.complete(0, true);
const storage = browser.snapshot();
browser.close();
browser = edBrowser(server, storage);
browser.save('Patient A');
assert.equal(browser.pending[0].body.idempotency_key, keyA);
assert.equal((await browser.complete()).code, 200, 'Lost-response retry bypasses same-row label conflict');
assert.equal(server.rows.length, 1);
assert.equal(browser.w._savedEncId_ed, 1);
const savedStorage = browser.snapshot();
browser.close();
browser = edBrowser(server, savedStorage);
browser.save('Patient A');
assert.equal(String(browser.pending[0].body.id), '1');
await browser.complete();
const first = { ...server.rows[0] };
assert.equal(first.transcript, 'First transcript');
assert.equal(first.note, 'First stage note');
browser.newPatient();
browser.save('Patient B');
assert.equal(browser.pending[0].body.id, null);
assert.notEqual(browser.pending[0].body.idempotency_key, keyA);
await browser.complete();
assert.equal(server.rows.length, 2);
assert.deepEqual(server.rows[0], first);
assert.equal(browser.w._savedEncId_ed, 2);
} finally { browser.close(); }
});
test('delayed old ED save cannot assign its ID or unlock a new patient save', async () => {
const server = encounterServer();
const browser = edBrowser(server);
try {
browser.save('Old patient');
browser.newPatient();
browser.save('New patient');
assert.equal(browser.pending.length, 2, 'New identity can save while old request is pending');
await browser.complete(0);
assert.equal(browser.w._savedEncId_ed, null);
assert.equal(browser.w.sessionStorage.getItem('_savedEncId_ed:owner:7'), null);
browser.save('New patient');
assert.equal(browser.pending.length, 1, 'Old callback must not release new save lock');
await browser.complete();
assert.equal(browser.w._savedEncId_ed, 2);
assert.equal(browser.w.sessionStorage.getItem('_savedEncId_ed:owner:7'), '2');
} finally { browser.close(); }
});
test('backend rejects ambiguous legacy ED key reuse without overwriting its first row', async () => {
const server = encounterServer();
const body = { label: 'Legacy A', enc_type: 'ed', idempotency_key: 'ed-draft-new', generated_note: 'Original' };
assert.equal((await server.post(body)).code, 200);
const first = { ...server.rows[0] };
assert.equal((await server.post({ ...body, label: 'Legacy B', generated_note: 'Other patient' })).code, 409);
assert.equal((await server.post(body)).code, 409, 'Ambiguous legacy retries require loading by ID');
assert.deepEqual(server.rows, [first]);
assert.equal((await server.post({ ...body, id: 1, generated_note: 'Explicit update' })).code, 200);
assert.equal(server.rows[0].note, 'Explicit update');
});
test('ED final save reuses draft UUID after lost response; delayed finalize cannot save a reset patient', async () => {
const server = encounterServer();
const browser = edBrowser(server, draftStorage);
try {
browser.save('Patient A');
const key = browser.pending[0].body.idempotency_key;
await browser.complete(0, true);
browser.w.document.getElementById('btn-ed-finalize').click();
await browser.complete();
assert.equal(browser.pending[0].body.idempotency_key, key);
assert.equal(browser.pending[0].body.status, 'final');
await browser.complete();
assert.equal(server.rows.length, 1);
assert.equal(server.rows[0].note, 'Consolidated note');
} finally { browser.close(); }
const delayed = edBrowser(server, draftStorage);
try {
delayed.w.document.getElementById('btn-ed-finalize').click();
assert.equal(delayed.pending.length, 1);
delayed.newPatient();
assert.equal(delayed.w.busy, false, 'Reset clears the old finalization busy indicator');
await delayed.complete();
assert.equal(delayed.pending.length, 0, 'Old finalize must not initiate a save with new identity');
assert.equal(delayed.w._savedEncId_ed, null);
assert.equal(delayed.w.document.getElementById('ed-final-note-card'), null);
} finally { delayed.close(); }
});