test(post-form): add edit/delete/anon coverage and fix stale photo-gate assumptions

New specs: edit-mode (ES1 save round-trip + ES2/ES3 prefill 404/500 states),
delete-flow (DEL1-3 happy/cancel/failed), anon-view (AN1 no owner controls,
AN2 draft hidden from anon), photo-editor (live add/delete/reorder). Existing:
P3-P8 now attach a photo to satisfy the create photo-gate; V3 picker cap 4->6.
Full post suite 38/38, stable across parallel (3-worker) runs.

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-05 23:42:45 +02:00
co-authored by Claude Opus 4.8
parent 7534d7d178
commit d3c17791b7
7 changed files with 565 additions and 27 deletions
+13 -3
View File
@@ -8,13 +8,23 @@ const { execSync } = require('child_process');
* *
* Resolution order: * Resolution order:
* 1. GRAV_USER_DIR env var (set in .env or shell) * 1. GRAV_USER_DIR env var (set in .env or shell)
* 2. docker inspect the running intotheeast_grav container * 2. Sibling `user/` directory — authoritative for this repo's layout, where
* 3. Sibling `user/` directory (worktree fallback) * docker-compose always bind-mounts `./user` relative to the checkout. This
* is correct for BOTH the main checkout and a git worktree (each worktree
* serves its own `./user`), so it must be preferred over docker inspect.
* 3. `docker inspect intotheeast_grav` — last-resort fallback for running the
* specs detached from the served checkout. NOTE: from a worktree this points
* at the MAIN checkout's container (a different `user/`), so it must never
* win over the sibling dir above, or disk assertions look in the wrong tree.
*/ */
function resolveUserDir() { function resolveUserDir() {
if (process.env.GRAV_USER_DIR) { if (process.env.GRAV_USER_DIR) {
return process.env.GRAV_USER_DIR; return process.env.GRAV_USER_DIR;
} }
const sibling = path.join(__dirname, '../../user');
if (fs.existsSync(path.join(sibling, 'config/site.yaml'))) {
return sibling;
}
try { try {
const raw = execSync( const raw = execSync(
"docker inspect intotheeast_grav --format '{{range .Mounts}}{{if eq .Destination \"/var/www/html/user\"}}{{.Source}}{{end}}{{end}}'", "docker inspect intotheeast_grav --format '{{range .Mounts}}{{if eq .Destination \"/var/www/html/user\"}}{{.Source}}{{end}}{{end}}'",
@@ -24,7 +34,7 @@ function resolveUserDir() {
} catch (_) { } catch (_) {
// docker not available or container not running // docker not available or container not running
} }
return path.join(__dirname, '../../user'); return sibling;
} }
/** /**
+80
View File
@@ -0,0 +1,80 @@
// @ts-check
// Tests: AN1AN2 — the anonymous (logged-out) visitor's view of the active trip.
//
// Every other spec runs as the authenticated owner, so nothing guards the
// owner/anon boundary. These assert the two things that boundary must enforce
// (R5, KTD7):
// - AN1: owner-only controls (Edit/Delete, data-entry-route) never render for
// an anonymous visitor, even though published entries are visible.
// - AN2: an unpublished DRAFT is shown to the owner (with a badge) but is
// completely absent for an anonymous visitor.
//
// The whole file runs UNauthenticated by clearing storageState. AN2 spins up a
// short-lived authenticated context to create the draft fixture and confirm the
// owner-visible side.
const { test, expect } = require('@playwright/test');
const path = require('path');
const { fillEditor, waitForPhotoUpload, cleanupEntry, findEntry, ACTIVE_TRIP_URL } = require('../helpers');
const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg');
const AUTH_STATE = 'tests/.auth/user.json';
const BASE = process.env.GRAV_BASE_URL || 'http://localhost:8081';
// Run this file with NO owner session.
test.use({ storageState: { cookies: [], origins: [] } });
const created = [];
test.afterAll(() => created.forEach(cleanupEntry));
// ── AN1: anonymous visitor sees content but no owner controls ─────────────────
test('AN1: an anonymous visitor sees published entries but no owner controls', async ({ page }) => {
await page.goto(ACTIVE_TRIP_URL);
// The demo trip has published journal cards — content is public.
await expect(page.locator('.journal-post').first()).toBeVisible();
// …but none of the owner-only affordances are present in the markup.
await expect(page.locator('.journal-post-actions')).toHaveCount(0);
await expect(page.locator('.entry-action--edit')).toHaveCount(0);
await expect(page.locator('.entry-action--delete')).toHaveCount(0);
await expect(page.locator('[data-entry-route]')).toHaveCount(0);
});
// ── AN2: a draft is owner-only ────────────────────────────────────────────────
test('AN2: a draft entry is shown to the owner but hidden from an anonymous visitor', async ({ page, browser }) => {
const tag = `draft-${Date.now()}`;
// Create an UNPUBLISHED entry as the owner, in a separate authed context.
const owner = await browser.newContext({ storageState: AUTH_STATE, baseURL: BASE });
const op = await owner.newPage();
await op.goto('/post');
await op.fill('input[name="data[title]"]', `UI Test ${tag}`);
await fillEditor(op, `Draft body ${tag}. Safe to delete.`);
await op.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
await waitForPhotoUpload(op);
// Flip Published → No. The toggle renders as a visually-hidden radio pair
// behind "More options", so set the state directly and fire `change` (what a
// real click would do) rather than fighting the visibility gate.
await op.evaluate(() => {
const off = document.querySelector('input[name="data[published]"][value="0"]');
off.checked = true;
off.dispatchEvent(new Event('change', { bubbles: true }));
});
await op.locator('.btn-post').evaluate(el => el.click());
await expect(op.locator('.form-messages, .notices')).toContainText(
'Entry posted successfully!', { timeout: 15_000 });
created.push(tag);
expect(findEntry(tag), 'draft fixture should exist on disk').not.toBeNull();
// Owner side: the draft appears in the feed WITH a Draft badge.
await op.goto(ACTIVE_TRIP_URL);
const ownerCard = op.locator('.journal-post', { hasText: tag });
await expect(ownerCard).toHaveCount(1);
await expect(ownerCard.locator('.journal-draft-badge')).toBeVisible();
await owner.close();
// Anonymous side (the default page fixture): the draft is nowhere to be seen.
await page.goto(ACTIVE_TRIP_URL);
await expect(page.locator('.journal-post', { hasText: tag })).toHaveCount(0);
await expect(page.locator('body')).not.toContainText(tag);
});
+93
View File
@@ -0,0 +1,93 @@
// @ts-check
// Tests: DEL1DEL3 — the owner entry-delete flow (feed-actions.js + the
// entry-actions `deleteEntry` route). Delete is a two-step inline confirm on a
// feed card: Delete → Cancel / Confirm delete → DELETE /api/v1/entry/<slug>.
//
// This flow — a destructive, owner-only action — had zero automated coverage.
// - DEL1: full happy path — the card vanishes AND the folder leaves disk.
// - DEL2: Cancel is a real escape hatch — nothing is deleted.
// - DEL3: a failed DELETE keeps the card and surfaces the inline error.
const { test, expect } = require('@playwright/test');
const path = require('path');
const {
fillEditor, waitForPhotoUpload, cleanupEntry, findEntry, ACTIVE_TRIP_URL,
} = require('../helpers');
const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg');
const created = [];
// cleanupEntry is a no-op when the entry was already deleted by the test.
test.afterAll(() => created.forEach(cleanupEntry));
/** Post a fresh entry through the create form so a feed card exists to delete. */
async function createEntry(page, tag) {
await page.goto('/post');
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
await fillEditor(page, `Delete-flow fixture ${tag}. Safe to delete.`);
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
await waitForPhotoUpload(page);
await page.locator('.btn-post').evaluate(el => el.click());
await expect(page.locator('.form-messages, .notices')).toContainText(
'Entry posted successfully!', { timeout: 15_000 });
}
// ── DEL1: happy delete removes the card and the folder ────────────────────────
test('DEL1: owner deletes an entry — the card disappears and the folder is removed', async ({ page }) => {
const tag = `del1-${Date.now()}`;
await createEntry(page, tag);
created.push(tag);
await page.goto(ACTIVE_TRIP_URL);
const card = page.locator('.journal-post', { hasText: tag });
await expect(card).toHaveCount(1);
// The owner-only controls must be present — this also asserts the auth gate.
await card.locator('[data-delete-start]').click();
await card.locator('[data-delete-confirm]').click();
await expect(page.locator('.journal-post', { hasText: tag }))
.toHaveCount(0, { timeout: 15_000 });
await expect.poll(() => findEntry(tag), { timeout: 15_000 }).toBeNull();
});
// ── DEL2: Cancel keeps the entry ──────────────────────────────────────────────
test('DEL2: cancelling the confirm step keeps the entry on the page and on disk', async ({ page }) => {
const tag = `del2-${Date.now()}`;
await createEntry(page, tag);
created.push(tag);
await page.goto(ACTIVE_TRIP_URL);
const card = page.locator('.journal-post', { hasText: tag });
await expect(card).toHaveCount(1);
await card.locator('[data-delete-start]').click();
await expect(card.locator('.entry-delete-confirm')).toBeVisible();
await card.locator('[data-delete-cancel]').click();
await expect(card.locator('.entry-delete-confirm')).toBeHidden();
await expect(card).toHaveCount(1);
expect(findEntry(tag), 'a cancelled delete must not remove the folder').not.toBeNull();
});
// ── DEL3: a failed DELETE keeps the card and shows the inline error ───────────
test('DEL3: a failed delete keeps the card and surfaces the inline error', async ({ page }) => {
const tag = `del3-${Date.now()}`;
await createEntry(page, tag);
created.push(tag);
await page.goto(ACTIVE_TRIP_URL);
// Force the delete request to fail after the confirm.
await page.route('**/api/v1/entry/**', (route) => {
if (route.request().method() === 'DELETE') return route.fulfill({ status: 500, body: '' });
return route.continue();
});
const card = page.locator('.journal-post', { hasText: tag });
await expect(card).toHaveCount(1);
await card.locator('[data-delete-start]').click();
await card.locator('[data-delete-confirm]').click();
await expect(card.locator('.entry-delete-msg'))
.toContainText('Could not delete', { timeout: 15_000 });
await expect(card).toHaveCount(1); // the card survives a failed delete
expect(findEntry(tag), 'a failed delete must not remove the folder').not.toBeNull();
});
+97
View File
@@ -0,0 +1,97 @@
// @ts-check
// Tests: ES1ES3 — edit mode field SAVE round-trip + prefill error states.
//
// Complements photo-editor.spec.js (which covers the live photo add/delete/
// reorder inside edit mode). Here we cover the *text* side of edit mode:
// - ES1 drives a real end-to-end save: create → open the feed card's Edit link
// → change title + body → Save → assert the new values land back on disk.
// - ES2/ES3 mock the prefill fetch to force the two D7 failure branches
// (404 "no longer exists" vs a transient "couldn't be loaded") — the copy
// that tells the owner whether a retry is worthwhile. These had zero coverage.
const { test, expect } = require('@playwright/test');
const path = require('path');
const {
fillEditor, waitForPhotoUpload, cleanupEntry, findEntry, readEntryMd, ACTIVE_TRIP_URL,
} = require('../helpers');
const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg');
// Synthetic route for the mocked error tests — never has to exist on disk.
const MISSING_ROUTE = '/trips/italy-2026-demo/dailies/does-not-exist';
const created = [];
test.afterAll(() => created.forEach(cleanupEntry));
/** Post a fresh entry through the create form (photo included, gate satisfied). */
async function createEntry(page, tag) {
await page.goto('/post');
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
await fillEditor(page, `Fixture for ${tag}. Safe to delete.`);
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
await waitForPhotoUpload(page);
await page.locator('.btn-post').evaluate(el => el.click());
await expect(page.locator('.form-messages, .notices')).toContainText(
'Entry posted successfully!', { timeout: 15_000 });
}
// ── ES1: edit-mode save writes the changed title + body back to disk ──────────
test('ES1: editing an entry saves the changed title and body back in place', async ({ page }) => {
const tag = `es1-${Date.now()}`;
await createEntry(page, tag);
created.push(tag);
// Reach edit mode the way the owner does: via the feed card's Edit link.
await page.goto(ACTIVE_TRIP_URL);
const card = page.locator('.journal-post', { hasText: tag });
await expect(card).toHaveCount(1);
const editHref = await card.locator('.entry-action--edit').getAttribute('href');
expect(editHref).toContain('/post?edit=');
await page.goto(editHref);
// Prefill is async (GET /api/v1/pages{route}); wait until it populates.
await expect(page.locator('input[name="data[title]"]'))
.toHaveValue(`UI Test ${tag}`, { timeout: 15_000 });
await page.fill('input[name="data[title]"]', `UI Test ${tag} EDITED`);
await fillEditor(page, `Edited body for ${tag}.`);
await page.locator('.btn-post').evaluate(el => el.click());
// overwrite_mode:edit writes back in place — assert both changes on disk.
await expect.poll(() => {
const dir = findEntry(tag);
return dir ? (readEntryMd(dir) || '') : '';
}, { timeout: 15_000 }).toContain('EDITED');
const md = readEntryMd(findEntry(tag));
expect(md, 'edited body should persist').toContain(`Edited body for ${tag}.`);
});
// ── ES2: prefill 404 → the "no longer exists" (deleted) branch ────────────────
test('ES2: opening a deleted entry for editing shows the "no longer exists" notice', async ({ page }) => {
await page.route('**/api/v1/pages/**', (route) => {
if (route.request().method() === 'GET') return route.fulfill({ status: 404, body: '' });
return route.continue();
});
await page.goto('/post?edit=' + encodeURIComponent(MISSING_ROUTE));
const banner = page.locator('.post-edit-error');
await expect(banner).toContainText('no longer exists', { timeout: 15_000 });
await expect(banner).toHaveAttribute('role', 'alert');
// D7: the form is left empty rather than half-filled.
await expect(page.locator('input[name="data[title]"]')).toHaveValue('');
});
// ── ES3: prefill 500 → the transient "couldn't be loaded" (retry) branch ──────
test('ES3: a transient prefill failure shows the retry-able "be loaded" notice', async ({ page }) => {
await page.route('**/api/v1/pages/**', (route) => {
if (route.request().method() === 'GET') return route.fulfill({ status: 500, body: '' });
return route.continue();
});
await page.goto('/post?edit=' + encodeURIComponent(MISSING_ROUTE));
// Copy differs from the 404 case so the owner knows a retry is worthwhile.
await expect(page.locator('.post-edit-error'))
.toContainText('be loaded for editing', { timeout: 15_000 });
await expect(page.locator('input[name="data[title]"]')).toHaveValue('');
});
+251
View File
@@ -0,0 +1,251 @@
// @ts-check
// Tests: E1E7 — the edit-mode live photo editor (initPhotoEditor).
//
// These cover the add / delete / reorder paths of the photo editor reached at
// `/post?edit=<route>`, with an emphasis on the FAILURE branches added in commit
// 7ffd75e (auth-expiry copy + incomplete-rollback warning) which had zero
// automated coverage.
//
// Strategy: the media API is fully mocked with page.route(). This is deliberate:
// - the create form is now photo-gated (≥1 photo required), so a text-only
// fixture entry can't be posted programmatically; and
// - mutating a real demo entry on disk would be destructive.
// Mocking lets us drive every add/delete/reorder branch — including the ones
// that only fire on server errors — deterministically and non-destructively.
// Real end-to-end persistence stays covered by the manual owner-session smoke
// test in the handover.
//
// Assertions target the stable USER-FACING strings, never minified identifiers,
// so they survive the bundle build.
const { test, expect } = require('@playwright/test');
const path = require('path');
const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg');
// A synthetic entry route. It never has to exist on disk — every API call the
// editor makes against it is intercepted below. The reorder route keys on the
// last path segment ("mock-entry").
const EDIT_ROUTE = '/trips/italy-2026-demo/dailies/mock-entry';
const EDIT_URL = '/post?edit=' + encodeURIComponent(EDIT_ROUTE);
/**
* Install a stateful mock of the Grav media API + entry-actions reorder route.
*
* cfg:
* photos: string[] initial filenames in the grid (default [])
* addName: string filename a successful POST /media "creates" (default 'stock-upload.jpg')
* status: { prefill, list, add, delete, reorder } — HTTP status per op.
* Any value >= 400 makes that op fail. Success defaults:
* prefill 200, list 200, add 200, delete 204, reorder 204.
*
* Returns a `state` object the test can inspect: `state.photos` (current set)
* and `state.reorders` (array of the `order` arrays received by the reorder
* route, newest last).
*/
async function installMockApi(page, cfg = {}) {
const state = {
photos: (cfg.photos || []).slice(),
reorders: [],
};
const s = Object.assign(
{ prefill: 200, list: 200, add: 200, delete: 204, reorder: 204 },
cfg.status || {}
);
const addName = cfg.addName || 'stock-upload.jpg';
await page.route('**/api/v1/**', async (route) => {
const req = route.request();
const method = req.method();
const p = new URL(req.url()).pathname;
const json = (status, obj) =>
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(obj) });
const empty = (status) => route.fulfill({ status, body: '' });
// Reorder: POST /api/v1/entry/{slug}/photos/order
if (/\/api\/v1\/entry\/[^/]+\/photos\/order$/.test(p)) {
if (s.reorder >= 400) return empty(s.reorder);
try { state.reorders.push(JSON.parse(req.postData() || '{}').order); } catch (_) {}
return empty(204);
}
// Delete: DELETE /api/v1/pages{route}/media/{filename}
const delMatch = p.match(/\/media\/([^/]+)$/);
if (delMatch && method === 'DELETE') {
if (s.delete >= 400) return empty(s.delete);
const fn = decodeURIComponent(delMatch[1]);
state.photos = state.photos.filter((x) => x !== fn);
return empty(204);
}
// List (GET) or Add (POST): /api/v1/pages{route}/media
if (/\/media$/.test(p)) {
if (method === 'POST') {
if (s.add >= 400) return empty(s.add);
state.photos.push(addName);
return json(200, { data: { filename: addName } });
}
if (s.list >= 400) return empty(s.list);
return json(200, { data: state.photos.map((f) => ({ filename: f })) });
}
// Prefill: GET /api/v1/pages{route}
if (/\/api\/v1\/pages\//.test(p)) {
if (s.prefill >= 400) return empty(s.prefill);
return json(200, {
data: {
header: {
title: 'Mock Entry',
date: '2026-09-01 07:00',
published: true,
lat: 43.5,
lng: 11.3,
},
content: 'Mock content for the editor test.',
published: true,
},
});
}
return route.continue();
});
return state;
}
/** Open the editor and wait for its first render to settle. */
async function openEditor(page) {
await page.goto(EDIT_URL);
await page.waitForSelector('.photo-editor__grid', { timeout: 15_000 });
// The grid starts on a "Loading photos…" placeholder; wait for the initial
// mediaList() to resolve into either cells or the empty-state message.
await page.waitForFunction(() => {
const g = document.querySelector('.photo-editor__grid');
return g && !g.querySelector('.photo-editor__loading');
}, { timeout: 15_000 });
}
const status = (page) => page.locator('.photo-editor__status');
const cells = (page) => page.locator('.photo-editor__cell');
/** Delete the Nth photo cell through the inline confirm dialog. */
async function deleteCell(page, index) {
await cells(page).nth(index).locator('.photo-editor__del').click();
await cells(page).nth(index).locator('.photo-editor__confirm-yes').click();
}
/**
* Drag cell at `from` onto cell at `to` using stepped mouse moves so SortableJS
* (which listens to native pointer events) picks it up.
*/
async function dragCell(page, from, to) {
const src = await cells(page).nth(from).boundingBox();
const dst = await cells(page).nth(to).boundingBox();
if (!src || !dst) throw new Error('cell not found for drag');
await page.mouse.move(src.x + src.width / 2, src.y + src.height / 2);
await page.mouse.down();
// A few intermediate steps are needed or SortableJS treats it as a click.
await page.mouse.move(src.x + src.width / 2 + 10, src.y + src.height / 2, { steps: 5 });
await page.mouse.move(dst.x + dst.width / 2, dst.y + dst.height / 2, { steps: 10 });
await page.mouse.move(dst.x + dst.width / 2 + 1, dst.y + dst.height / 2, { steps: 5 });
await page.mouse.up();
}
// ── E1: happy add — a photo added through the editor renders in the grid ──────
test('E1: adding a photo renders it in the grid and clears the status', async ({ page }) => {
await installMockApi(page, { photos: [], addName: 'photo-01.jpg' });
await openEditor(page);
await expect(page.locator('.photo-editor__empty')).toBeVisible();
await page.locator('.photo-editor__input').setInputFiles(TEST_PHOTO);
await expect(cells(page)).toHaveCount(1, { timeout: 15_000 });
await expect(cells(page).first()).toHaveAttribute('data-filename', 'photo-01.jpg');
await expect(status(page)).toHaveText('');
});
// ── E2: auth-expiry on add (commit 7ffd75e #1) ────────────────────────────────
test('E2: a 401 while adding surfaces the "sign in again" copy', async ({ page }) => {
await installMockApi(page, { photos: [], status: { add: 401 } });
await openEditor(page);
await page.locator('.photo-editor__input').setInputFiles(TEST_PHOTO);
await expect(status(page)).toContainText('login session expired', { timeout: 15_000 });
await expect(status(page)).toContainText('Sign in again');
await expect(status(page)).toHaveClass(/error/);
});
// ── E3: incomplete-rollback warning (commit 7ffd75e #6) — highest value ───────
// Upload succeeds, the post-upload reorder fails (twice), and the rollback
// DELETE also fails, so cleanup is incomplete and a stray file may remain.
test('E3: failed reorder + failed cleanup after add warns "cleanup was incomplete"', async ({ page }) => {
await installMockApi(page, {
photos: [],
addName: 'stray-stock.jpg',
status: { reorder: 500, delete: 500 },
});
await openEditor(page);
await page.locator('.photo-editor__input').setInputFiles(TEST_PHOTO);
await expect(status(page)).toContainText('cleanup was incomplete', { timeout: 20_000 });
await expect(status(page)).toContainText('reload the page');
await expect(status(page)).toHaveClass(/error/);
});
// ── E4: delete failure (500) leaves the photo in place ────────────────────────
test('E4: a 500 on delete keeps the photo and shows a retry-able error', async ({ page }) => {
await installMockApi(page, { photos: ['photo-01.jpg', 'photo-02.jpg'], status: { delete: 500 } });
await openEditor(page);
await expect(cells(page)).toHaveCount(2);
await deleteCell(page, 0);
await expect(status(page)).toContainText('Couldnt delete that photo', { timeout: 15_000 });
await expect(status(page)).toHaveClass(/error/);
// The photo must survive a failed delete.
await expect(cells(page)).toHaveCount(2);
});
// ── E5: auth-expiry on delete (commit 7ffd75e #1) ─────────────────────────────
test('E5: a 401 on delete surfaces the "sign in again" copy and keeps the photo', async ({ page }) => {
await installMockApi(page, { photos: ['photo-01.jpg', 'photo-02.jpg'], status: { delete: 401 } });
await openEditor(page);
await expect(cells(page)).toHaveCount(2);
await deleteCell(page, 0);
await expect(status(page)).toContainText('login session expired', { timeout: 15_000 });
await expect(status(page)).toContainText('sign in again');
await expect(cells(page)).toHaveCount(2);
});
// ── E6: happy reorder — a drag persists the new order via the reorder route ────
test('E6: dragging a photo saves the new order', async ({ page }) => {
const state = await installMockApi(page, { photos: ['photo-01.jpg', 'photo-02.jpg'] });
await openEditor(page);
await expect(cells(page)).toHaveCount(2);
await dragCell(page, 0, 1);
// The reorder route must have been called with the swapped order.
await expect.poll(() => state.reorders.length, { timeout: 15_000 }).toBeGreaterThan(0);
expect(state.reorders[state.reorders.length - 1]).toEqual(['photo-02.jpg', 'photo-01.jpg']);
await expect(status(page)).toHaveText('');
});
// ── E7: auth-expiry on reorder (commit 7ffd75e #1) reverts the drag ───────────
test('E7: a 401 on reorder surfaces the "sign in again" copy and reverts', async ({ page }) => {
await installMockApi(page, { photos: ['photo-01.jpg', 'photo-02.jpg'], status: { reorder: 401 } });
await openEditor(page);
await expect(cells(page)).toHaveCount(2);
await dragCell(page, 0, 1);
await expect(status(page)).toContainText('login session expired', { timeout: 15_000 });
await expect(status(page)).toContainText('sign in again');
// Reverted to the last-known-good order.
await expect(cells(page)).toHaveCount(2);
await expect(cells(page).first()).toHaveAttribute('data-filename', 'photo-01.jpg');
});
+22 -17
View File
@@ -15,36 +15,31 @@ test.afterAll(() => {
created.forEach(cleanupEntry); created.forEach(cleanupEntry);
}); });
// ── P1: Post without photo ───────────────────────────────────────────────────── // ── P1: A photo is required — a text-only submit is blocked, writing nothing ───
test('P1: post text-only entry → created on disk and visible in trip feed', async ({ page }) => { // Create mode requires ≥1 photo (post-form.js gate). The UX suite asserts the
// inline error + suppressed notice; P1 is the complementary DISK-level guarantee
// that a blocked submit never lands an entry on disk.
test('P1: text-only submit is blocked by the photo gate and creates no entry', async ({ page }) => {
const tag = `p1-${Date.now()}`; const tag = `p1-${Date.now()}`;
const title = `UI Test ${tag}`; const title = `UI Test ${tag}`;
await page.goto('/post'); await page.goto('/post');
await page.fill('input[name="data[title]"]', title); await page.fill('input[name="data[title]"]', title);
await fillEditor(page, 'Text-only test entry. Safe to delete.'); await fillEditor(page, 'Text-only entry. Should be rejected — no photo.');
await page.fill('input[name="data[location_city]"]', 'Testville'); await page.fill('input[name="data[location_city]"]', 'Testville');
await page.fill('input[name="data[location_country]"]', 'Testland'); await page.fill('input[name="data[location_country]"]', 'Testland');
await page.locator('.btn-post').evaluate(el => el.click()); await page.locator('.btn-post').evaluate(el => el.click());
await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 });
const entryDir = findEntry(tag); // The photo gate fires an inline error and suppresses the success notice.
expect(entryDir, 'Entry folder should exist on disk').toBeTruthy(); await expect(page.locator('.photos-collapse .field-error')).toContainText('at least one photo');
created.push(tag); await expect(page.locator('.notices.success, .notices.green')).toHaveCount(0);
const md = readEntryMd(entryDir); // The crucial guarantee: nothing was written to disk.
expect(md).toContain(tag); expect(findEntry(tag), 'a blocked submit must not create an entry').toBeNull();
// No photo expected
const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f));
expect(photos.length, 'Text-only entry should have no photos').toBe(0);
await page.goto(ACTIVE_TRIP_URL);
await expect(page.locator('body')).toContainText(tag);
}); });
// ── P2: Post with photo ──────────────────────────────────────────────────────── // ── P2: Post with photo ────────────────────────────────────────────────────────
test.skip('P2: post entry with photo → photo saved in entry folder and visible in trip feed', async ({ page }) => { test('P2: post entry with photo → photo saved in entry folder and visible in trip feed', async ({ page }) => {
const tag = `p2-${Date.now()}`; const tag = `p2-${Date.now()}`;
const title = `UI Test ${tag}`; const title = `UI Test ${tag}`;
@@ -84,6 +79,8 @@ test('P3: post entry with city/country → frontmatter contains location', async
await fillEditor(page, 'Location test. Safe to delete.'); await fillEditor(page, 'Location test. Safe to delete.');
await page.fill('input[name="data[location_city]"]', 'Kyoto'); await page.fill('input[name="data[location_city]"]', 'Kyoto');
await page.fill('input[name="data[location_country]"]', 'Japan'); await page.fill('input[name="data[location_country]"]', 'Japan');
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
await waitForPhotoUpload(page);
await page.locator('.btn-post').evaluate(el => el.click()); await page.locator('.btn-post').evaluate(el => el.click());
await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 }); await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 });
@@ -110,6 +107,8 @@ test('P4: post entry with lat/lng → coordinates saved in frontmatter', async (
document.querySelector('input[name="data[lat]"]').value = '35.6762'; document.querySelector('input[name="data[lat]"]').value = '35.6762';
document.querySelector('input[name="data[lng]"]').value = '139.6503'; document.querySelector('input[name="data[lng]"]').value = '139.6503';
}); });
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
await waitForPhotoUpload(page);
await page.locator('.btn-post').evaluate(el => el.click()); await page.locator('.btn-post').evaluate(el => el.click());
await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 }); await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 });
@@ -143,6 +142,8 @@ test('P6: successful submit shows "Entry posted successfully!" message', async (
await page.goto('/post'); await page.goto('/post');
await page.fill('input[name="data[title]"]', `UI Test ${tag}`); await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
await fillEditor(page, 'P6 test. Safe to delete.'); await fillEditor(page, 'P6 test. Safe to delete.');
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
await waitForPhotoUpload(page);
await page.locator('.btn-post').evaluate(el => el.click()); await page.locator('.btn-post').evaluate(el => el.click());
await expect(page.locator('.form-messages, .notices')).toContainText( await expect(page.locator('.form-messages, .notices')).toContainText(
'Entry posted successfully!', { timeout: 15_000 } 'Entry posted successfully!', { timeout: 15_000 }
@@ -159,6 +160,8 @@ test('P7: submitted entry is saved with a date within 5 minutes of now', async (
await page.goto('/post'); await page.goto('/post');
await page.fill('input[name="data[title]"]', `UI Test ${tag}`); await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
await fillEditor(page, 'P7 date test. Safe to delete.'); await fillEditor(page, 'P7 date test. Safe to delete.');
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
await waitForPhotoUpload(page);
await page.locator('.btn-post').evaluate(el => el.click()); await page.locator('.btn-post').evaluate(el => el.click());
await expect(page.locator('.form-messages, .notices')).toContainText( await expect(page.locator('.form-messages, .notices')).toContainText(
'Entry posted successfully!', { timeout: 15_000 } 'Entry posted successfully!', { timeout: 15_000 }
@@ -185,6 +188,8 @@ test('P8: title and content fields are empty after a successful submit', async (
await page.goto('/post'); await page.goto('/post');
await page.fill('input[name="data[title]"]', `UI Test ${tag}`); await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
await fillEditor(page, 'P8 reset test. Safe to delete.'); await fillEditor(page, 'P8 reset test. Safe to delete.');
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
await waitForPhotoUpload(page);
await page.locator('.btn-post').evaluate(el => el.click()); await page.locator('.btn-post').evaluate(el => el.click());
await expect(page.locator('.form-messages, .notices')).toContainText( await expect(page.locator('.form-messages, .notices')).toContainText(
'Entry posted successfully!', { timeout: 15_000 } 'Entry posted successfully!', { timeout: 15_000 }
+9 -7
View File
@@ -33,21 +33,23 @@ test('V2: submit without content shows a validation error or stays on /post', as
expect(bodyText).not.toContain('Entry posted successfully'); expect(bodyText).not.toContain('Entry posted successfully');
}); });
// ── V3: Photo limit (max 4) ─────────────────────────────────────────────────── // ── V3: Photo limit (max 6) ───────────────────────────────────────────────────
test('V3: FilePond caps attachments at the limit (4)', async ({ page }) => { // The blueprint limit is 6 (asserted directly as maxFiles===6 in the UX suite);
// this is the behavioral counterpart — the picker must refuse a 7th attachment.
test('V3: FilePond caps attachments at the limit (6)', async ({ page }) => {
await page.goto('/post'); await page.goto('/post');
const browser = page.locator('input.filepond--browser'); const browser = page.locator('input.filepond--browser');
// Attach 4 photos (same fixture — we only need four items). // Attach 6 photos (same fixture — we only need six items).
await browser.setInputFiles([TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO]); await browser.setInputFiles([TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO]);
await page.waitForFunction(() => await page.waitForFunction(() =>
document.querySelectorAll('.filepond--item').length === 4, { timeout: 10_000 }); document.querySelectorAll('.filepond--item').length === 6, { timeout: 10_000 });
// A 5th is ignored once the limit is reached. // A 7th is ignored once the limit is reached.
await browser.setInputFiles([TEST_PHOTO]); await browser.setInputFiles([TEST_PHOTO]);
await page.waitForTimeout(500); await page.waitForTimeout(500);
expect(await page.locator('.filepond--item').count()).toBe(4); expect(await page.locator('.filepond--item').count()).toBe(6);
}); });
// ── V4: Non-image file rejected ─────────────────────────────────────────────── // ── V4: Non-image file rejected ───────────────────────────────────────────────