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:
@@ -0,0 +1,251 @@
|
||||
// @ts-check
|
||||
// Tests: E1–E7 — 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('Couldn’t 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');
|
||||
});
|
||||
Reference in New Issue
Block a user