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
+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');
});