From 136417d6b410f3c67c128911b73f660ee7cf3a35 Mon Sep 17 00:00:00 2001 From: fabi Date: Wed, 1 Jul 2026 19:05:53 +0200 Subject: [PATCH] test(e2e): make vacuous feed/SSE tests assert real behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several tests ran green while asserting nothing. Replace them with real assertions, and fix the SSE helper they depend on. sse-listener: exchange the JWT for a single-use ticket (POST /stream/ticket) and connect via ?ticket= — the helper still used the dead ?token= scheme, so every SSE-based assertion would have silently failed to receive events. like-comment: - "like is idempotent" asserted nothing (void feed; void b) → now seeds a real upload and pins the like contract: counted once per user, toggles off on repeat (guards double-count), and a second user's like is counted independently. - "comment → SSE to B" asserted length >= 0 (always true) → B now subscribes to the stream, A comments, and B must receive the new-comment event for that upload (comment_count === 1). ~30s due to reverse-proxy SSE buffering; timeout raised. sse-realtime: only checked a nav link was visible → now counts EventSource opens and asserts a fresh stream connection after hidden→visible (also fixes the sim, which set visibilityState but not document.hidden, so the close never fired). multi-tab "SSE delivers to both": only checked nav links → now asserts each tab opens its own stream connection (delivery isn't asserted — it hinges on the ~30s proxy buffering; connection establishment is the reliable, honest signal). safe-area: delete the /join probe whose only assertion was Array.isArray(x) === true (always true); the real sheet-level env() check already exists below it. All verified green against the live backend. Co-Authored-By: Claude Opus 4.8 --- e2e/helpers/sse-listener.ts | 17 ++- e2e/specs/03-feed/like-comment.spec.ts | 108 +++++++++++++++---- e2e/specs/03-feed/sse-realtime.spec.ts | 44 ++++++-- e2e/specs/08-browser-chaos/multi-tab.spec.ts | 24 ++++- e2e/specs/09-mobile/safe-area.spec.ts | 16 +-- 5 files changed, 155 insertions(+), 54 deletions(-) diff --git a/e2e/helpers/sse-listener.ts b/e2e/helpers/sse-listener.ts index 08a0fd7..3c4b79d 100644 --- a/e2e/helpers/sse-listener.ts +++ b/e2e/helpers/sse-listener.ts @@ -3,8 +3,9 @@ * `like-update` arrived for upload X within 5 seconds" without driving a * second browser tab. * - * The backend authenticates the SSE endpoint via `?token=` query param - * (the EventSource API can't set headers). + * The backend authenticates the SSE endpoint via a single-use `?ticket=` minted + * at POST /api/v1/stream/ticket (the raw JWT is never put in the URL). This helper + * does that exchange internally, so callers still just pass a JWT to `start()`. */ export type SseEvent = { type: string; data: any; receivedAt: number }; @@ -17,7 +18,17 @@ export class SseListener { constructor(private baseUrl: string = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') {} async start(token: string): Promise { - const url = `${this.baseUrl}/api/v1/stream?token=${encodeURIComponent(token)}`; + // Exchange the JWT for a single-use SSE ticket (the stream endpoint no longer + // accepts ?token=). + const ticketRes = await fetch(`${this.baseUrl}/api/v1/stream/ticket`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + if (ticketRes.status !== 200) { + throw new Error(`SSE ticket mint failed: ${ticketRes.status} ${await ticketRes.text()}`); + } + const { ticket } = await ticketRes.json(); + const url = `${this.baseUrl}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`; // Use fetch with streaming since Node has no EventSource by default. const res = await fetch(url, { signal: this.controller.signal }); if (!res.body) throw new Error('SSE response has no body'); diff --git a/e2e/specs/03-feed/like-comment.spec.ts b/e2e/specs/03-feed/like-comment.spec.ts index 269265b..916eae5 100644 --- a/e2e/specs/03-feed/like-comment.spec.ts +++ b/e2e/specs/03-feed/like-comment.spec.ts @@ -1,36 +1,100 @@ /** - * USER_JOURNEYS.md §7 — liking and commenting. SSE round-trip is - * asserted by opening a second tab as a different user. + * USER_JOURNEYS.md §7 — liking and commenting. + * + * Like behavior is asserted deterministically via the feed snapshot; the comment + * SSE round-trip is asserted by subscribing to the stream as a second user and + * waiting for the `new-comment` event to arrive. */ import { test, expect } from '../../fixtures/test'; import { SseListener } from '../../helpers/sse-listener'; +import { uploadRaw, JPEG_MAGIC } from '../../helpers/upload-client'; + +const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; + +async function seedUpload(jwt: string, db: any, caption?: string): Promise { + const body = new Uint8Array(1024); + body.set(JPEG_MAGIC, 0); + const res = await uploadRaw(jwt, body, { filename: 'a.jpg', contentType: 'image/jpeg', caption }); + if (res.status !== 201) throw new Error(`seed upload failed: ${res.status} ${await res.text()}`); + const { id } = await res.json(); + await db.setUploadCompressionStatus(id, 'done'); + return id; +} + +async function like(jwt: string, uploadId: string): Promise { + const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/like`, { + method: 'POST', + headers: { Authorization: `Bearer ${jwt}` }, + }); + return res.status; +} + +function findRow(feed: any, id: string): any { + const list: any[] = feed.uploads ?? feed.items ?? feed; + return list.find((u: any) => u.id === id); +} test.describe('Feed — like + comment', () => { - test('like is idempotent against rapid double-click', async ({ api, guest }) => { - const a = await guest('Liker'); - // Seed an upload from a second user so `a` has something to like. - const b = await guest('Author'); - // Without a multipart helper in Node, we exercise the like endpoint directly - // and assert behavior via the public feed snapshot. - // (Spec is a placeholder until we add a Node-side upload helper or do - // the seed via UI.) - const feed = await api.getFeed(a.jwt); - void feed; - void b; + test('a like counts once per user and toggles off on repeat (no double-count)', async ({ api, guest, db }) => { + const author = await guest('Author'); + const liker = await guest('Liker'); + const uploadId = await seedUpload(author.jwt, db); + + // Baseline: nobody has liked yet. + let row = findRow(await api.getFeed(liker.jwt), uploadId); + expect(row.like_count).toBe(0); + expect(row.liked_by_me).toBe(false); + + // First like → counted exactly once. + expect(await like(liker.jwt, uploadId)).toBe(204); + row = findRow(await api.getFeed(liker.jwt), uploadId); + expect(row.like_count).toBe(1); + expect(row.liked_by_me).toBe(true); + + // Liking again is a toggle → back to zero (guards against a regression that + // double-counts a repeated like instead of removing it). + expect(await like(liker.jwt, uploadId)).toBe(204); + row = findRow(await api.getFeed(liker.jwt), uploadId); + expect(row.like_count).toBe(0); + expect(row.liked_by_me).toBe(false); + + // A second distinct user's like is counted independently (per-user semantics). + expect(await like(liker.jwt, uploadId)).toBe(204); // liker likes again → 1 + expect(await like(author.jwt, uploadId)).toBe(204); // author likes too → 2 + row = findRow(await api.getFeed(liker.jwt), uploadId); + expect(row.like_count).toBe(2); }); - test('comment by user A → SSE new-comment delivered to user B', async ({ guest }) => { - const a = await guest('A'); - const b = await guest('B'); + test('comment by user A → SSE new-comment delivered to user B', async ({ guest, db }) => { + // SSE frames can take a keep-alive tick to flush through the reverse proxy, so + // both the stream connect and the delivery may cost up to ~30s each. + test.setTimeout(120_000); + const a = await guest('CommenterA'); + const b = await guest('ListenerB'); + // B subscribes to the stream BEFORE A comments, so the broadcast is captured. const sse = new SseListener(); await sse.start(b.jwt); - // Without an upload helper, this currently only verifies that the SSE stream - // *connects* for a guest. The comment send + receive assertion lands as soon - // as we add a backend-side helper to inject uploads bypassing multipart. - expect(sse.allEvents().length).toBeGreaterThanOrEqual(0); - sse.stop(); - void a; + try { + const uploadId = await seedUpload(a.jwt, db, 'pic'); + const cRes = await fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, { + method: 'POST', + headers: { Authorization: `Bearer ${a.jwt}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ body: 'hello from A' }), + }); + expect(cRes.status).toBe(201); + + // B must receive the new-comment event for this upload. Generous timeout: SSE + // frames flush on the keep-alive tick through the compressing reverse proxy. + const evt = await sse.waitForEvent( + 'new-comment', + (e) => e.data?.upload_id === uploadId, + 45_000 + ); + expect(evt.data.comment_count).toBe(1); + } finally { + sse.stop(); + } }); }); diff --git a/e2e/specs/03-feed/sse-realtime.spec.ts b/e2e/specs/03-feed/sse-realtime.spec.ts index 1fa1676..436b530 100644 --- a/e2e/specs/03-feed/sse-realtime.spec.ts +++ b/e2e/specs/03-feed/sse-realtime.spec.ts @@ -1,27 +1,51 @@ /** * SSE reconnection after tab background. USER_JOURNEYS.md §17 / edge cases. + * + * The app closes the EventSource on `document.hidden` and reopens it (minting a + * fresh ticket + new EventSource) when the tab becomes visible again — see + * frontend/src/lib/sse.ts. We assert the reconnect by counting stream-open + * requests rather than event delivery, which keeps the test fast and independent + * of the reverse proxy's SSE buffering. */ import { test, expect } from '../../fixtures/test'; +/** Count EventSource opens (GET /api/v1/stream?ticket=…), not the ticket POST. */ +function trackStreamOpens(page: import('@playwright/test').Page): () => number { + let n = 0; + page.on('request', (req) => { + if (req.method() === 'GET' && req.url().includes('/api/v1/stream?')) n++; + }); + return () => n; +} + test.describe('Feed — SSE behavior', () => { - test('SSE reconnects after tab visibility goes hidden then visible', async ({ page, guest, signIn }) => { - const h = await guest('SseReconnect'); - await signIn(page, h); - await page.goto('/feed'); + test('backgrounding then foregrounding the tab opens a fresh SSE connection', async ({ page, guest, signIn }) => { + const g = await guest('SseReconnect'); + const streamOpens = trackStreamOpens(page); - // Force-fire a visibilitychange to hidden, then back to visible. The app's - // sse.ts is expected to close + reopen the EventSource around this. + await signIn(page, g); // lands on /feed, which calls connectSse() on mount + // Initial connection established. + await expect.poll(streamOpens, { timeout: 10_000 }).toBeGreaterThanOrEqual(1); + const afterInitial = streamOpens(); + + // Background: the visibility handler reads document.hidden, so override that + // (not just visibilityState) before dispatching, or the close never fires. await page.evaluate(() => { - Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' }); + Object.defineProperty(document, 'hidden', { configurable: true, get: () => true }); + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' }); document.dispatchEvent(new Event('visibilitychange')); }); - await page.waitForTimeout(500); + // Foreground again → connectSse() mints a new ticket and opens a new EventSource. await page.evaluate(() => { - Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' }); + Object.defineProperty(document, 'hidden', { configurable: true, get: () => false }); + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' }); document.dispatchEvent(new Event('visibilitychange')); }); - // App should still be functional — assert the bottom nav remains visible. + // A reconnect means a brand-new stream GET beyond the initial one. + await expect.poll(streamOpens, { timeout: 10_000 }).toBeGreaterThan(afterInitial); + + // And the app is still functional. await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible(); }); }); diff --git a/e2e/specs/08-browser-chaos/multi-tab.spec.ts b/e2e/specs/08-browser-chaos/multi-tab.spec.ts index ebe891d..c0c9295 100644 --- a/e2e/specs/08-browser-chaos/multi-tab.spec.ts +++ b/e2e/specs/08-browser-chaos/multi-tab.spec.ts @@ -5,16 +5,30 @@ import { test, expect } from '../../fixtures/test'; test.describe('Browser chaos — multi-tab', () => { - test('same user in two tabs — SSE delivers to both', async ({ page, context, guest, signIn }) => { + test('same user in two tabs — each tab establishes its own SSE stream', async ({ page, context, guest, signIn }) => { const g = await guest('Twin'); - await signIn(page, g); - await page.goto('/feed'); + + // Count each tab's own EventSource open (GET /api/v1/stream?ticket=…). Asserting + // *connection establishment* is fast and reliable; asserting event *delivery* would + // depend on the reverse proxy's ~30s SSE buffering and isn't worth the flake here. + const streamOpens = (p: import('@playwright/test').Page) => { + let n = 0; + p.on('request', (req) => { + if (req.method() === 'GET' && req.url().includes('/api/v1/stream?')) n++; + }); + return () => n; + }; + + const opens1 = streamOpens(page); + await signIn(page, g); // → /feed, connectSse() on mount + await expect.poll(opens1, { timeout: 10_000 }).toBeGreaterThanOrEqual(1); const tab2 = await context.newPage(); + const opens2 = streamOpens(tab2); await signIn(tab2, g); - await tab2.goto('/feed'); + await expect.poll(opens2, { timeout: 10_000 }).toBeGreaterThanOrEqual(1); - // Both tabs should mount the bottom nav. + // Both tabs mounted and each opened its own independent stream. await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible(); await expect(tab2.getByRole('link', { name: 'Galerie' })).toBeVisible(); await tab2.close(); diff --git a/e2e/specs/09-mobile/safe-area.spec.ts b/e2e/specs/09-mobile/safe-area.spec.ts index 8fab7db..a512460 100644 --- a/e2e/specs/09-mobile/safe-area.spec.ts +++ b/e2e/specs/09-mobile/safe-area.spec.ts @@ -45,20 +45,8 @@ test.describe('Mobile — safe-area insets', () => { expect(distanceFromBottom).toBeLessThanOrEqual(2); }); - test('context sheet (when opened) carries the same safe-area declaration', async ({ page }) => { - // We can't easily open the context sheet without a feed card to long-press, - // but the markup lives in the layout once the route mounts. We probe by - // scanning every element with a `style` attribute for the env() reference. - await page.goto('/join'); - const candidateStyles: string[] = await page.evaluate(() => { - return Array.from(document.querySelectorAll('[style]')) - .map((el) => el.getAttribute('style') ?? '') - .filter((s) => s.includes('env(safe-area-inset-bottom)')); - }); - // On /join there may be zero — the assertion is more of a sanity check. - // On /feed and /account it would be ≥ 1. We assert that on /feed below. - expect(Array.isArray(candidateStyles)).toBe(true); - }); + // (A vacuous `/join` probe that only asserted `Array.isArray(...)` — always true — + // was removed; the real sheet-level env() check is the structural test below.) test('upload sheet and context sheet both honor env() (structural check)', async ({ page, guest, signIn }) => { const g = await guest('SafeAreaSheets');