Files
intotheeast-com/tests/ui/trip/trip-publish.spec.js
T
m038andClaude Opus 4.8 2cdb435182 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
2026-07-08 17:17:09 +02:00

354 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @ts-check
// Tests: TP1, TP1b, TP2TP8 — 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, 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
// strict username match. So this suite PINS site.owner_username to the
// authenticated test user (restore on teardown) rather than assuming the
// committed value. TP5's 403 leg derives a non-owner by briefly overriding
// owner_username to a value the test user does not match.
//
// Config is read fresh per request (twig.cache:false), but a NEW page folder is
// only picked up after a page-tree cache clear (the folder-hash staleness class
// of bug fixed in deleteEntry) — so createFixtureTrip / config writes clear the
// cache of the container serving THIS worktree's user dir.
//
// RUN THIS SUITE SERIALLY (`--workers=1` for tests/ui/trip, or run the file on
// its own). It mutates GLOBAL state — site.owner_username / active_trip and the
// shared page-tree cache (the publish endpoint flushes APCu site-wide) — so a
// spec reading the active trip or a trip page in a PARALLEL worker can transiently
// observe the mutated config or a mid-rebuild page. On its own, or serially, it
// is deterministic. This mirrors how home-highlights.spec.js mutates `travelling`
// and coexists only because the home/maps specs skip when it does.
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { test, expect } = require('@playwright/test');
const BASE = process.env.GRAV_BASE_URL || 'http://localhost:8081';
const OWNER = process.env.GRAV_TEST_USER || 'testrunner';
// ── user dir + container (worktree-safe; two grav containers can run at once) ──
const USER_DIR = process.env.GRAV_USER_DIR
? path.resolve(process.env.GRAV_USER_DIR)
: path.resolve(__dirname, '../../../user');
const SITE_YAML = path.join(USER_DIR, 'config/site.yaml');
const TRIPS_DIR = path.join(USER_DIR, 'pages/01.trips');
function resolveContainer() {
if (process.env.GRAV_CONTAINER) return process.env.GRAV_CONTAINER;
const want = fs.realpathSync(USER_DIR);
const names = execSync("docker ps --format '{{.Names}}'", { encoding: 'utf-8' })
.trim().split(/\r?\n/).filter(Boolean);
for (const c of names) {
try {
const src = execSync(
`docker inspect ${c} --format '{{range .Mounts}}{{if eq .Destination "/var/www/html/user"}}{{.Source}}{{end}}{{end}}'`,
{ encoding: 'utf-8' }
).trim();
if (src && fs.realpathSync(src) === want) return c;
} catch (_) { /* container went away mid-scan */ }
}
return 'intotheeast_grav';
}
const CONTAINER = resolveContainer();
function clearCache() {
execSync(`docker exec ${CONTAINER} sh -c 'cd /var/www/html && php bin/grav clearcache'`, { stdio: 'ignore' });
}
// ── site.yaml patch/restore ───────────────────────────────────────────────────
let originalSite = null; // committed/working-tree state, restored on teardown
let basePatched = null; // originalSite + owner_username pinned to OWNER
function setKey(content, key, val) {
const re = new RegExp(`^${key}:.*$`, 'm');
const line = `${key}: ${val}`;
return re.test(content) ? content.replace(re, line) : `${content.replace(/\n*$/, '')}\n${line}\n`;
}
function writeSite(content) {
fs.writeFileSync(SITE_YAML, content);
clearCache();
}
// ── fixture trips ─────────────────────────────────────────────────────────────
const fixtures = [];
function createFixtureTrip(slug, { published = true } = {}) {
const dir = path.join(TRIPS_DIR, slug);
fs.mkdirSync(path.join(dir, '01.dailies'), { recursive: true });
fs.mkdirSync(path.join(dir, '04.stories'), { recursive: true });
// Coverless by design (no cover_image, no entries) — the most common publish
// target and the state TP1b guards.
fs.writeFileSync(path.join(dir, 'trip.md'),
`---\ntitle: '${slug} fixture'\ntemplate: trip\ndate: '2020-01-01'\ncover_image: ''\npublished: ${published}\n---\n`);
fs.writeFileSync(path.join(dir, '01.dailies/dailies.md'),
'---\ntitle: Journal\ntemplate: default\nroutable: false\nvisible: false\n---\n');
fs.writeFileSync(path.join(dir, '04.stories/stories.md'),
'---\ntitle: Stories\ntemplate: default\nroutable: false\nvisible: false\n---\n');
if (!fixtures.includes(slug)) fixtures.push(slug);
clearCache();
return dir;
}
function readTripPublished(slug) {
const p = path.join(TRIPS_DIR, slug, 'trip.md');
if (!fs.existsSync(p)) return null;
const m = fs.readFileSync(p, 'utf-8').match(/^published:\s*(\S+)/m);
return m ? m[1] : null;
}
function cleanupFixtures() {
let removed = false;
for (const slug of fixtures) {
const dir = path.join(TRIPS_DIR, slug);
if (fs.existsSync(dir)) { fs.rmSync(dir, { recursive: true, force: true }); removed = true; }
}
if (removed) clearCache();
}
// Locators
const cardWrap = (page, slug) => page.locator(`.trip-card-wrap:has(a.trip-card[href="/trips/${slug}"])`);
const toggleFor = (page, slug) => cardWrap(page, slug).locator('.trip-publish-toggle');
// This file mutates shared global config; keep its own tests ordered and reset
// config after each so a per-test override never leaks into the next.
test.describe.configure({ mode: 'serial' });
test.beforeAll(() => {
originalSite = fs.readFileSync(SITE_YAML, 'utf-8');
basePatched = setKey(originalSite, 'owner_username', OWNER);
writeSite(basePatched);
});
test.afterEach(() => { writeSite(basePatched); });
test.afterAll(() => {
if (originalSite != null) writeSite(originalSite);
cleanupFixtures();
});
// ── TP1: owner gate ───────────────────────────────────────────────────────────
test('TP1: owner sees the toggle + drafts; anon sees neither', async ({ page, browser }) => {
const pub = `tp1pub-${Date.now()}`;
const draft = `tp1draft-${Date.now()}`;
createFixtureTrip(pub, { published: true });
createFixtureTrip(draft, { published: false });
// Owner: toggle present, draft trip visible + badged.
await page.goto('/trips');
await expect(toggleFor(page, pub)).toHaveCount(1);
await expect(toggleFor(page, pub)).toHaveAttribute('aria-checked', 'true');
await expect(cardWrap(page, draft)).toHaveCount(1);
await expect(cardWrap(page, draft).locator('.trip-draft-badge')).toBeVisible();
// The switch is an accessible switch identifying the trip.
await expect(toggleFor(page, draft)).toHaveAttribute('role', 'switch');
await expect(toggleFor(page, draft)).toHaveAttribute('aria-label', /fixture/);
// Anon: no toggle anywhere, draft absent, published still visible.
const anon = await browser.newContext({ storageState: { cookies: [], origins: [] }, baseURL: BASE });
const ap = await anon.newPage();
await ap.goto('/trips');
await expect(ap.locator('.trip-publish-toggle')).toHaveCount(0);
await expect(ap.locator(`a.trip-card[href="/trips/${draft}"]`)).toHaveCount(0);
await expect(ap.locator(`a.trip-card[href="/trips/${pub}"]`)).toHaveCount(1);
await anon.close();
});
// ── TP1b: a coverless draft still renders a working toggle ─────────────────────
test('TP1b: a coverless draft still renders a working toggle', async ({ page }) => {
const slug = `tp1b-${Date.now()}`;
createFixtureTrip(slug, { published: false }); // no cover, no entries
await page.goto('/trips');
// No cover image emitted…
await expect(cardWrap(page, slug).locator('.trip-card-cover')).toHaveCount(0);
// …but the toggle still has an anchor and is usable.
const toggle = toggleFor(page, slug);
await expect(toggle).toBeVisible();
await expect(toggle).toHaveAttribute('aria-checked', 'false');
});
// ── TP2: unpublish hides the trip for anon after a fresh load (cache-correct) ──
test('TP2: unpublishing hides the trip for anon after reload', async ({ page, browser }) => {
const slug = `tp2-${Date.now()}`;
createFixtureTrip(slug, { published: true });
await page.goto('/trips');
const toggle = toggleFor(page, slug);
await expect(toggle).toHaveAttribute('aria-checked', 'true');
await toggle.click();
// Optimistic in-place flip + Draft badge, no reload.
await expect(toggle).toHaveAttribute('aria-checked', 'false');
await expect(cardWrap(page, slug).locator('.trip-draft-badge')).toBeVisible();
// Persisted to disk.
await expect.poll(() => readTripPublished(slug), { timeout: 15_000 }).toBe('false');
// Anon fresh load: absent (the endpoint invalidated the page-tree index).
const anon = await browser.newContext({ storageState: { cookies: [], origins: [] }, baseURL: BASE });
const ap = await anon.newPage();
await ap.goto('/trips');
await expect(ap.locator(`a.trip-card[href="/trips/${slug}"]`)).toHaveCount(0);
await anon.close();
// Owner fresh load: still visible, badged as Draft.
await page.goto('/trips');
await expect(cardWrap(page, slug).locator('.trip-draft-badge')).toBeVisible();
});
// ── TP3: republish restores the trip for anon ─────────────────────────────────
test('TP3: republishing a draft restores it for anon', async ({ page, browser }) => {
const slug = `tp3-${Date.now()}`;
createFixtureTrip(slug, { published: false });
await page.goto('/trips');
const toggle = toggleFor(page, slug);
await expect(toggle).toHaveAttribute('aria-checked', 'false');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-checked', 'true');
await expect.poll(() => readTripPublished(slug), { timeout: 15_000 }).toBe('true');
const anon = await browser.newContext({ storageState: { cookies: [], origins: [] }, baseURL: BASE });
const ap = await anon.newPage();
await ap.goto('/trips');
await expect(ap.locator(`a.trip-card[href="/trips/${slug}"]`)).toHaveCount(1);
await anon.close();
});
// ── TP4: dismissing the active-trip confirm leaves it published ───────────────
test('TP4: dismissing the active-trip confirm leaves it published', async ({ page }) => {
const slug = `tp4-${Date.now()}`;
createFixtureTrip(slug, { published: true });
writeSite(setKey(basePatched, 'active_trip', `/trips/${slug}`));
await page.goto('/trips');
const toggle = toggleFor(page, slug);
await expect(toggle).toHaveAttribute('data-active', 'true');
// Dismiss the confirm → no request, stays published.
page.once('dialog', (d) => d.dismiss());
await toggle.click();
await expect(toggle).toHaveAttribute('aria-checked', 'true');
expect(readTripPublished(slug)).toBe('true');
});
// ── TP5: backend authz + non-boolean rejection ────────────────────────────────
test('TP5: publish endpoint enforces 401/403 and rejects a non-boolean body', async ({ page, browser }) => {
const slug = `tp5-${Date.now()}`;
createFixtureTrip(slug, { published: true });
const url = `/api/v1/trip/${slug}/publish`;
// Anonymous → 401, frontmatter unchanged.
const anon = await browser.newContext({ storageState: { cookies: [], origins: [] }, baseURL: BASE });
let r = await anon.request.post(url, { data: { published: false } });
expect(r.status()).toBe(401);
await anon.close();
expect(readTripPublished(slug)).toBe('true');
// Authenticated NON-owner → 403 (briefly make the logged-in user not the owner).
writeSite(setKey(basePatched, 'owner_username', `not-${OWNER}-xyz`));
r = await page.request.post(url, { data: { published: false } });
expect(r.status()).toBe(403);
expect(readTripPublished(slug)).toBe('true');
writeSite(basePatched); // back to owner for the 400 check
// Owner, non-boolean published → 400, frontmatter unchanged.
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 ───────────────────────
test('TP6: an unpublished active trip falls back to between-trips on home', async ({ page }) => {
const slug = `tp6-${Date.now()}`;
createFixtureTrip(slug, { published: true });
writeSite(setKey(setKey(basePatched, 'active_trip', `/trips/${slug}`), 'travelling', 'true'));
// Published active trip → active-trip mode. The fixture has no entries, so
// active mode renders the pre-departure partial (a between-trips-only
// .home-highlights-header is absent; the predeparture divider is present).
// Both branches carry a .home-highlights-cta, so it is not a discriminator.
// Reload-poll so a config/cache settle after the fixture write can't flake it.
await expect(async () => {
await page.goto('/');
await expect(page.locator('.home-predeparture-divider')).toBeVisible({ timeout: 2_000 });
await expect(page.locator('.home-highlights-header')).toHaveCount(0);
}).toPass({ timeout: 15_000 });
// Unpublish it via the owner endpoint (clears cache).
const r = await page.request.post(`/api/v1/trip/${slug}/publish`, { data: { published: false } });
expect(r.status()).toBe(204);
// Home now falls through to the between-trips highlights state.
await expect(async () => {
await page.goto('/');
await expect(page.locator('.home-highlights-header')).toBeVisible({ timeout: 2_000 });
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');
});