test(trip): publish-toggle Playwright specs + plan/spec docs; bump user pin

TP1/TP1b/TP2–TP6 cover the owner gate, coverless drafts, cache-correct
hide/restore, the active-trip confirm, backend authz (401/403/400), and
the home fallback. The suite pins site.owner_username to the authenticated
test user (restore on teardown) and runs serially — it mutates global
config and clears the shared cache, so it collides with parallel readers.

Bumps the user/ pin to the finished trip-publish-toggle content (064f0f0)
and marks the plan Complete.

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 14:50:20 +02:00
co-authored by Claude Opus 4.8
parent 793ca10d4d
commit 3e1ddd8132
4 changed files with 658 additions and 37 deletions
+284
View File
@@ -0,0 +1,284 @@
// @ts-check
// Tests: TP1, TP1b, TP2TP6 — 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.
//
// 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');
});
// ── 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 });
});