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:
@@ -0,0 +1,284 @@
|
||||
// @ts-check
|
||||
// Tests: TP1, TP1b, TP2–TP6 — 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 });
|
||||
});
|
||||
Reference in New Issue
Block a user