// @ts-check // 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, 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'); });