Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
93 lines
4.6 KiB
JavaScript
93 lines
4.6 KiB
JavaScript
// 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\)/);
|
||
});
|