pediatric-ai-scribe-v3/test/generated-image-cache.test.js
Daniel 2f7233f317 perf: store real image previews in MinIO, generated at creation and on demand
Gallery tiles are 56px but were downloading the full ~280kB original. Previews
are now rendered with sharp and stored beside the originals in the same MinIO
bucket under a thumbs/ prefix, so nothing about credentials, lifecycle or backup
changes. Measured on live assets: 216-294kB originals become 13-19kB at 256px,
about 16x smaller; 640px is about 4x.

Both paths, as asked:
- Rendered when a job completes, so the first viewer never waits for a resize.
  A preview failure never unmakes a finished job.
- Rendered on demand for anything that has none — the existing 26 images work
  immediately with no backfill required, and the result is stored for next time.

Boundaries that matter more than the speed:
- Only 256 and 640 are honoured. An open width parameter would let a caller
  drive arbitrary resizes.
- Permission is checked against the ORIGINAL before a preview is served, so a
  preview can never widen who can see an image.
- Previews carry their own SHA-256 and owner headers, because the client
  verifies both on every asset; sending the original's checksum would be
  rejected as tampering, which is that check working correctly.
- Still private, no-store. The client asset pattern was widened to exactly
  ?w=256 and ?w=640 and nothing else.

Client-side downscaling stays as the fallback when a preview cannot be produced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e1PLqrKgAM9jQhFKRnbLd
2026-09-10 06:39:30 +02:00

75 lines
4.3 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
// Generated assets are served `private, no-store`, which is correct for a
// clinical app on a shared workstation. The cost was that every gallery render
// re-downloaded every image — 26 images at ~279kB to draw 56px tiles.
test('assets are still never written to disk', () => {
const route = read('src/routes/generatedImages.js');
assert.match(route, /Cache-Control', 'private, no-store'/,
'the no-store posture is unchanged; caching is in memory for the session only');
});
test('the cache is scoped to the account that fetched it', () => {
const js = read('public/js/generatedImages.js');
// A cache keyed only by URL would let the next account read the previous
// one's bytes out of memory.
assert.match(js, /function cacheKey\(src, ticket\) \{ return String\(ticket && ticket\.ticket\)/,
'entries are keyed by owner ticket as well as asset');
assert.match(js, /clearImageCache\(\); \/\/ cached bytes must not outlive the account/,
'and dropped when the account boundary moves');
assert.match(js, /assertImageOwner\(ticket\)/, 'ownership is asserted on the way in');
});
test('concurrent tiles share one request instead of racing', () => {
const js = read('public/js/generatedImages.js');
assert.match(js, /if \(inflight\.has\(key\)\) return inflight\.get\(key\);/);
assert.match(js, /\.finally\(\(\) => inflight\.delete\(key\)\)/, 'and the slot is released either way');
});
test('a tile fetches a server preview, not the original', () => {
const js = read('public/js/generatedImages.js');
assert.match(js, /const edge = Number\(img\.getAttribute\('data-image-thumb'\)\) \|\| 0;/);
// The server stores real previews now, so a tile downloads a few kB instead of
// pulling ~280kB and shrinking it in the browser.
assert.match(js, /'w=' \+ edge/, 'the tile asks the server for the preview');
assert.match(js, /\.catch\(\(\) => cachedThumbnail\(src, edge, ticket\)\)/,
'client downscaling remains the fallback');
// A browser without OffscreenCanvas still shows the image rather than nothing.
assert.match(js, /if \(typeof createImageBitmap !== 'function' \|\| typeof OffscreenCanvas !== 'function'\) return blob;/);
const assistant = read('public/js/clinicalAssistant.js');
assert.match(assistant, /data-image-thumb="256"/, 'the 56px gallery asks for a small copy');
});
test('a preview cannot widen access or be asked for arbitrary sizes', () => {
const utils = read('src/utils/generatedImages.js');
const route = read('src/routes/generatedImages.js');
// An open width parameter would let a caller drive arbitrary resizes.
assert.match(utils, /const THUMB_WIDTHS = Object\.freeze\(\[256, 640\]\);/);
assert.match(utils, /return THUMB_WIDTHS\.includes\(width\) \? width : null;/);
// Permission is checked against the ORIGINAL before any preview is served.
assert.match(route, /await images\.service\(\)\.asset\(req\.params\.id, req\.user\); \/\/ authorise/);
// Previews carry their own checksum; the original's would be rejected.
assert.match(route, /createHash\('sha256'\)\.update\(thumb\.bytes\)/);
assert.match(route, /Cache-Control', 'private, no-store'/, 'and they are no-store like the original');
const client = read('public/js/generatedImages.js');
assert.match(client, /\\?w=\(\?:256\|640\)/, 'the client accepts only those two widths as asset URLs');
});
test('previews are rendered once and stored beside the original', () => {
const utils = read('src/utils/generatedImages.js');
assert.match(utils, /const cached = await getStorage\(\)\.getThumb\(id, width\);\s*\n\s*if \(cached\) return cached;/,
'a stored preview is reused rather than re-rendered');
assert.match(utils, /await warmThumbs\(job\.id\);/, 'and rendered when the job completes');
// Neither caching nor warming may break the thing they are optimising.
assert.match(utils, /catch \(_\) \{ \/\* served anyway; the next request retries the write \*\/ \}/);
assert.match(utils, /catch \(_\) \{ \/\* previews are an optimisation; never fail the job for one \*\/ \}/);
const storage = read('src/utils/generatedImageStorage.js');
assert.match(storage, /return 'thumbs\/' \+ id \+ '\/' \+ width;/, 'same bucket, own prefix');
});