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
285 lines
14 KiB
JavaScript
285 lines
14 KiB
JavaScript
// @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 });
|
||
});
|