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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
This commit is contained in:
2026-07-08 17:17:09 +02:00
co-authored by Claude Opus 4.8
parent 3e1ddd8132
commit 2cdb435182
+71 -2
View File
@@ -1,7 +1,8 @@
// @ts-check // @ts-check
// Tests: TP1, TP1b, TP2TP6 — the owner trip publish/unpublish toggle on the // Tests: TP1, TP1b, TP2TP8 — the owner trip publish/unpublish toggle on the
// /trips listing (U7). Covers the owner gate, coverless drafts, cache-correct // /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, // Owner identity (doc-review P1): the harness authenticates as GRAV_TEST_USER,
// but committed site.yaml sets owner_username: mischa, and EntryScopeGuard is a // 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' } }); r = await page.request.post(url, { data: { published: 'false' } });
expect(r.status()).toBe(400); expect(r.status()).toBe(400);
expect(readTripPublished(slug)).toBe('true'); 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 ─────────────────────── // ── 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); await expect(page.locator('.home-predeparture-divider')).toHaveCount(0);
}).toPass({ timeout: 15_000 }); }).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');
});