/** * Regression guard — the keepsake must not be sideways. * * Round 1 taught the compression worker to apply EXIF orientation, which fixed the live app * (feed preview + diashow display). The export worker was missed: it does NOT reuse those * derivatives — it re-decodes the originals itself with `image::open`, which ignores the * orientation tag — and then re-encodes to JPEG, which drops the tag, so the viewer has no * way to recover it. * * The resulting damage was oddly shaped, which is what made it read as a viewer bug: * - Gallery.zip originals → correct (byte-copied, EXIF intact) * - Memories viewer grid thumbnails → ALWAYS sideways * - Memories viewer full image >5 MB → sideways (re-encoded at 2000px) * - Memories viewer full image ≤5 MB → correct (streamed byte-for-byte) * * So clicking a small photo silently "fixed" it and a large one didn't. This pins the * thumbnail, which is the path every photo takes. */ import { test, expect } from '../../fixtures/test'; import { uploadRaw } from '../../helpers/upload-client'; import { BASE } from '../../helpers/env'; import { execFileSync } from 'node:child_process'; import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; // 40x20 landscape pixels tagged Orientation=6 ("rotate 90° CW to display"), so anything // that honours the tag emits a PORTRAIT derivative. const EXIF_FIXTURE = join(process.cwd(), 'fixtures', 'media', 'portrait-exif6.jpg'); /** Pixel dimensions from a JPEG's SOF marker — avoids an image dep for one assertion. */ function jpegSize(buf: Buffer): { width: number; height: number } { let i = 2; while (i < buf.length) { if (buf[i] !== 0xff) { i++; continue; } const marker = buf[i + 1]; if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) }; } i += 2 + buf.readUInt16BE(i + 2); } throw new Error('no SOF marker found — not a JPEG?'); } test.describe('Export — EXIF orientation in the keepsake', () => { test('the Memories viewer thumbnail of a rotated photo is upright', async ({ host, db }) => { test.setTimeout(90_000); const bearer = { Authorization: `Bearer ${host.jwt}` }; const src = readFileSync(EXIF_FIXTURE); // Sanity: the SOURCE really is stored landscape, or this test proves nothing. const srcSize = jpegSize(src); expect(srcSize.width).toBeGreaterThan(srcSize.height); const up = await uploadRaw(host.jwt, src, { filename: 'hochkant.jpg', contentType: 'image/jpeg', caption: 'hochkant', }); expect(up.status).toBe(201); const { id } = (await up.json()) as { id: string }; await expect.poll(() => db.compressionStatus(id), { timeout: 30_000 }).toBe('done'); const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, { method: 'POST', headers: bearer, }); expect(rel.status).toBe(204); await expect .poll( async () => { const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer }); return (await res.json()).html?.status; }, { timeout: 60_000, intervals: [500] } ) .toBe('done'); const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, { method: 'POST', headers: bearer, }); const { ticket } = (await ticketRes.json()) as { ticket: string }; const dl = await fetch(`${BASE}/api/v1/export/html?ticket=${encodeURIComponent(ticket)}`); expect(dl.status).toBe(200); const dir = mkdtempSync(join(tmpdir(), 'eventsnap-exif-')); try { const zipPath = join(dir, 'Memories.zip'); writeFileSync(zipPath, Buffer.from(await dl.arrayBuffer())); const entries = execFileSync('unzip', ['-Z1', zipPath], { encoding: 'utf8' }) .split('\n') .filter(Boolean); const thumbEntry = entries.find((e) => e.includes(`${id}_thumb`)); expect(thumbEntry, `no thumbnail for ${id} in Memories.zip`).toBeTruthy(); // `-p` streams the entry to stdout. Extracting to disk instead fails with EACCES: // the archive preserves the container's file mode, which the test user can't read. const thumb = execFileSync('unzip', ['-p', zipPath, thumbEntry!], { maxBuffer: 64 * 1024 * 1024, }); const { width, height } = jpegSize(thumb); expect( height, `the keepsake grid thumbnail is ${width}x${height} — EXIF orientation was not applied` ).toBeGreaterThan(width); } finally { rmSync(dir, { recursive: true, force: true }); } }); });