pediatric-ai-scribe-v3/test/transcription-memory-policy.test.js
Daniel 523926ab17 feat: keep the screen awake while recording, and keep every recording 24h
Recording
- A screen wake lock is held for as long as a recording runs. Browsers drop
  the lock whenever the page is hidden, so it is taken again on return —
  without that, one glance away ended it for the session. The lock is
  reference counted (two recorders cannot release each other's), never
  requested while hidden (the request would just be rejected), and a denial
  or an unsupported browser leaves the recording running.
- Signing out releases it and stops the recording; nothing is sent, because
  the session that owned the audio is gone.
- start() on an already-running recorder is now a no-op instead of replacing
  the MediaRecorder and silently dropping everything captured so far.
- A recording that ends by itself — recorder error, or the microphone taken
  by another app, unplugged or revoked — takes the same path as pressing
  Stop, so it is transcribed and stored rather than left in a tab that still
  says "recording". Moving around the workspace already kept recording.

Retention
- Every recording is kept for 24 hours now, not only the ones whose
  transcription failed. /api/transcribe already has the audio, so this costs
  no second upload, and a storage failure is logged rather than thrown: it
  must never lose the transcription someone is waiting for.
- One store (src/utils/audioBackupStore.js) is shared by /api/transcribe and
  /api/audio-backups so the two cannot drift. Payload goes to object storage
  when AUDIO_BACKUPS_S3_* is set and to the encrypted Postgres column
  otherwise; metadata always stays in Postgres, so listing, ownership and
  expiry behave the same either way. Object keys are scoped by owner, and
  the expiry sweep deletes the object with the row.

Verified against the live database: round trip byte-identical, another user
reads null, 950 -> 48 bytes compressed, expired rows take their objects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 16:42:31 +02:00

174 lines
10 KiB
JavaScript

const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
function read(relativePath) {
return fs.readFileSync(path.join(root, relativePath), 'utf8');
}
test('AI memory context is limited to saved template categories', () => {
const route = read('src/routes/memories.js');
assert.match(route, /var AI_CONTEXT_CATEGORIES = \[/);
assert.match(route, /'physical_exam'/);
assert.match(route, /'template_ed'/);
assert.match(route, /rows = rows\.filter\(function\(r\) \{ return AI_CONTEXT_CATEGORIES\.indexOf\(r\.category\) !== -1; \}\)/);
assert.doesNotMatch(route.match(/var AI_CONTEXT_CATEGORIES = \[[\s\S]*?\];/)[0], /'custom'/);
});
test('template settings do not offer new custom AI memories', () => {
const settings = read('public/components/settings.html');
const memories = read('public/js/memories.js');
assert.doesNotMatch(settings, /<option value="custom">/);
assert.match(settings, /Only template categories are sent to AI/);
assert.match(memories, /custom: 'Custom \(not used by AI\)'/);
assert.match(memories, /window\.getUserMemoryContext = function\(\)/);
});
test('note refine corrections still call AI without storing learning memories', () => {
const app = read('public/js/app.js');
const refine = read('src/routes/refine.js');
assert.match(app, /function refineDocument\(outputElementId, inputElementId\)/);
assert.match(app, /fetch\('\/api\/refine'/);
assert.match(app, /currentDocument: docText, instructions: instructions/);
assert.match(refine, /router\.post\('\/refine'/);
assert.match(refine, /PROMPTS\.refine \+ INJECTION_GUARD/);
assert.match(refine, /sourceContext/);
assert.doesNotMatch(refine, /INSERT INTO user_memories|correction_/);
});
test('browser Whisper is removed from public runtime and user settings', () => {
assert.equal(fs.existsSync(path.join(root, 'public/js/browserWhisper.js')), false);
assert.equal(fs.existsSync(path.join(root, 'public/js/whisperWorker.js')), false);
assert.equal(fs.existsSync(path.join(root, 'public/js/whisperWorkerV2.js')), false);
assert.equal(fs.existsSync(path.join(root, 'docs/browser-whisper-setup.md')), false);
assert.equal(fs.existsSync(path.join(root, 'docs/browser-whisper-troubleshooting.md')), false);
assert.equal(fs.existsSync(path.join(root, 'scripts/download-whisper-models.sh')), false);
const publicRuntimeFiles = [
'public/index.html',
'public/js/app.js',
'public/js/transcriptionSettings.js',
'public/components/settings.html',
'public/components/faq.html'
];
publicRuntimeFiles.forEach((file) => {
assert.doesNotMatch(read(file), /BrowserWhisper|browser-whisper|Browser Whisper|Xenova\/whisper|transformers\.min\.js/, file);
});
const dockerfile = read('Dockerfile');
assert.doesNotMatch(dockerfile, /Xenova\/whisper|transformers\.min\.js|Browser Whisper/);
const server = read('server.js');
assert.doesNotMatch(server, /wasm-unsafe-eval|unsafe-eval|huggingface\.co|cdn-lfs|transformers/);
});
test('browser speech recognition is gated by explicit user setting', () => {
const app = read('public/js/app.js');
const speechFactory = app.match(/function createSpeechRecognition\(\) \{[\s\S]*?\n\}/)[0];
assert.match(speechFactory, /window\.WebSpeechRecognition && !window\.WebSpeechRecognition\.isEnabled\(\)/);
});
test('audio backup settings render without dynamic HTML templates', () => {
const audioBackup = read('public/js/audioBackup.js');
const renderer = audioBackup.match(/window\.renderAudioBackups = function\(\) \{[\s\S]*?\n \};/)[0];
assert.doesNotMatch(renderer, /innerHTML/);
assert.match(renderer, /document\.createElement\('button'\)/);
assert.match(renderer, /textContent =/);
});
// The Settings picker offered six hardcoded ids. On this gateway none of them
// resolve, and /api/transcribe prefers the user's choice over the admin
// default — so choosing one broke every recording with "Invalid model name".
test('the STT picker offers what the gateway has, not a hardcoded list', () => {
const stt = read('src/utils/sttProvider.js');
const prefs = read('src/routes/userPreferences.js');
assert.match(stt, /async function discoverSTTModels\(options\)/);
assert.match(stt, /getLiteLLMSTTModels\(resp\.data && resp\.data\.data\)/, 'filtered by audio_transcription mode');
assert.match(stt, /STT_DISCOVERY_TTL_MS = 5 \* 60 \* 1000;/, 'cached, so a user-facing page does not hit the gateway every load');
assert.match(stt, /module\.exports = \{[\s\S]{0,80}discoverSTTModels,/);
assert.match(prefs, /var sttIds = await discoverSTTModels\(\);/);
assert.match(prefs, /if \(!sttIds\.length\) sttIds = getSTTModelLists\(\)\.litellm\.slice\(\);/,
'the built-in list survives only as a fallback');
assert.match(prefs, /model === adminSttModel \? ' \(default\)' : ''/, 'the admin default is marked');
});
// A recording is significant clinical material: it must be possible to take a
// copy out of the app, and a recording that has silently stopped must say so.
test('recordings can be exported, and a dead recorder is reported', () => {
const backup = read('public/js/audioBackup.js');
assert.match(backup, /window\.downloadAudioBackup = function\(id, stamp\)/);
assert.match(backup, /'\/api\/audio-backups\/' \+ id\.replace\('server_', ''\) \+ '\/audio'/, 'server-side copies');
assert.match(backup, /objectStore\(STORE_NAME\)\.get\(localId\)/, 'and local ones');
// A local record belongs to one account; another must not be able to pull it.
assert.match(backup, /!boundary\.valid\(owner\) \|\| record\.owner !== owner/);
assert.match(backup, /audio-backup-download/, 'the list offers it');
assert.match(backup, /indexOf\('mp4'\) !== -1 \? 'm4a'/, 'the extension matches what was recorded');
const app = read('public/js/app.js');
assert.match(app, /self\.mediaRecorder\.onerror = function\(event\)/);
assert.match(app, /track\.addEventListener\('ended'/, 'the microphone being taken away is a failure too');
assert.match(app, /AudioRecorder\.prototype\.notifyFailure/);
assert.match(app, /if \(this\.notified\) return;/, 'reported once, not per chunk');
assert.match(app, /audio-recorder-failed/, 'callers can react');
});
test('a running recording holds the screen awake and survives a glance away', () => {
const app = read('public/js/app.js');
// The screen sleeping suspends the recording, and the browser drops a wake
// lock whenever the page is hidden — so it has to be taken again on return,
// or one glance away ends it for the rest of the session.
assert.match(app, /navigator\.wakeLock\.request\('screen'\)/);
assert.match(app, /document\.addEventListener\('visibilitychange', function\(\) \{\s*\n\s*if \(document\.visibilityState === 'visible'\) _acquireWakeLock\(\);/);
assert.match(app, /if \(document\.visibilityState !== 'visible'\) return Promise\.resolve\(null\);/,
'requesting while hidden would just be rejected');
// Counted, so two recorders do not release each other's lock.
assert.match(app, /_wakeLockHolders = Math\.max\(0, _wakeLockHolders - 1\);/);
assert.match(app, /if \(_wakeLockHolders > 0 \|\| !_wakeLock\) return;/);
// Signing out must not leave the screen pinned awake.
assert.match(app, /window\.addEventListener\('account-boundary', function\(\) \{\s*\n\s*_wakeLockHolders = 0;/);
// Denied or unsupported must not stop the recording.
assert.match(app, /\.catch\(function\(\) \{ return null; \}\);/);
});
test('starting an already-running recorder does not throw away what it has', () => {
const app = read('public/js/app.js');
assert.match(app, /if \(self\.mediaRecorder && self\.mediaRecorder\.state === 'recording'\) return Promise\.resolve\(\);/);
assert.match(app, /if \(self\.heldWakeLock\) \{ self\.heldWakeLock = false; releaseWakeLock\(\); \}/, 'and stopping releases the lock');
});
test('a recording that ends by itself is still transcribed, and logging out stops it', () => {
const live = read('public/js/liveEncounter.js');
// Same path as pressing Stop, so the audio is transcribed and stored rather
// than left in a tab that still claims to be recording.
assert.match(live, /document\.addEventListener\('audio-recorder-failed', function\(\) \{[\s\S]{0,160}recordBtn\.click\(\);/);
assert.match(live, /window\.addEventListener\('account-boundary', function\(\) \{[\s\S]{0,200}recorder\.stop\(\)/,
'signing out mid-recording stops it');
});
test('every recording is kept for 24 hours, not only the failures', () => {
const transcribe = read('src/routes/transcribe.js');
const store = read('src/utils/audioBackupStore.js');
const db = read('src/db/database.js');
// The audio is already on the server for transcription, so keeping it costs
// no second upload.
assert.match(transcribe, /require\('\.\.\/utils\/audioBackupStore'\)\.save\(req\.user\.id, req\.body\.module \|\| 'recording', req\.file\.buffer, mimeType\)/);
assert.match(transcribe, /console\.warn\('\[Transcribe\] backup failed \(transcription continues\)/,
'a storage failure must not lose the transcription someone is waiting for');
// Object storage when configured, the encrypted database column otherwise.
assert.match(store, /if \(!env\.AUDIO_BACKUPS_S3_ENDPOINT \|\| !env\.AUDIO_BACKUPS_S3_BUCKET\) return null;/);
assert.match(store, /cryptoUtil\.encryptBuffer\(compressed\)/, 'compressed and encrypted either way');
assert.match(store, /'recordings\/' \+ userId \+ '\/'/, 'keys are scoped to their owner');
assert.match(store, /WHERE id = \$1 AND user_id = \$2 AND expires_at > NOW\(\)/, 'ownership and expiry are in the query');
assert.match(store, /cryptoUtil\.isEncryptedBuffer\(stored\) \? cryptoUtil\.decryptBuffer\(stored\) : stored/,
'rows written before encryption still read back');
// An expired row must take its object with it.
assert.match(db, /DELETE FROM audio_backups WHERE expires_at < NOW\(\) RETURNING storage_key/);
assert.match(db, /await store\.removeObject\(audio\.rows\[i\]\.storage_key\)/);
assert.match(db, /ALTER TABLE audio_backups ADD COLUMN IF NOT EXISTS storage_key TEXT;/,
'existing installations get the column too');
});