From d3c17791b7642395f3a27f5f399f9f3224955452 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sun, 5 Jul 2026 23:42:45 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn --- tests/ui/helpers.js | 16 +- tests/ui/post/anon-view.spec.js | 80 +++++++++ tests/ui/post/delete-flow.spec.js | 93 +++++++++++ tests/ui/post/edit-mode.spec.js | 97 +++++++++++ tests/ui/post/photo-editor.spec.js | 251 +++++++++++++++++++++++++++++ tests/ui/post/post.spec.js | 39 +++-- tests/ui/post/validation.spec.js | 16 +- 7 files changed, 565 insertions(+), 27 deletions(-) create mode 100644 tests/ui/post/anon-view.spec.js create mode 100644 tests/ui/post/delete-flow.spec.js create mode 100644 tests/ui/post/edit-mode.spec.js create mode 100644 tests/ui/post/photo-editor.spec.js diff --git a/tests/ui/helpers.js b/tests/ui/helpers.js index 2fc172f..b397ad1 100644 --- a/tests/ui/helpers.js +++ b/tests/ui/helpers.js @@ -8,13 +8,23 @@ const { execSync } = require('child_process'); * * Resolution order: * 1. GRAV_USER_DIR env var (set in .env or shell) - * 2. docker inspect the running intotheeast_grav container - * 3. Sibling `user/` directory (worktree fallback) + * 2. Sibling `user/` directory — authoritative for this repo's layout, where + * 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() { if (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 { const raw = execSync( "docker inspect intotheeast_grav --format '{{range .Mounts}}{{if eq .Destination \"/var/www/html/user\"}}{{.Source}}{{end}}{{end}}'", @@ -24,7 +34,7 @@ function resolveUserDir() { } catch (_) { // docker not available or container not running } - return path.join(__dirname, '../../user'); + return sibling; } /** diff --git a/tests/ui/post/anon-view.spec.js b/tests/ui/post/anon-view.spec.js new file mode 100644 index 0000000..443c5db --- /dev/null +++ b/tests/ui/post/anon-view.spec.js @@ -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); +}); diff --git a/tests/ui/post/delete-flow.spec.js b/tests/ui/post/delete-flow.spec.js new file mode 100644 index 0000000..74fec9a --- /dev/null +++ b/tests/ui/post/delete-flow.spec.js @@ -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/. +// +// 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(); +}); diff --git a/tests/ui/post/edit-mode.spec.js b/tests/ui/post/edit-mode.spec.js new file mode 100644 index 0000000..02a6771 --- /dev/null +++ b/tests/ui/post/edit-mode.spec.js @@ -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(''); +}); diff --git a/tests/ui/post/photo-editor.spec.js b/tests/ui/post/photo-editor.spec.js new file mode 100644 index 0000000..43fb478 --- /dev/null +++ b/tests/ui/post/photo-editor.spec.js @@ -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=`, 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'); +}); diff --git a/tests/ui/post/post.spec.js b/tests/ui/post/post.spec.js index c61fa0e..8c45d80 100644 --- a/tests/ui/post/post.spec.js +++ b/tests/ui/post/post.spec.js @@ -15,36 +15,31 @@ test.afterAll(() => { created.forEach(cleanupEntry); }); -// ── P1: Post without photo ───────────────────────────────────────────────────── -test('P1: post text-only entry → created on disk and visible in trip feed', async ({ page }) => { +// ── P1: A photo is required — a text-only submit is blocked, writing nothing ─── +// 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 title = `UI Test ${tag}`; await page.goto('/post'); 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_country]"]', 'Testland'); await page.locator('.btn-post').evaluate(el => el.click()); - await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 }); - const entryDir = findEntry(tag); - expect(entryDir, 'Entry folder should exist on disk').toBeTruthy(); - created.push(tag); + // The photo gate fires an inline error and suppresses the success notice. + await expect(page.locator('.photos-collapse .field-error')).toContainText('at least one photo'); + await expect(page.locator('.notices.success, .notices.green')).toHaveCount(0); - const md = readEntryMd(entryDir); - expect(md).toContain(tag); - - // 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); + // The crucial guarantee: nothing was written to disk. + expect(findEntry(tag), 'a blocked submit must not create an entry').toBeNull(); }); // ── 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 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 page.fill('input[name="data[location_city]"]', 'Kyoto'); 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.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[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.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.fill('input[name="data[title]"]', `UI Test ${tag}`); 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 expect(page.locator('.form-messages, .notices')).toContainText( '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.fill('input[name="data[title]"]', `UI Test ${tag}`); 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 expect(page.locator('.form-messages, .notices')).toContainText( '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.fill('input[name="data[title]"]', `UI Test ${tag}`); 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 expect(page.locator('.form-messages, .notices')).toContainText( 'Entry posted successfully!', { timeout: 15_000 } diff --git a/tests/ui/post/validation.spec.js b/tests/ui/post/validation.spec.js index f5a8d39..faa53fa 100644 --- a/tests/ui/post/validation.spec.js +++ b/tests/ui/post/validation.spec.js @@ -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'); }); -// ── V3: Photo limit (max 4) ─────────────────────────────────────────────────── -test('V3: FilePond caps attachments at the limit (4)', async ({ page }) => { +// ── V3: Photo limit (max 6) ─────────────────────────────────────────────────── +// 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'); const browser = page.locator('input.filepond--browser'); - // Attach 4 photos (same fixture — we only need four items). - await browser.setInputFiles([TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO]); + // Attach 6 photos (same fixture — we only need six items). + await browser.setInputFiles([TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO]); 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 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 ───────────────────────────────────────────────