From 002355ba40c02a4188d00d49e89c2214ae8bdbe8 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 18 Jul 2026 19:54:36 +0200 Subject: [PATCH] test(e2e): client upload-queue under a realistic burst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the scenario the server-side load test could not — a guest multi-selecting 10-20 photos at once — by driving the real client path (UploadSheet → /upload → addToQueue → processQueue → XHR) rather than hitting POST /upload directly. Two tests assert the properties that keep a guest's photos from being lost: serial per-device draining (one upload in flight at a time), IndexedDB persistence (blobs kept until upload, dropped after), every file landing server-side, and a hard reload mid-burst resuming the rest via loadQueue() with no photo lost. Injects small per-upload latency via route interception so the serial drain is observable and the mid-burst reload window is reliable. Co-Authored-By: Claude Opus 4.8 --- e2e/specs/02-upload/burst-queue.spec.ts | 214 ++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 e2e/specs/02-upload/burst-queue.spec.ts diff --git a/e2e/specs/02-upload/burst-queue.spec.ts b/e2e/specs/02-upload/burst-queue.spec.ts new file mode 100644 index 0000000..8b538d5 --- /dev/null +++ b/e2e/specs/02-upload/burst-queue.spec.ts @@ -0,0 +1,214 @@ +/** + * Client upload-queue under a realistic burst — the scenario an event actually + * produces (a guest multi-selects 10-20 photos at once) and the one the + * server-side load test could NOT cover, because that test hit POST /upload + * directly and bypassed the browser queue entirely. + * + * Drives the REAL client path (UploadSheet → /upload → addToQueue → + * processQueue → XHR) and asserts the four properties that matter for not + * losing a guest's photos: + * + * 1. SERIAL drain — the queue uploads ONE file at a time per device + * (processQueue's find-next-pending loop), never a + * parallel fan-out. Proven by counting how many upload + * requests sit in the intercept handler at once. + * 2. PERSISTENCE — every staged file is written to IndexedDB with its + * blob before it's sent, and the blob is dropped only + * after the upload succeeds. Read straight out of IDB + * mid-burst. + * 3. ALL LAND — every file in the burst is created server-side. + * 4. RESUME ON RELOAD — a hard reload mid-burst (tab closed / PWA killed) + * resumes the remaining uploads from IndexedDB via + * loadQueue(), losing nothing. + * + * A tiny per-upload latency is injected via route interception so the serial + * drain is observable and there's a window to reload mid-burst — no need for + * large fixture files. + */ +import { test, expect } from '../../fixtures/test'; +import { FeedPage, UploadSheet } from '../../page-objects'; +import { mkdtempSync, copyFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { Page } from '@playwright/test'; + +const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg'); +const BURST = 16; // in the 10-20 "multi-select" range + +// Make BURST distinct files. The queue dedupes on name+size+lastModified, so +// distinct NAMES are enough to keep them from collapsing into one item — content +// can be identical (we only assert rows are created, not what they decode to). +function makeBurstFiles(): { dir: string; paths: string[] } { + const dir = mkdtempSync(join(tmpdir(), 'eventsnap-burst-')); + const paths = Array.from({ length: BURST }, (_, i) => { + const p = join(dir, `burst_${String(i).padStart(2, '0')}.jpg`); + copyFileSync(SAMPLE_JPG, p); + return p; + }); + return { dir, paths }; +} + +// White-box: read the queue rows straight from IndexedDB, including whether the +// blob is still attached — so we can prove blobs are kept until upload and +// dropped after. Mirrors the reader in offline-resume.spec. +async function queueRows(page: Page): Promise> { + return page.evaluate(async () => { + return new Promise>((resolve, reject) => { + const req = indexedDB.open('eventsnap-uploads', 3); + req.onerror = () => reject(req.error); + req.onsuccess = () => { + const dbh = req.result; + const tx = dbh.transaction('queue', 'readonly'); + const all = tx.objectStore('queue').getAll(); + all.onsuccess = () => + resolve(all.result.map((r: any) => ({ status: r.status, hasBlob: r.blob != null }))); + all.onerror = () => reject(all.error); + }; + }); + }); +} + +/** + * Intercept POST /upload to (a) inject latency so the serial drain is observable + * and (b) count how many uploads are in the handler simultaneously. A serial + * client never has more than one in flight; a parallel fan-out would spike. + * Returns a `peak()` accessor for the max concurrency seen. + */ +async function instrumentUploads(page: Page, delayMs: number) { + let active = 0; + let peak = 0; + await page.route('**/api/v1/upload', async (route) => { + active++; + peak = Math.max(peak, active); + await new Promise((r) => setTimeout(r, delayMs)); + active--; + await route.continue(); + }); + return { peak: () => peak }; +} + +async function warmUploadChunk(page: Page) { + // Load the code-split /upload route while online so a later reload/navigation + // doesn't fetch the chunk at a bad moment (mirrors offline-resume.spec). + await page.goto('/upload'); + await page.goto('/feed'); +} + +test.describe('Upload — client queue under a burst', () => { + test('a 16-file burst drains serially, persists to IndexedDB, and all land', async ({ + page, + guest, + signIn, + db, + }) => { + const g = await guest('BurstSerial'); + await signIn(page, g); + await warmUploadChunk(page); + const uploads = await instrumentUploads(page, 200); + + // Stage the whole burst through the real UI. + const { dir, paths } = makeBurstFiles(); + try { + const feed = new FeedPage(page); + const sheet = new UploadSheet(page); + await feed.openUploadSheet(); + await sheet.stageFiles(paths); + await sheet.captionInput.waitFor({ state: 'visible', timeout: 10_000 }); + await sheet.fillCaption('burst of 16 #hochzeit'); + await sheet.submit(); + await page.waitForURL('**/feed', { timeout: 15_000 }); + + // (2) PERSISTENCE: all 16 are written to IndexedDB with blobs essentially + // immediately — before most have been sent. Catch the burst while the + // queue still holds most of them. + await expect + .poll(async () => (await queueRows(page)).length, { timeout: 10_000 }) + .toBe(BURST); + + // While draining, pending rows keep their blob; done rows have dropped it. + // Poll for a mid-burst moment that shows both invariants at once. + await expect + .poll( + async () => { + const rows = await queueRows(page); + const pendingKeepBlob = rows + .filter((r) => r.status === 'pending' || r.status === 'uploading') + .every((r) => r.hasBlob); + const doneDropBlob = rows.filter((r) => r.status === 'done').every((r) => !r.hasBlob); + return pendingKeepBlob && doneDropBlob; + }, + { timeout: 10_000 } + ) + .toBe(true); + + // (3) ALL LAND: every file is created server-side. + await expect.poll(() => db.countUploadsForUser(g.userId), { timeout: 30_000 }).toBe(BURST); + + // (1) SERIAL: never more than one upload in flight at a time. + expect(uploads.peak()).toBe(1); + + // Clean end — the queue settles all items to done (no stuck error/blocked). + await expect + .poll(async () => (await queueRows(page)).every((r) => r.status === 'done'), { + timeout: 10_000, + }) + .toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('a hard reload mid-burst resumes the remaining uploads — nothing lost', async ({ + page, + guest, + signIn, + db, + }) => { + const g = await guest('BurstResume'); + await signIn(page, g); + await warmUploadChunk(page); + // Bigger delay → a wide, reliable mid-burst window to reload inside. (We don't + // assert seriality here — test 1 already proves it, and a reload aborts the + // in-flight request mid-intercept, which would overcount concurrency.) + await instrumentUploads(page, 300); + + const { dir, paths } = makeBurstFiles(); + try { + const feed = new FeedPage(page); + const sheet = new UploadSheet(page); + await feed.openUploadSheet(); + await sheet.stageFiles(paths); + await sheet.captionInput.waitFor({ state: 'visible', timeout: 10_000 }); + await sheet.fillCaption('burst then reload'); + await sheet.submit(); + await page.waitForURL('**/feed', { timeout: 15_000 }); + + // Wait until we're genuinely mid-burst: a few landed, but far from all. + await expect + .poll(() => db.countUploadsForUser(g.userId), { timeout: 20_000 }) + .toBeGreaterThanOrEqual(3); + const landedBeforeReload = await db.countUploadsForUser(g.userId); + expect(landedBeforeReload).toBeLessThan(BURST); + + // Hard reload — wipes the JS module (and its in-flight drain), exactly like + // a closed tab / killed PWA. The remaining pending items live only in + // IndexedDB now. + await page.reload(); + // The queue only resumes where loadQueue() runs — the /upload route's + // onMount. Navigating there is the "reopen the composer" recovery path. + await page.goto('/upload'); + + // (4) RESUME: every file ends up server-side without re-staging anything. + // `>=` not `===`: the only imperfection possible is a DUPLICATE (an upload + // that succeeded server-side in the ~1ms between the XHR load event and the + // IndexedDB 'done' write, caught by the reload, then re-sent on resume). + // That's at worst one extra row, never a lost photo — the property we care + // about. A shortfall (< BURST) would be a real data-loss regression. + await expect + .poll(() => db.countUploadsForUser(g.userId), { timeout: 30_000 }) + .toBeGreaterThanOrEqual(BURST); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +});