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:
+13
-3
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Tests: AN1–AN2 — 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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Tests: DEL1–DEL3 — 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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Tests: ES1–ES3 — 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('');
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
+22
-17
@@ -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 }
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user