Files
intotheeast-com/tests/ui/post/photo-editor.spec.js
m038andClaude Opus 4.8 d3c17791b7 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
2026-07-05 23:42:45 +02:00

252 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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');
});