Files
picpeak/backend/__tests__/integration/previewTiers.test.js
T
Paul Nothaft acb25a9a1c fix(images): probe and clean up preview tiers under the extension the encoder actually wrote (#1355)
generatePreviewImage rewrites the output extension to match the encoding
it chose, .jpg or .webp for alpha and multi-frame sources. The tier
lookup in ensurePreviewImageAtWidth and the cleanup list in
previewTierKeys kept the SOURCE extension instead, so for anything but a
lowercase .jpg source the stat never matched: every tier request for a
.png, .JPG, .jpeg, .heic or RAW photo re-ran Sharp, and cleanup never
found the files it left behind, which accumulated for the life of the
install.

Both now derive every key the tier can live under: the .jpg and .webp
candidates, plus the source-extension key last so tiers written before
the rewrite are still found by lookup and by cleanup.

Follow-up to issue 1020, where the mismatch was identified during review.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-08 08:41:53 +02:00

466 lines
21 KiB
JavaScript

/**
* Responsive preview tiers (#1095).
*
* A phone can display ~1170px at most, so the single 1920px preview ships
* roughly twice the bytes it can use on every lightbox swipe — and the
* lightbox prefetches neighbours, so a guest flicking through a wedding
* gallery on cellular pays that repeatedly.
*
* The width is whitelisted rather than free-form: every distinct value is a
* permanent cache entry on disk, so an open ?w= is an invitation to fill the
* volume with renditions nobody asked for.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-tiers-'));
process.env.TEST_DATABASE_PATH = path.join(tmpRoot, 'db.sqlite');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'tiers-test-secret';
process.env.STORAGE_PATH = path.join(tmpRoot, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
const sharp = require('sharp');
const imageProcessor = require('../../src/services/imageProcessor');
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup;
describe('preview tiers (#1095)', () => {
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
await fs.promises.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
});
describe('normalizeTierWidth', () => {
const { normalizeTierWidth, PREVIEW_WIDTHS, THUMBNAIL_WIDTHS } = imageProcessor;
it('accepts every advertised width', () => {
for (const w of PREVIEW_WIDTHS) {
expect(normalizeTierWidth(String(w), PREVIEW_WIDTHS)).toBe(w);
}
for (const w of THUMBNAIL_WIDTHS) {
expect(normalizeTierWidth(String(w), THUMBNAIL_WIDTHS)).toBe(w);
}
});
it('rejects anything not on the list', () => {
// The disk-filling cases: arbitrary sizes, and a caller walking a range.
for (const bad of ['999', '1921', '0', '-100', '99999']) {
expect(normalizeTierWidth(bad, PREVIEW_WIDTHS)).toBeNull();
}
});
it('rejects junk without throwing', () => {
// Straight off a query string, so it is whatever the client sent.
for (const bad of [undefined, null, '', 'abc', '12abc', {}, [], '1e3', 'NaN']) {
expect(normalizeTierWidth(bad, PREVIEW_WIDTHS)).toBeNull();
}
});
it('does not let a thumbnail width through the preview list', () => {
// The two lists are separate on purpose; 600 is a thumb tier, not a
// preview tier, and vice versa for 1280.
expect(normalizeTierWidth('600', PREVIEW_WIDTHS)).toBeNull();
expect(normalizeTierWidth('1280', THUMBNAIL_WIDTHS)).toBeNull();
});
});
describe('ensurePreviewImageAtWidth', () => {
async function seedPhoto() {
const [e] = await db('events').insert({
slug: `tier-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'tier',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `tier-${Math.random()}`,
expires_at: new Date().toISOString(),
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
// A real image on disk under STORAGE_PATH, since the managed branch
// resolves through storage rather than a mount.
const rel = `events/active/tier/${Math.random().toString(36).slice(2, 8)}.jpg`;
const abs = path.join(process.env.STORAGE_PATH, rel);
await fs.promises.mkdir(path.dirname(abs), { recursive: true });
await sharp({ create: { width: 3000, height: 2000, channels: 3, background: { r: 10, g: 90, b: 160 } } })
.jpeg().toFile(abs);
const [p] = await db('photos').insert({
event_id: eventId,
filename: path.basename(rel),
path: rel.replace(/^events\/active\//, ''),
type: 'individual',
width: 3000,
height: 2000,
processing_status: 'complete',
source_origin: 'managed',
}).returning('id');
return db('photos').where({ id: typeof p === 'object' ? p.id : p }).first();
}
it('scopes keys by photo id so two galleries cannot collide', async () => {
// The leak: managed auto-imports keep camera basenames, so two events can
// each hold an IMG_0001.jpg. A tier is served straight from a cache hit
// without re-reading the source, so a shared key hands one gallery's
// photo to another.
const a = await seedPhoto();
const b = await seedPhoto();
await db('photos').where({ id: a.id }).update({ path: 'wedding-a/IMG_0001.jpg' });
await db('photos').where({ id: b.id }).update({ path: 'wedding-b/IMG_0001.jpg' });
const keyA = imageProcessor.previewTierKeys(await db('photos').where({ id: a.id }).first())[0];
const keyB = imageProcessor.previewTierKeys(await db('photos').where({ id: b.id }).first())[0];
expect(keyA).not.toBe(keyB);
expect(keyA).toContain(`p${a.id}_`);
expect(keyB).toContain(`p${b.id}_`);
});
it('derives every non-default tier key for cleanup', () => {
// Tiers live outside preview_path, so delete/archive/regenerate have no
// other way to find them. 1920 is excluded because that IS preview_path.
// Two candidates per width — the encoder picks `.jpg` or `.webp` and the
// cleanup list cannot know which without probing the source.
const keys = imageProcessor.previewTierKeys({ id: 5, path: 'e/a.jpg', source_origin: 'managed' });
const widths = imageProcessor.PREVIEW_WIDTHS.filter((w) => w !== 1920);
expect(keys).toHaveLength(widths.length * 2);
for (const w of widths) {
expect(keys).toContain(`previews/preview_w${w}_p5_a.jpg`);
expect(keys).toContain(`previews/preview_w${w}_p5_a.webp`);
}
expect(keys.some((k) => k.includes('w1920'))).toBe(false);
expect(keys.every((k) => k.includes('p5_'))).toBe(true);
});
it('deletePreviewTiers removes generated tiers from storage', async () => {
const photo = await seedPhoto();
const key = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
const abs = path.join(process.env.STORAGE_PATH, key);
expect(fs.existsSync(abs)).toBe(true);
await imageProcessor.deletePreviewTiers(await db('photos').where({ id: photo.id }).first());
expect(fs.existsSync(abs)).toBe(false);
});
it('produces a distinct key per width and never touches preview_path', async () => {
const photo = await seedPhoto();
const small = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
expect(small).toContain('preview_w640_');
// The extra tiers are cache, not state. Writing them to the row would
// mean the last size requested silently becomes "the" preview.
const row = await db('photos').where({ id: photo.id }).first();
expect(row.preview_path == null || !String(row.preview_path).includes('w640')).toBe(true);
});
it('resolves the default width to the canonical preview, not a w1920 copy', async () => {
// Otherwise every existing install grows a duplicate of every preview it
// already has, for no benefit.
const photo = await seedPhoto();
const def = await imageProcessor.ensurePreviewImageAtWidth(photo, 1920);
expect(def).not.toContain('preview_w1920_');
});
it('reuses the cached tier instead of regenerating', async () => {
const photo = await seedPhoto();
const first = await imageProcessor.ensurePreviewImageAtWidth(photo, 1280);
expect(first).toBeTruthy();
const abs = path.join(process.env.STORAGE_PATH, first);
const before = (await fs.promises.stat(abs)).mtimeMs;
await new Promise((r) => setTimeout(r, 20));
const second = await imageProcessor.ensurePreviewImageAtWidth(photo, 1280);
expect(second).toBe(first);
expect((await fs.promises.stat(abs)).mtimeMs).toBe(before);
});
it('actually resizes to the requested tier', async () => {
const photo = await seedPhoto();
const key = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
const meta = await sharp(path.join(process.env.STORAGE_PATH, key)).metadata();
// 3000x2000 constrained to a 640 long edge.
expect(Math.max(meta.width, meta.height)).toBe(640);
expect(meta.height).toBe(Math.round(640 * (2000 / 3000)));
});
});
describe('thumbnail tiers', () => {
async function seedThumbPhoto(w = 3000, h = 2000) {
const [e] = await db('events').insert({
slug: `tt-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding', event_name: 'tt', event_date: '2026-01-01',
host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: `tt-${Math.random()}`,
expires_at: new Date().toISOString(),
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
const rel = `events/active/tt/${Math.random().toString(36).slice(2, 8)}.jpg`;
const abs = path.join(process.env.STORAGE_PATH, rel);
await fs.promises.mkdir(path.dirname(abs), { recursive: true });
await sharp({ create: { width: w, height: h, channels: 3, background: { r: 5, g: 5, b: 5 } } })
.jpeg().toFile(abs);
const [p2] = await db('photos').insert({
event_id: eventId, filename: path.basename(rel),
path: rel.replace(/^events\/active\//, ''), type: 'individual',
width: w, height: h, processing_status: 'complete', source_origin: 'managed',
}).returning('id');
return db('photos').where({ id: typeof p2 === 'object' ? p2.id : p2 }).first();
}
it('scopes thumbnail tier keys by photo id', async () => {
// Same cross-gallery hazard the preview tiers had: a cache hit serves
// without re-reading the source, so a shared basename leaks across events.
const keys = imageProcessor.thumbnailTierKeys({ id: 42, path: 'a/IMG_0001.jpg', source_origin: 'managed' });
expect(keys.every((k) => k.includes('p42_'))).toBe(true);
// Every width, canonical included: which one is canonical depends on the
// thumbnail_width setting, so on a 600-configured install w300 is the
// tier file. Deleting a key that was never written is a no-op; missing
// one strands it forever.
expect(keys).toHaveLength(3);
});
it('tags the tier against the configured width, not the 300 default', async () => {
// Regression: with thumbnail_width=600 a w=300 request wrote
// `thumb_<name>` while the caller probed `thumb_w300_<name>`. The cache
// never hit, so every request re-downloaded the original and ran Sharp,
// and the file it left behind was in no cleanup list.
await db('app_settings').where('setting_key', 'thumbnail_width')
.update({ setting_value: 600 });
try {
const photo = await seedThumbPhoto();
const first = await imageProcessor.ensureThumbnailAtWidth(photo, 300);
expect(first).toContain('thumb_w300_');
// The second call must be a cache hit on the key the first one wrote.
const before = fs.statSync(path.join(process.env.STORAGE_PATH, first)).mtimeMs;
const second = await imageProcessor.ensureThumbnailAtWidth(photo, 300);
expect(second).toBe(first);
expect(fs.statSync(path.join(process.env.STORAGE_PATH, second)).mtimeMs).toBe(before);
// ...and 600 is now the canonical, so it resolves to the plain thumbnail.
const canonical = await imageProcessor.ensureThumbnailAtWidth(photo, 600);
expect(canonical).not.toContain('thumb_w600_');
// Cleanup still reaches the w300 tier this install actually generated.
expect(imageProcessor.thumbnailTierKeys(photo)).toContain(first);
} finally {
await db('app_settings').where('setting_key', 'thumbnail_width')
.update({ setting_value: 300 });
}
});
it('generates a tier at the requested size', async () => {
const photo = await seedThumbPhoto();
const key = await imageProcessor.ensureThumbnailAtWidth(photo, 600);
expect(key).toContain('thumb_w600_');
const meta = await sharp(path.join(process.env.STORAGE_PATH, key)).metadata();
expect(Math.max(meta.width, meta.height)).toBe(600);
});
it('does not upscale past the source, which is why the tier is clamped', async () => {
// The reason tileThumbnailWidth checks the short edge: ask a 400px
// source for 900 and withoutEnlargement caps it, so the request buys a
// Sharp run and a second cache entry for a file identical to the 300.
const small = await seedThumbPhoto(500, 400);
const key = await imageProcessor.ensureThumbnailAtWidth(small, 900);
const meta = await sharp(path.join(process.env.STORAGE_PATH, key)).metadata();
expect(Math.max(meta.width, meta.height)).toBeLessThan(900);
});
it('resolves the canonical width to the normal thumbnail', async () => {
const photo = await seedThumbPhoto();
const key = await imageProcessor.ensureThumbnailAtWidth(photo, 300);
expect(key).not.toContain('thumb_w300_');
});
it('keeps the configured aspect ratio instead of forcing a square', async () => {
// Thumbnails are square by default, but the settings API takes any
// width/height in 50..1000. With fit:'cover' a 300x200 canonical and a
// 600x600 tier are two different crops, so the photo would visibly
// reframe as the tile size changed.
await db('app_settings').where('setting_key', 'thumbnail_height')
.update({ setting_value: 200 });
try {
const photo = await seedThumbPhoto();
const key = await imageProcessor.ensureThumbnailAtWidth(photo, 600);
const meta = await sharp(path.join(process.env.STORAGE_PATH, key)).metadata();
expect(meta.width).toBe(600);
expect(meta.height).toBe(400); // 600 * (200/300), not 600
} finally {
await db('app_settings').where('setting_key', 'thumbnail_height')
.update({ setting_value: 300 });
}
});
it('never hands a video to Sharp', async () => {
// A video's thumbnail is a poster frame from videoProcessor, not a
// resize of the stored file. Without the short-circuit the tier path
// would download the whole video (withLocalCopy, in full on S3) and
// then fail to decode it — every request, since nothing caches a miss.
const photo = await seedThumbPhoto();
await db('photos').where({ id: photo.id })
.update({ media_type: 'video', mime_type: 'video/mp4' });
const video = await db('photos').where({ id: photo.id }).first();
const key = await imageProcessor.ensureThumbnailAtWidth(video, 900);
expect(key).not.toContain('thumb_w900_');
});
it('drops tiers when a rename moves the basename they are keyed on', async () => {
// The key embeds the basename, so the DB update in renamePhotoFiles is
// the point past which the old keys cannot be derived at all — a later
// delete or archive computes the new ones and leaves these behind.
const renameService = require('../../src/services/eventRenameService');
const photo = await seedThumbPhoto();
const event = await db('events').where({ id: photo.event_id }).first();
// Give it a filename the rename will actually rewrite.
const dir = path.join(process.env.STORAGE_PATH, 'events/active', event.slug, 'individual');
await fs.promises.mkdir(dir, { recursive: true });
await sharp({ create: { width: 1200, height: 900, channels: 3, background: { r: 7, g: 7, b: 7 } } })
.jpeg().toFile(path.join(dir, 'Old_Name_001.jpg'));
await db('photos').where({ id: photo.id }).update({
filename: 'Old_Name_001.jpg',
path: `${event.slug}/individual/Old_Name_001.jpg`,
});
const renamable = await db('photos').where({ id: photo.id }).first();
const key = await imageProcessor.ensureThumbnailAtWidth(renamable, 600);
const abs = path.join(process.env.STORAGE_PATH, key);
expect(fs.existsSync(abs)).toBe(true);
await renameService.renamePhotoFiles(
event.id, 'Old Name', 'New Name', event.slug, event.slug
);
expect(await db('photos').where({ id: photo.id }).first())
.toMatchObject({ filename: 'New_Name_001.jpg' });
expect(fs.existsSync(abs)).toBe(false);
});
it('leaves tiers alone when a rename does not move the basename', async () => {
// Four storage deletes per photo is 20k calls against S3 for a
// 5,000-photo event whose slug merely changed, so the sweep is gated on
// the filename actually moving.
const renameService = require('../../src/services/eventRenameService');
const photo = await seedThumbPhoto();
const event = await db('events').where({ id: photo.event_id }).first();
const key = await imageProcessor.ensureThumbnailAtWidth(photo, 600);
const abs = path.join(process.env.STORAGE_PATH, key);
// The photo's filename carries no event-name prefix, so nothing moves.
await renameService.renamePhotoFiles(
event.id, 'Old Name', 'New Name', event.slug, event.slug
);
expect(fs.existsSync(abs)).toBe(true);
});
it('deleteThumbnailTiers removes them', async () => {
const photo = await seedThumbPhoto();
const key = await imageProcessor.ensureThumbnailAtWidth(photo, 600);
const abs = path.join(process.env.STORAGE_PATH, key);
expect(fs.existsSync(abs)).toBe(true);
await imageProcessor.deleteThumbnailTiers(await db('photos').where({ id: photo.id }).first());
expect(fs.existsSync(abs)).toBe(false);
});
/**
* The crash in #1128 needed two things: a tier that disappears, and a
* reader that dies on it. The reader is fixed in streamResponse; this is
* the half that stops the file disappearing in the first place.
*/
describe('concurrent generation (#1128)', () => {
it('never leaves the tier absent once it has been published', async () => {
const photo = await seedThumbPhoto();
const key = imageProcessor.thumbnailTierKeys(photo).find((k) => k.includes('_w600_'));
const abs = path.join(process.env.STORAGE_PATH, key);
// A grid fires one request per tile at once, and on a cold gallery
// every one of them misses the cache. Previously each carried
// `regenerate: true`, whose first act is to DELETE the target — so a
// later arrival unlinked the file an earlier one had already published
// and handed to a reader.
const watcher = [];
const poll = setInterval(() => watcher.push(fs.existsSync(abs)), 1);
const results = await Promise.all(
Array.from({ length: 12 }, () => imageProcessor.ensureThumbnailAtWidth(photo, 600))
);
clearInterval(poll);
expect(results.every((r) => r === key)).toBe(true);
expect(fs.existsSync(abs)).toBe(true);
// Once true, never false again: no window where a validated file is gone.
const firstSeen = watcher.indexOf(true);
if (firstSeen !== -1) {
expect(watcher.slice(firstSeen).every(Boolean)).toBe(true);
}
});
it('runs one generation for a burst of requests, not one per request', async () => {
const photo = await seedThumbPhoto();
const thumbDir = path.join(process.env.STORAGE_PATH, 'thumbnails');
await fs.promises.mkdir(thumbDir, { recursive: true });
// Counted through the staging files LocalFsStorage writes:
// `<key>.tmp.<pid>.<hex>`, one per put, each a distinct random suffix.
// So distinct temp names == distinct generations, which is the thing
// the dedupe is supposed to collapse. (Spying on generateThumbnail
// would not work — ensureThumbnailAtWidth calls it through the
// module-local binding, so an export spy never sees it.)
const seen = new Set();
const poll = setInterval(() => {
for (const f of fs.readdirSync(thumbDir)) {
if (f.includes('_w900_') && f.includes('.tmp.')) seen.add(f);
}
}, 1);
const results = await Promise.all(
Array.from({ length: 8 }, () => imageProcessor.ensureThumbnailAtWidth(photo, 900))
);
clearInterval(poll);
const abs = path.join(
process.env.STORAGE_PATH,
imageProcessor.thumbnailTierKeys(photo).find((k) => k.includes('_w900_'))
);
expect(fs.existsSync(abs)).toBe(true);
expect(new Set(results).size).toBe(1);
// 8 requests, at most one Sharp pass. Before the dedupe this was 8 —
// and on an external photo, 8 full reads of the original.
expect(seen.size).toBeLessThanOrEqual(1);
});
it('does not cache a failure — a later request retries', async () => {
const photo = await seedThumbPhoto();
// Source removed underneath: generation fails and must not poison the
// key for the lifetime of the process.
const src = path.join(process.env.STORAGE_PATH, 'events/active', photo.path);
const saved = await fs.promises.readFile(src);
await fs.promises.unlink(src);
expect(await imageProcessor.ensureThumbnailAtWidth(photo, 600)).toBeNull();
await fs.promises.writeFile(src, saved);
expect(await imageProcessor.ensureThumbnailAtWidth(photo, 600)).toContain('_w600_');
});
});
});
});