From 259b4858be012af462c050936cf1f4b8cb030f3e Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 14:36:30 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20an=20image=20library=20=E2=80=94=20ever?= =?UTF-8?q?y=20picture=20this=20account=20has=20made?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A figure outlives the deck it was drawn for: the deck gets replaced, the diagram is still good. Until now a generated image could only be seen in the resource it was made for, and there was no way to find one again or to delete it. Library → Images is a grid of every finished image the account owns, across all three workflows, newest first. GET /api/generated-images is scoped by owner_id in the statement rather than filtered after, returns only finished jobs — an unfinished one is a broken frame in a gallery — and pages by keyset, because a gallery that grows while you scroll repeats or skips rows under OFFSET. Most of this already existed. Thumbnails were already rendered at 256 and 640 by sharp and already served by ?w=, with their own checksum so the client's tamper check passes on a derived copy; hydrateImage already handles auth, the account boundary and caching. The tiles ask for the 256px preview, so thirty of them cost a few kB each rather than thirty full-size downloads, and the prompt is decrypted for the caption because it is the only human-readable label an image has. Deleting needed new work. The storage interface had no remove at all, so a delete that dropped the row would have left the object and both previews in the bucket — paid for, and still readable by anything with credentials. Storage now removes all three keys, and the bytes go before the row: a row pointing at a missing object is a broken image in a gallery, while an object without its row is only wasted space, and unreachable storage refuses the delete outright rather than reporting a success that left the picture behind. THUMB_WIDTHS now has one definition, in generatedImageStorage. Two copies drift, and the drift that matters is a width that gets written and never deleted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- docs/my-resources.md | 36 ++++++ public/components/my-resources.html | 30 ++++- public/js/myResources.js | 179 +++++++++++++++++++++++++++- src/routes/generatedImages.js | 87 ++++++++++++++ src/utils/generatedImageStorage.js | 17 ++- src/utils/generatedImages.js | 26 +++- test/generated-image-cache.test.js | 2 +- test/image-library.test.js | 93 +++++++++++++++ 8 files changed, 461 insertions(+), 9 deletions(-) create mode 100644 test/image-library.test.js diff --git a/docs/my-resources.md b/docs/my-resources.md index f0017e4a..5e9fe766 100644 --- a/docs/my-resources.md +++ b/docs/my-resources.md @@ -253,6 +253,42 @@ download that works without it. of Python. Both pip packages are pinned: unpinned, a rebuild from the same commit could produce different documents. +## The image library + +Library → **Images** is every picture the account has generated, across all +three features — My Resources, the Clinical Assistant and the Learning Hub — +newest first. A figure outlives the deck it was drawn for: the deck gets +replaced, the diagram is still good. + +`GET /api/generated-images` returns only finished jobs, scoped by `owner_id` in +the statement rather than filtered afterwards. Paging is keyset (`created_at < +cursor`), not `OFFSET`, because a gallery that grows while you scroll repeats or +skips a row under `OFFSET`. The prompt is the only human-readable label an image +has — there is no filename and no title — so it is decrypted for the caption; a +prompt that cannot be decrypted costs the caption, never the picture. + +Tiles request the stored 256px preview through `data-image-thumb`, so thirty +tiles cost a few kB each rather than thirty full-size downloads. Every fetch +goes through `hydrateImage`, never a bare `src`: assets are served `no-store` +and a bare `src` would not carry the session on a mobile client. + +### Deleting + +`DELETE /api/generated-images/:id` removes the bytes before the row, and refuses +the whole operation if storage is unreachable. The other order would leave a row +pointing at a key that is gone — an image listed in the gallery that renders +broken — whereas failing between the two leaves a complete, working image and an +error worth retrying. + +Both derived previews go with the original; they live under their own prefix in +the same bucket, and missing them would leave paid-for bytes behind that are +still readable. `THUMB_WIDTHS` is defined once, in `generatedImageStorage.js`, +because a width that is written but never deleted is exactly what two copies of +that list produces. + +A resource that embedded the figure keeps working: a deck stores the job id and +renders without the figure when it has gone. + ## Slide review Off unless an administrator names a model, in **Admin → Slide review**. diff --git a/public/components/my-resources.html b/public/components/my-resources.html index 911961d4..b07dbba6 100644 --- a/public/components/my-resources.html +++ b/public/components/my-resources.html @@ -149,13 +149,37 @@

Library

- +
+ +
+ + +
+ +
-
+ -
+
+ + +
diff --git a/public/js/myResources.js b/public/js/myResources.js index 8d551046..0b9f8d95 100644 --- a/public/js/myResources.js +++ b/public/js/myResources.js @@ -73,7 +73,18 @@ if (generate) generate.addEventListener('click', runGenerate); var refresh = document.getElementById('btn-mr-refresh'); - if (refresh) refresh.addEventListener('click', loadLibrary); + // Refreshes whichever view is showing, rather than always the documents. + if (refresh) refresh.addEventListener('click', function () { + var panel = document.getElementById('mr-images-panel'); + if (panel && !panel.hidden) loadImages(true); else loadLibrary(); + }); + + var docsTab = document.getElementById('tab-mr-docs'); + var imagesTab = document.getElementById('tab-mr-images'); + if (docsTab) docsTab.addEventListener('click', function () { showLibraryTab('docs'); }); + if (imagesTab) imagesTab.addEventListener('click', function () { showLibraryTab('images'); }); + var moreImages = document.getElementById('btn-mr-images-more'); + if (moreImages) moreImages.addEventListener('click', function () { loadImages(false); }); // One delegated handler: rows are rebuilt on every refresh, so binding per // row would leak listeners and miss anything added later. @@ -254,6 +265,172 @@ }); } + // ── The image library ────────────────────────────────────────────────────── + // Every picture this account has generated, across all three features. Kept + // because a figure outlives the deck it was drawn for: the deck gets replaced, + // the diagram is still good. + // + // Thumbnails, not originals. The server stores a 256px preview beside each + // asset, so a grid of thirty costs a few kB each instead of thirty full-size + // renders — data-image-thumb is what asks hydrateImage for the small copy. + var imagesLoaded = false; + var nextImagesBefore = null; + + function showLibraryTab(which) { + var docs = which !== 'images'; + [['tab-mr-docs', docs], ['tab-mr-images', !docs]].forEach(function (pair) { + var tab = document.getElementById(pair[0]); + if (!tab) return; + tab.setAttribute('aria-selected', pair[1] ? 'true' : 'false'); + tab.style.background = pair[1] ? 'var(--white)' : 'transparent'; + }); + var list = document.getElementById('mr-list'); + var panel = document.getElementById('mr-images-panel'); + var search = document.getElementById('mr-docs-search'); + if (list) list.hidden = !docs; + if (search) search.hidden = !docs; + if (panel) panel.hidden = docs; + // Fetched the first time the tab is opened, not on page load: most visits + // never open it, and it is a database read plus a thumbnail per tile. + if (!docs && !imagesLoaded) loadImages(true); + } + + function loadImages(reset) { + var grid = document.getElementById('mr-images-grid'); + var empty = document.getElementById('mr-images-empty'); + var more = document.getElementById('btn-mr-images-more'); + if (!grid) return; + if (reset) { grid.textContent = ''; nextImagesBefore = null; } + if (empty) { empty.hidden = false; empty.textContent = 'Loading…'; } + + var url = '/api/generated-images?limit=60' + + (nextImagesBefore && !reset ? '&before=' + encodeURIComponent(nextImagesBefore) : ''); + fetch(url, { headers: getAuthHeaders() }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (!data.success) throw new Error(data.error || 'Could not load your images'); + imagesLoaded = true; + nextImagesBefore = data.nextBefore || null; + if (more) more.hidden = !nextImagesBefore; + if (empty) { + empty.hidden = Boolean(data.images.length) || Boolean(grid.children.length); + empty.textContent = 'No images yet. Tick "Add illustrations" when you generate, ' + + 'or ask the Clinical Assistant for a diagram.'; + } + import('/js/generatedImages.js').then(function (m) { + data.images.forEach(function (image) { grid.appendChild(imageTile(image, m)); }); + }).catch(function () { + if (empty) { empty.hidden = false; empty.textContent = 'Images could not be displayed.'; } + }); + }) + .catch(function (err) { + if (empty) { empty.hidden = false; empty.textContent = err.message; } + }); + } + + // Built as elements, never innerHTML: the caption is a model-written prompt. + function imageTile(image, m) { + var tile = document.createElement('figure'); + tile.style.cssText = 'margin:0;border:1px solid var(--g200);border-radius:8px;overflow:hidden;' + + 'display:flex;flex-direction:column;background:var(--white);'; + + var frame = document.createElement('div'); + frame.style.cssText = 'aspect-ratio:4/3;background:var(--g100);display:flex;align-items:center;justify-content:center;overflow:hidden;'; + var img = document.createElement('img'); + img.alt = image.prompt ? 'Generated illustration: ' + image.prompt.slice(0, 80) : 'Generated illustration'; + img.setAttribute('data-image-thumb', '256'); + img.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block;'; + frame.appendChild(img); + m.hydrateImage(img, image.imageUrl).catch(function () { + img.remove(); + frame.textContent = 'Unavailable'; + frame.style.fontSize = '11px'; + frame.style.color = 'var(--g500)'; + }); + + var caption = document.createElement('figcaption'); + caption.style.cssText = 'padding:8px;display:flex;flex-direction:column;gap:6px;min-width:0;'; + + var text = document.createElement('div'); + text.style.cssText = 'font-size:11px;color:var(--g600);line-height:1.35;max-height:3.4em;overflow:hidden;'; + text.textContent = image.prompt || 'No description recorded'; + + var meta = document.createElement('div'); + meta.style.cssText = 'font-size:10px;color:var(--g500);display:flex;gap:6px;flex-wrap:wrap;'; + meta.textContent = image.source + ' · ' + new Date(image.createdAt).toLocaleDateString(); + + var actions = document.createElement('div'); + actions.style.cssText = 'display:flex;gap:6px;'; + var open = document.createElement('button'); + open.type = 'button'; + open.className = 'btn-sm btn-ghost'; + open.style.cssText = 'font-size:11px;padding:3px 8px;'; + open.textContent = 'Open'; + open.addEventListener('click', function () { openImage(image, m); }); + + var remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'btn-sm btn-ghost'; + remove.style.cssText = 'font-size:11px;padding:3px 8px;color:var(--red);'; + remove.textContent = 'Delete'; + remove.addEventListener('click', function () { deleteImage(image, tile, remove); }); + + actions.append(open, remove); + caption.append(text, meta, actions); + tile.append(frame, caption); + return tile; + } + + // The full-size copy, fetched the same authenticated way as the tile. A plain + // link would not carry the session on a mobile client, and the asset is served + // no-store on purpose. + function openImage(image, m) { + var overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.72);z-index:9999;' + + 'display:flex;align-items:center;justify-content:center;padding:24px;'; + var full = document.createElement('img'); + full.alt = image.prompt || 'Generated illustration'; + full.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain;border-radius:6px;'; + overlay.appendChild(full); + overlay.addEventListener('click', function () { overlay.remove(); }); + document.addEventListener('keydown', function escape(e) { + if (e.key !== 'Escape') return; + overlay.remove(); + document.removeEventListener('keydown', escape); + }); + document.body.appendChild(overlay); + m.hydrateImage(full, image.imageUrl).catch(function () { + full.remove(); + overlay.textContent = 'That image could not be loaded.'; + overlay.style.color = 'var(--white)'; + }); + } + + function deleteImage(image, tile, button) { + // Asked, because it removes the picture from every deck that used it. The + // deck keeps working; the figure is simply no longer there. + if (!window.confirm('Delete this image? Any resource that used it will render without it.')) return; + button.disabled = true; + fetch('/api/generated-images/' + encodeURIComponent(image.id), { + method: 'DELETE', headers: getAuthHeaders() + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (!data.success) throw new Error(data.error || 'Could not delete that image'); + tile.remove(); + var grid = document.getElementById('mr-images-grid'); + var empty = document.getElementById('mr-images-empty'); + if (grid && !grid.children.length && empty) { + empty.hidden = false; + empty.textContent = 'No images yet.'; + } + }) + .catch(function (err) { + button.disabled = false; + if (typeof showToast === 'function') showToast(err.message, 'error'); + }); + } + // The library as last fetched. Held so searching and the Modify picker both // work from one copy rather than each asking the server again. var library = []; diff --git a/src/routes/generatedImages.js b/src/routes/generatedImages.js index a31d9117..c4548534 100644 --- a/src/routes/generatedImages.js +++ b/src/routes/generatedImages.js @@ -40,6 +40,93 @@ async function sendAsset(req, res, download) { res.setHeader('Content-Disposition', (download ? 'attachment' : 'inline') + '; filename="generated-image.' + ({ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' })[image.mime] + '"'); res.send(image.bytes); } +// ── The owner's own pictures, wherever they were made ─────────────────────── +// Every image this account generated, newest first, across all three workflows. +// The per-workflow endpoint below answers "how is this job doing"; this answers +// "what have I made", which is the question a library is for. +// +// Only finished jobs: a queued or failed one has nothing to show, and listing it +// would put a broken frame in a gallery. The prompt is stored encrypted and is +// decrypted here because it is the only human-readable label an image has — +// there is no filename and no title. +const WORKFLOW_LABELS = { + clinical_assistant: 'Clinical Assistant', + learning_hub: 'Learning Hub', + my_resources: 'My Resources' +}; + +router.get('/generated-images', async (req, res) => { + try { + const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 60, 1), 120); + // Keyset, not OFFSET: a gallery that grows while you scroll would otherwise + // repeat or skip a row on every page. + const before = req.query.before ? new Date(String(req.query.before)) : null; + if (before && isNaN(before.getTime())) throw images.failure(400, 'Invalid cursor'); + + const params = [req.user.id]; + let where = "owner_id=$1 AND stage='done'"; + if (before) { params.push(before.toISOString()); where += ' AND created_at < $' + params.length; } + params.push(limit + 1); + + const result = await db.query( + 'SELECT id, workflow, model, byte_length, mime, prompt_cipher, created_at ' + + 'FROM generated_image_jobs WHERE ' + where + + ' ORDER BY created_at DESC LIMIT $' + params.length, params); + + const rows = result.rows.slice(0, limit); + const encryption = require('../utils/crypto'); + res.json({ + success: true, + images: rows.map(function (row) { + let prompt = ''; + // A prompt that cannot be decrypted is not worth failing a gallery for; + // the picture is still there and still downloadable. + try { prompt = String(encryption.decryptString(row.prompt_cipher) || ''); } catch (e) { prompt = ''; } + return { + id: row.id, + workflow: row.workflow, + source: WORKFLOW_LABELS[row.workflow] || row.workflow, + model: row.model, + bytes: row.byte_length, + mime: row.mime, + createdAt: row.created_at, + prompt: prompt.slice(0, 300), + thumbUrl: '/api/generated-images/' + row.id + '?w=256', + imageUrl: '/api/generated-images/' + row.id, + downloadUrl: '/api/generated-images/' + row.id + '?download=1' + }; + }), + nextBefore: result.rows.length > limit ? rows[rows.length - 1].created_at : null + }); + } catch (e) { fail(res, e); } +}); + +// Deleting is the owner's. Scoped by owner_id in the statement rather than +// checked first, so a borrowed id deletes nothing rather than someone else's +// picture, and an id that is not theirs is indistinguishable from one that does +// not exist. +// +// The bytes go before the row. The other order would leave a row pointing at a +// key that is gone — a listed image that renders broken — whereas failing +// between the two leaves a complete, working image and an error the caller can +// retry. Storage that is unreachable refuses the delete rather than removing +// the row and stranding the object in the bucket. +// +// A resource that embedded the figure keeps working: a deck stores the job id +// and renders without the figure when it has gone. +router.delete('/generated-images/:id', async (req, res) => { + try { + if (!images.UUID.test(req.params.id)) throw images.failure(404, 'Image not found'); + const owned = await db.query( + 'SELECT id FROM generated_image_jobs WHERE id=$1 AND owner_id=$2', + [req.params.id, req.user.id]); + if (!owned.rows.length) throw images.failure(404, 'Image not found'); + + await images.service().discard(req.params.id, req.user.id); + res.json({ success: true }); + } catch (e) { fail(res, e); } +}); + router.get('/generated-images/:id', async (req, res) => { try { await sendAsset(req, res, req.query.download === '1'); } catch (e) { fail(res, e); } }); diff --git a/src/utils/generatedImageStorage.js b/src/utils/generatedImageStorage.js index 473f3441..f66a9bf9 100644 --- a/src/utils/generatedImageStorage.js +++ b/src/utils/generatedImageStorage.js @@ -49,6 +49,10 @@ async function download(url, { lookup = dns.lookup, request = https.get, signal } // Derived previews are addressed by asset id and width, so a request can only // ever reach a preview of the asset it already has permission to read. +// Defined here, the lower-level module, and re-exported by generatedImages. +// Two copies would drift: a width added for writing and not for deleting would +// be paid for once and then left in the bucket forever. +const THUMB_WIDTHS = Object.freeze([256, 640]); function thumbKey(id, width) { return 'thumbs/' + id + '/' + width; } function createStorage(env = process.env) { @@ -92,7 +96,18 @@ function createStorage(env = process.env) { for await (const chunk of response.Body) { size += chunk.length; if (size > MAX_BYTES) { response.Body.destroy(); throw new Error('Invalid stored image size'); } chunks.push(chunk); } return inspect(Buffer.concat(chunks), response.ContentType); }, + // Deleting an image means deleting the picture, not just the row that points + // at it. The original and both derived previews live under two different + // prefixes, so all three keys go — otherwise "delete" leaves the bytes in + // the bucket, paid for and still readable by anything holding credentials. + // + // A key that is already gone is success: S3 DeleteObject is idempotent, and + // a job whose thumbnails were never rendered has none to remove. + async remove(id) { + const keys = ['assets/' + id].concat(THUMB_WIDTHS.map(function (w) { return thumbKey(id, w); })); + for (const Key of keys) await client.send(new DeleteObjectCommand({ Bucket, Key })); + }, close() { client.destroy(); } }; } -module.exports = { MAX_BYTES, inspect, decodeBase64, download, createStorage }; +module.exports = { MAX_BYTES, THUMB_WIDTHS, inspect, decodeBase64, download, createStorage }; diff --git a/src/utils/generatedImages.js b/src/utils/generatedImages.js index a724a569..a1d287f9 100644 --- a/src/utils/generatedImages.js +++ b/src/utils/generatedImages.js @@ -43,7 +43,8 @@ function shouldRetryImageFallback(state) { // Previews are generated once and stored beside the original, so a 56px tile // costs a few kB instead of ~280kB. Only these widths are allowed: a caller // cannot ask for arbitrary sizes and turn this into a CPU amplifier. -const THUMB_WIDTHS = Object.freeze([256, 640]); +// One definition, in generatedImageStorage, which is also what deletes them. +const { THUMB_WIDTHS } = storageUtil; function thumbWidth(requested) { const width = Number(requested); @@ -239,7 +240,26 @@ async function warmThumbs(id) { } } -async function get(id, owner, workflow) { +// Remove a picture and everything derived from it. Storage first, then the row: + // a row without its object is a broken image in a gallery, while an object + // without its row is only wasted space, and this cannot produce the former. + async function discard(id, owner) { + if (!UUID.test(id)) throw failure(404, 'Image job not found'); + try { + await getStorage().remove(id); + } catch (e) { + // Bytes that could not be removed must not become a row that says they + // were: the caller is told to try again rather than shown a success that + // left the image in the bucket. + throw failure(503, 'Image storage is unavailable; nothing was deleted'); + } + const result = await db.query( + 'DELETE FROM generated_image_jobs WHERE id=$1 AND owner_id=$2 RETURNING id', [id, owner]); + if (!result.rows.length) throw failure(404, 'Image job not found'); + return { deleted: id }; + } + + async function get(id, owner, workflow) { if (!UUID.test(id)) throw failure(404, 'Image job not found'); const result = await db.query('SELECT id,stage,model,error_code,context_included,context_total,prompt_units,budget FROM generated_image_jobs WHERE id=$1 AND owner_id=$2 AND workflow=$3', [id, owner, workflow]); if (!result.rows[0]) throw failure(404, 'Image job not found'); @@ -272,7 +292,7 @@ async function get(id, owner, workflow) { if (active) await active; // The same storage client serves draining HTTP reads; process shutdown owns its lifetime. } - return { enqueue, get, asset, ready, snapshot, claim, tick, start, stop, thumbnail, warmThumbs }; + return { enqueue, get, asset, discard, ready, snapshot, claim, tick, start, stop, thumbnail, warmThumbs }; } let singleton; function service() { return singleton || (singleton = createImageService({ db: require('../db/database') })); } diff --git a/test/generated-image-cache.test.js b/test/generated-image-cache.test.js index 4ec59904..a5921804 100644 --- a/test/generated-image-cache.test.js +++ b/test/generated-image-cache.test.js @@ -49,7 +49,7 @@ 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, /const \{ THUMB_WIDTHS \} = storageUtil;/); 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/); diff --git a/test/image-library.test.js b/test/image-library.test.js new file mode 100644 index 00000000..f43dd9ac --- /dev/null +++ b/test/image-library.test.js @@ -0,0 +1,93 @@ +// The image library: every picture this account generated, across all three +// features, with thumbnails rather than originals and a delete that removes the +// bytes and not only the row. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8'); + +test('the listing is the owner’s own finished images, newest first', () => { + const route = read('src/routes/generatedImages.js'); + assert.match(route, /router\.get\('\/generated-images'/); + // Scoped by owner in the statement, not filtered afterwards. + assert.match(route, /owner_id=\$1 AND stage='done'/); + assert.match(route, /ORDER BY created_at DESC/); + // Unfinished jobs have nothing to show; listing one puts a broken frame in a + // gallery. + assert.match(route, /Only finished jobs/); +}); + +test('paging is keyset, not OFFSET', () => { + // A gallery that grows while you scroll repeats or skips a row under OFFSET. + const route = read('src/routes/generatedImages.js'); + assert.match(route, /created_at < \$/); + assert.doesNotMatch(route, /\bOFFSET\s+\$?\d/i, 'no SQL OFFSET; the word in a comment is fine'); + assert.match(route, /nextBefore/); +}); + +test('a prompt that cannot be decrypted does not cost the gallery its picture', () => { + const route = read('src/routes/generatedImages.js'); + assert.match(route, /try \{ prompt = String\(encryption\.decryptString/); + assert.match(route, /catch \(e\) \{ prompt = ''; \}/); +}); + +test('deleting removes the bytes before the row, and refuses if it cannot', () => { + // A row without its object is a broken image in a gallery; an object without + // its row is only wasted space. Only one of those is allowed to happen. + const lib = read('src/utils/generatedImages.js'); + assert.match(lib, /async function discard\(id, owner\)/); + const discard = lib.slice(lib.indexOf('async function discard')); + const storageFirst = discard.indexOf('getStorage().remove(id)'); + const rowSecond = discard.indexOf('DELETE FROM generated_image_jobs'); + assert.ok(storageFirst > -1 && rowSecond > storageFirst, 'storage must be removed first'); + assert.match(discard.slice(0, 800), /throw failure\(503, 'Image storage is unavailable; nothing was deleted'\)/); +}); + +test('deleting removes the previews too, not just the original', () => { + // Both derived widths live under their own prefix. Missing them leaves paid-for + // bytes in the bucket that are still readable. + const storage = read('src/utils/generatedImageStorage.js'); + assert.match(storage, /async remove\(id\)/); + assert.match(storage, /\['assets\/' \+ id\]\.concat\(THUMB_WIDTHS\.map/); +}); + +test('the thumbnail widths have one definition', () => { + // Two copies drift: a width written but never deleted is paid for once and + // then left in the bucket forever. + const storage = read('src/utils/generatedImageStorage.js'); + const lib = read('src/utils/generatedImages.js'); + assert.match(storage, /const THUMB_WIDTHS = Object\.freeze\(\[256, 640\]\)/); + assert.match(lib, /const \{ THUMB_WIDTHS \} = storageUtil/); + assert.doesNotMatch(lib, /THUMB_WIDTHS = Object\.freeze/, 'not defined twice'); + assert.deepEqual(require('../src/utils/generatedImages').THUMB_WIDTHS, [256, 640]); +}); + +test('a borrowed id deletes nothing rather than someone else’s picture', () => { + const route = read('src/routes/generatedImages.js'); + assert.match(route, /SELECT id FROM generated_image_jobs WHERE id=\$1 AND owner_id=\$2/); + const lib = read('src/utils/generatedImages.js'); + assert.match(lib, /DELETE FROM generated_image_jobs WHERE id=\$1 AND owner_id=\$2/); +}); + +test('the grid asks for the stored preview, not the original', () => { + // Thirty tiles at full size is thirty full-size downloads. + const ui = read('public/js/myResources.js'); + assert.match(ui, /setAttribute\('data-image-thumb', '256'\)/); + assert.match(ui, /hydrateImage\(img, image\.imageUrl\)/, + 'fetched through the authenticated loader, never a bare src'); +}); + +test('the caption is a model-written prompt and never reaches the page as HTML', () => { + const ui = read('public/js/myResources.js'); + const tile = ui.slice(ui.indexOf('function imageTile'), ui.indexOf('function openImage')); + assert.match(tile, /text\.textContent = image\.prompt/); + assert.doesNotMatch(tile, /innerHTML/); +}); + +test('the images tab loads on first open, not on page load', () => { + // Most visits never open it, and it costs a query plus a thumbnail per tile. + const ui = read('public/js/myResources.js'); + assert.match(ui, /if \(!docs && !imagesLoaded\) loadImages\(true\)/); +});