From 2cdb43518201fffdba3d13315a6c097b93fd1b53 Mon Sep 17 00:00:00 2001 From: Mischa Date: Wed, 8 Jul 2026 17:17:09 +0200 Subject: [PATCH] test(trip): cover publish-toggle failure/lock paths + missing-key 400 Add the three cases the code review flagged as uncovered: - TP7 (R15): a failed POST reverts the switch and surfaces the visible toast. - TP8 (R13): the in-flight lock suppresses a concurrent second submit (exactly one POST fires while the switch is aria-busy/disabled). - TP5 leg: a MISSING published key -> 400 (the array_key_exists branch, distinct from the is_bool branch already covered). All 10 trip-publish specs green (serial, worktree container). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn --- tests/ui/trip/trip-publish.spec.js | 73 +++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/tests/ui/trip/trip-publish.spec.js b/tests/ui/trip/trip-publish.spec.js index 03b436d..1e92aa4 100644 --- a/tests/ui/trip/trip-publish.spec.js +++ b/tests/ui/trip/trip-publish.spec.js @@ -1,7 +1,8 @@ // @ts-check -// Tests: TP1, TP1b, TP2–TP6 — the owner trip publish/unpublish toggle on the +// Tests: TP1, TP1b, TP2–TP8 — the owner trip publish/unpublish toggle on the // /trips listing (U7). Covers the owner gate, coverless drafts, cache-correct -// hide/restore, the active-trip confirm, backend authz, and the home fallback. +// hide/restore, the active-trip confirm, backend authz, the home fallback, the +// client failure/toast path (R15), and the in-flight double-submit lock (R13). // // Owner identity (doc-review P1): the harness authenticates as GRAV_TEST_USER, // but committed site.yaml sets owner_username: mischa, and EntryScopeGuard is a @@ -252,6 +253,12 @@ test('TP5: publish endpoint enforces 401/403 and rejects a non-boolean body', as r = await page.request.post(url, { data: { published: 'false' } }); expect(r.status()).toBe(400); expect(readTripPublished(slug)).toBe('true'); + + // Owner, MISSING published key → 400 (the array_key_exists branch, distinct + // from the is_bool branch above), frontmatter unchanged. + r = await page.request.post(url, { data: {} }); + expect(r.status()).toBe(400); + expect(readTripPublished(slug)).toBe('true'); }); // ── TP6: an unpublished active trip makes home fall back ─────────────────────── @@ -282,3 +289,65 @@ test('TP6: an unpublished active trip falls back to between-trips on home', asyn await expect(page.locator('.home-predeparture-divider')).toHaveCount(0); }).toPass({ timeout: 15_000 }); }); + +// ── TP7: a failed publish reverts the switch and surfaces a visible toast ────── +test('TP7: a failed publish reverts the switch and shows a toast (R15)', async ({ page }) => { + const slug = `tp7-${Date.now()}`; + createFixtureTrip(slug, { published: true }); + + await page.goto('/trips'); + const toggle = toggleFor(page, slug); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + + // Force the mutation to fail server-side; the request is intercepted so it + // never reaches the endpoint (a generic 5xx → generic "couldn't update" copy). + await page.route('**/api/v1/trip/*/publish', (route) => + route.fulfill({ status: 500, contentType: 'application/json', body: '{}' })); + + await toggle.click(); + + // The switch never flipped (the optimistic flip only happens on success), so + // "revert" is just re-enabling it; the visible page-level toast appears. + await expect(page.locator('#trip-publish-live')).toBeVisible(); + await expect(page.locator('#trip-publish-live')).toContainText("Couldn't update"); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + await expect(toggle).toBeEnabled(); + // Never persisted (the request was intercepted before the server). + expect(readTripPublished(slug)).toBe('true'); + + await page.unroute('**/api/v1/trip/*/publish'); +}); + +// ── TP8: the in-flight lock suppresses a concurrent second submit ────────────── +test('TP8: the pending lock suppresses a concurrent second submit (R13)', async ({ page }) => { + const slug = `tp8-${Date.now()}`; + createFixtureTrip(slug, { published: true }); + + await page.goto('/trips'); + const toggle = toggleFor(page, slug); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + + // Count and DELAY the mutation so the switch stays in-flight while we click + // again. Fulfilled locally (204), so the server/disk is never touched. + let posts = 0; + await page.route('**/api/v1/trip/*/publish', async (route) => { + posts += 1; + await new Promise((r) => setTimeout(r, 1_000)); + route.fulfill({ status: 204, body: '' }); + }); + + await toggle.click(); + // In flight: locked (aria-busy + disabled). + await expect(toggle).toHaveAttribute('aria-busy', 'true'); + await expect(toggle).toBeDisabled(); + + // A second click during the in-flight window must NOT fire a second POST. + await toggle.click({ force: true }); + + // First request settles → optimistic flip + unlock; exactly one POST fired. + await expect(toggle).toHaveAttribute('aria-checked', 'false'); + await expect(toggle).toBeEnabled(); + expect(posts).toBe(1); + + await page.unroute('**/api/v1/trip/*/publish'); +});