From 7bf67cc299d21638a94200279a6f753ea3120ea1 Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Tue, 22 Sep 2026 22:01:52 +0530 Subject: [PATCH] Make reordering work on the page you are actually looking at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects, both found by driving the real editor in Firefox: 1. The editor drew grab handles with a grab cursor on sets it refuses to reorder, so the live Food menu looked draggable and did nothing. Handles and the move buttons are now rendered only when reordering is possible, and the notice says what can still be done. 2. Reordering was saved only from dragend. Browsers suppress mouse events during a drag and dragend is not guaranteed to arrive, which left the rows visibly reordered while the server kept the old order — the drag looked like it worked and then reverted on reload. The order now commits from dragover after a pause in the drag, with drop/dragend/mouse events as faster paths, deduped against the last confirmed order and reported in the page. To make (1) coherent the Current set may now be reordered while its Pages stay fixed: order is presentation, media URLs are content-hashed and pages are rendered per request, so nothing stale can be served (ADR 0006). Archives stay frozen. Admin pages are no-store and assets cache for 5 minutes, so a fix like this one is not masked by a stale admin.js. scripts/check-dnd.mjs drives the real editor and asserts the drag persists; it is the only automated check that reaches this path. --- README.md | 9 + docs/adr/0006-reordering-the-current-set.md | 5 + docs/plan.md | 3 +- package.json | 3 +- public/admin.js | 68 ++++++-- scripts/check-dnd.mjs | 172 ++++++++++++++++++++ src/render/admin.ts | 47 ++++-- src/routes/admin.ts | 6 +- src/server.ts | 4 +- src/storage/sets.ts | 16 +- test/admin.test.ts | 78 +++++++++ test/sets.test.ts | 39 ++++- 12 files changed, 415 insertions(+), 35 deletions(-) create mode 100644 docs/adr/0006-reordering-the-current-set.md create mode 100644 scripts/check-dnd.mjs diff --git a/README.md b/README.md index 7dc2ecd..0218117 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,15 @@ npm run verify # integrity of .dev/data npm run check-site -- http://127.0.0.1:3000 # checks a running site from outside ``` +Dragging Pages is the one interaction that only a browser can check, so there is +a harness for it. It needs `geckodriver` on PATH and a running dev server: + +```sh +geckodriver --port 4444 & +npm run check-dnd -- native # drags and asserts it persisted +npm run check-dnd -- none # just reports affordances +``` + ### In VS Code `.vscode/` holds launch configurations and tasks. diff --git a/docs/adr/0006-reordering-the-current-set.md b/docs/adr/0006-reordering-the-current-set.md new file mode 100644 index 0000000..ca2e69c --- /dev/null +++ b/docs/adr/0006-reordering-the-current-set.md @@ -0,0 +1,5 @@ +# The Current set can be reordered, though its Pages are fixed + +A published Menu Set's Pages are still fixed, but the order of the Current set can be changed, because order is presentation rather than content: Viewing image URLs are content-hashed, pages are rendered per request, and nothing downstream caches a rendered page, so moving a Page cannot serve anything stale. A restaurant that finds its live menu in the wrong order fixes it in seconds instead of re-uploading twenty photos; Archives stay frozen as historical records. + +**Considered Options**: duplicating the Current set into a Draft to reorder it. That keeps one simple rule but makes the commonest correction the most expensive, and it moves the menu's URL for what is only a change of order. diff --git a/docs/plan.md b/docs/plan.md index 678c708..4a0a5fc 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -21,7 +21,7 @@ Replaces the `scp`-a-JPG workflow for `menu.hopsngrains.com` with a small self-h | Metadata | JSON manifests, state encoded in the directory layout (ADR 0002) | | Scope | One app instance per Site (ADR 0004); other two Sites migrate later | | Sections | Per-Site config in git; not editable in the admin (ADR 0005) | -| Lifecycle | Draft → Current → Archive; publishing auto-archives the previous set; archives unlisted but reachable | +| Lifecycle | Draft → Current → Archive; publishing auto-archives the previous set; archives unlisted but reachable. A published set's Pages are fixed, but the Current set's order can be corrected (ADR 0006) | | Auth | Single shared password per Site; app login; hash + session secret in an untracked `.env` on the server | | Images | 1600px viewing WebP + JPEG fallback, 480px admin-only thumbnails, Originals kept and never served | | Logging | JSON to stdout only; no Prometheus, no Promtail | @@ -55,6 +55,7 @@ caddy (80/443, TLS) ──reverse_proxy──▶ menu-app.hopsngrains.com:3000 - `` is the valid-until date when set, else the publication date (`YYYY-MM-DD`), suffixed `-2` on collision. - Directory encodes state: `drafts/` is invisible to customers, `published/` is reachable. +- Editing rules: a Draft accepts anything; the Current set accepts reordering only (order is presentation, ADR 0006); an Archive is frozen. - `manifest.json` holds `id`, `section`, `label`, `validUntil`, `createdAt`, `publishedAt` and the ordered Page list; each Page carries its own `id`, content hash, source extension, the Viewing image's dimensions and the uploaded filename. - **Current** is the published set for a Section with the newest `publishedAt`; everything else published is an Archive. No separate current pointer exists to drift. - Writes are temp file + `fsync` + rename, and publishing is a directory rename, so a crash leaves either a Draft or a published set. `publish`/`archive` serialize behind one in-process lock. Boot validation checks that every manifest matches its directory and that Page files exist. diff --git a/package.json b/package.json index d87d11d..a165df9 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "verify": "node src/cli/verify.ts", "repair": "node src/cli/repair.ts", "typecheck": "tsc --noEmit", - "test": "node --test test/*.test.ts" + "test": "node --test test/*.test.ts", + "check-dnd": "node scripts/check-dnd.mjs" }, "dependencies": { "@fastify/cookie": "^11.1.2", diff --git a/public/admin.js b/public/admin.js index 298a8c9..c92952b 100644 --- a/public/admin.js +++ b/public/admin.js @@ -154,6 +154,12 @@ return Array.prototype.slice.call(list.querySelectorAll('[data-page-id]')); } + function order() { + return items().map(function (item) { + return item.getAttribute('data-page-id'); + }); + } + function renumber() { items().forEach(function (item, index) { var number = item.querySelector('.page-admin__number'); @@ -161,11 +167,21 @@ }); } - function saveOrder() { - var ids = items().map(function (item) { - return item.getAttribute('data-page-id'); - }); - say('Saving order…'); + // The order the server last confirmed. Everything below goes through + // saveOrderIfChanged, so a reorder is sent once however the drag ends. + var savedOrder = list ? order().join(',') : ''; + var orderStatus = document.querySelector('[data-order-status]'); + + function sayOrder(message) { + if (orderStatus) orderStatus.textContent = message; + } + + function saveOrderIfChanged() { + if (!list) return; + var ids = order(); + var key = ids.join(','); + if (key === savedOrder) return; + sayOrder('Saving order…'); return fetch('/admin/sets/' + setId + '/reorder', { method: 'POST', headers: { 'content-type': 'application/json', 'x-csrf': csrf, accept: 'application/json' }, @@ -173,15 +189,40 @@ }) .then(function (response) { if (!response.ok) throw new Error('could not save the order'); - say('Order saved.'); + savedOrder = key; + sayOrder('Order saved.'); }) .catch(function (error) { - say(error.message); + // Leave savedOrder alone so the next interaction tries again, and say so + // rather than leaving a reordered list that is not what customers see. + sayOrder(error.message + ' — not saved yet, try again'); }); } if (list) { var dragged = null; + var pendingSave = null; + + // Browsers suppress mouse events while a drag is in flight, and dragend is + // not guaranteed to arrive — so the save cannot wait for the drag to end. + // The rows have already moved by then, and what is on screen is what should + // be saved, so a pause in the drag commits it. + function scheduleSave() { + if (pendingSave) clearTimeout(pendingSave); + pendingSave = setTimeout(function () { + pendingSave = null; + saveOrderIfChanged(); + }, 600); + } + + function commitSave() { + if (pendingSave) { + clearTimeout(pendingSave); + pendingSave = null; + } + renumber(); + saveOrderIfChanged(); + } items().forEach(function (item) { item.addEventListener('dragstart', function (event) { @@ -189,19 +230,20 @@ item.classList.add('is-dragging'); if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'; }); - item.addEventListener('dragend', function () { - item.classList.remove('is-dragging'); - dragged = null; - renumber(); - saveOrder(); - }); + item.addEventListener('dragend', commitSave); item.addEventListener('dragover', function (event) { if (!dragged || dragged === item) return; event.preventDefault(); var rect = item.getBoundingClientRect(); var after = event.clientY > rect.top + rect.height / 2; list.insertBefore(dragged, after ? item.nextSibling : item); + renumber(); + scheduleSave(); }); }); + + ['drop', 'pointerup', 'mouseup', 'pointercancel'].forEach(function (type) { + list.addEventListener(type, commitSave); + }); } })(); diff --git a/scripts/check-dnd.mjs b/scripts/check-dnd.mjs new file mode 100644 index 0000000..dbe9580 --- /dev/null +++ b/scripts/check-dnd.mjs @@ -0,0 +1,172 @@ +/* Drives the real admin editor in Firefox and reports whether dragging a Page + actually reorders it and reaches the server. This is the only automated check + that reaches the drag path: the order is committed from dragover, because + browsers suppress mouse events during a drag and dragend is not guaranteed. + + Needs a running dev server and geckodriver on PATH: + + npm run dev # in one terminal + geckodriver --port 4444 & # in another + npm run check-dnd -- [native|synthetic|none] + + Use "none" to inspect a set's affordances without dragging: an Archive should + report no draggable rows and no grab handles. +*/ + +const GECKO = 'http://127.0.0.1:4444'; +const SITE = process.env.SITE ?? 'http://127.0.0.1:3000'; +const PASSWORD = process.env.DEV_PASSWORD ?? 'menu-dev-password'; +const setId = process.argv[2]; +const gesture = process.argv[3] ?? 'native'; +if (!setId) throw new Error('usage: node .tmp/dnd.mjs [native|synthetic|none]'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const KEY = 'element-6066-11e4-a52e-4f735466cecf'; + +async function cmd(method, path, body) { + const response = await fetch(GECKO + path, { + method, + headers: body ? { 'content-type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + }); + const json = JSON.parse(await response.text()); + if (json.value && json.value.error) throw new Error(`${json.value.error}: ${json.value.message}`); + return json.value; +} +const find = async (sid, selector) => + (await cmd('POST', `/session/${sid}/element`, { using: 'css selector', value: selector }))[KEY]; +const findAll = async (sid, selector) => + (await cmd('POST', `/session/${sid}/elements`, { using: 'css selector', value: selector })).map( + (e) => e[KEY], + ); +const click = (sid, element) => cmd('POST', `/session/${sid}/element/${element}/click`, {}); +const type = (sid, element, value) => + cmd('POST', `/session/${sid}/element/${element}/value`, { text: value }); +const script = (sid, body, args = []) => + cmd('POST', `/session/${sid}/execute/sync`, { script: body, args }); + +async function pageState(sid) { + return script( + sid, + `return { + rows: Array.from(document.querySelectorAll('[data-page-id]')).map(function (li) { + var handle = li.querySelector('.page-admin__handle'); + return { + id: li.getAttribute('data-page-id'), + draggable: li.draggable, + hasHandle: !!handle, + cursor: handle ? getComputedStyle(handle).cursor : null, + formCount: li.querySelectorAll('form').length + }; + }), + hasList: !!document.querySelector('[data-pages-admin]'), + scriptLoaded: !!document.querySelector('script[src="/assets/admin.js"]'), + dnd: window.__dnd || null + };`, + ); +} + +const session = await cmd('POST', '/session', { + capabilities: { alwaysMatch: { 'moz:firefoxOptions': { args: ['-headless'] } } }, +}); +const sid = session.sessionId; + +try { + await cmd('POST', `/session/${sid}/window/rect`, { width: 430, height: 1000 }); + await cmd('POST', `/session/${sid}/url`, { url: `${SITE}/admin/login` }); + await sleep(500); + await type(sid, await find(sid, 'input[name=password]'), PASSWORD); + await click(sid, await find(sid, 'button[type=submit]')); + await sleep(800); + + await cmd('POST', `/session/${sid}/url`, { url: `${SITE}/admin/sets/${setId}` }); + await sleep(1000); + + await script( + sid, + `window.__dnd = { dragstart: 0, dragover: 0, drop: 0, dragend: 0 }; + ['dragstart','dragover','drop','dragend'].forEach(function (name) { + document.addEventListener(name, function () { window.__dnd[name] += 1; }, true); + }); + return true;`, + ); + + const before = await pageState(sid); + console.log(`rows: ${before.rows.length}`); + console.log(`draggable rows: ${before.rows.filter((r) => r.draggable).length}`); + console.log(`rows with a grab handle: ${before.rows.filter((r) => r.hasHandle).length}`); + console.log(`handle cursor: ${before.rows[0]?.cursor}`); + console.log(`per-row up/down/delete forms: ${before.rows[0]?.formCount}`); + console.log(`[data-pages-admin] list present: ${before.hasList}`); + console.log(`admin.js included: ${before.scriptLoaded}`); + const beforeIds = before.rows.map((r) => r.id); + + if (gesture === 'native' && before.rows.length >= 3) { + const elements = await findAll(sid, '[data-page-id]'); + const origin = (element) => ({ [KEY]: element }); + const from = elements[0]; + const to = elements[2]; + await cmd('POST', `/session/${sid}/actions`, { + actions: [ + { + type: 'pointer', + id: 'mouse', + parameters: { pointerType: 'mouse' }, + actions: [ + { type: 'pointerMove', duration: 0, origin: origin(from), x: 5, y: 5 }, + { type: 'pointerDown', button: 0 }, + { type: 'pause', duration: 200 }, + { type: 'pointerMove', duration: 300, origin: origin(to), x: 5, y: 40 }, + { type: 'pause', duration: 300 }, + { type: 'pointerUp', button: 0 }, + ], + }, + ], + }); + await sleep(1200); + } + + if (gesture === 'synthetic' && before.rows.length >= 3) { + // Exercises our handlers without the browser's own drag machinery. + await script( + sid, + `var rows = document.querySelectorAll('[data-page-id]'); + var from = rows[0], to = rows[2]; + var rect = to.getBoundingClientRect(); + function fire(target, type, extra) { + var event = new DragEvent(type, Object.assign({ bubbles: true, cancelable: true, dataTransfer: new DataTransfer() }, extra || {})); + target.dispatchEvent(event); + } + fire(from, 'dragstart'); + fire(to, 'dragover', { clientY: rect.bottom - 2 }); + fire(to, 'drop', { clientY: rect.bottom - 2 }); + fire(from, 'dragend'); + return true;`, + ); + await sleep(1200); + } + + const after = await pageState(sid); + const afterIds = after.rows.map((r) => r.id); + console.log(`\nevents seen: ${JSON.stringify(after.dnd)}`); + + const domChanged = JSON.stringify(beforeIds) !== JSON.stringify(afterIds); + console.log(`DOM order changed by the drag: ${domChanged}`); + if (domChanged) { + console.log(` was: ${beforeIds.slice(0, 3).join(', ')} ...`); + console.log(` now: ${afterIds.slice(0, 3).join(', ')} ...`); + } + + // What the server holds now, read back through the editor. + await cmd('POST', `/session/${sid}/url`, { url: `${SITE}/admin/sets/${setId}` }); + await sleep(800); + const reloaded = await pageState(sid); + const serverIds = reloaded.rows.map((r) => r.id); + const persisted = JSON.stringify(serverIds) !== JSON.stringify(beforeIds); + console.log(`server order changed (after reload): ${persisted}`); + if (persisted) console.log(` server now: ${serverIds.slice(0, 3).join(', ')} ...`); + + console.log(`\nVERDICT: ${domChanged && persisted ? 'PASS' : 'FAIL'} (drag reorders and persists)`); +} finally { + await cmd('DELETE', `/session/${sid}`); +} diff --git a/src/render/admin.ts b/src/render/admin.ts index 62cc7f8..c55fb58 100644 --- a/src/render/admin.ts +++ b/src/render/admin.ts @@ -157,6 +157,8 @@ export interface EditorInput { section: SectionConfig; set: StoredSet; csrf: string; + /** True when this set is the one customers are being served right now. */ + current: boolean; notice: string | null; error: string | null; } @@ -164,18 +166,21 @@ export interface EditorInput { export function renderEditor(input: EditorInput): Html { const { config, section, set } = input; const draft = set.state === 'draft'; + // A published set's Pages are fixed, but its order can still be corrected + // while it is the Current one. Archives are read-only. + const reorderable = draft || input.current; const pageItems = set.manifest.pages.map((page, index) => { const names = variantFileNames(page); - return html`
  • - + return html`
  • + ${reorderable ? html`` : ''} ${index + 1} ${page.originalName} ${page.width} × ${page.height} - ${draft + ${reorderable ? html`
    @@ -189,10 +194,12 @@ export function renderEditor(input: EditorInput): Html {
    -
    - - -
    + ${draft + ? html`
    + + +
    ` + : ''}
    ` : ''}
  • `; @@ -212,12 +219,20 @@ export function renderEditor(input: EditorInput): Html { ${input.notice === null ? '' : html`

    ${input.notice}

    `} ${input.error === null ? '' : html`

    ${input.error}

    `} - ${set.state === 'published' - ? html`

    - This menu is published at /${section.slug}/${set.slug}/. - A published menu never changes; to change it, create a new Menu Set. -

    ` - : ''} + ${set.state === 'draft' + ? '' + : input.current + ? html`

    + This is the menu customers are seeing at + /${section.slug}/. Its Pages are fixed, but you can drag them + into a different order and it takes effect straight away. To add, remove or replace a Page, + create a new Menu Set. +

    ` + : html`

    + This is an older menu, kept as it was at + /${section.slug}/${set.slug}/. Archived menus cannot + be changed; press "Make current" to put it back in front of customers. +

    `}

    Details

    @@ -253,10 +268,12 @@ export function renderEditor(input: EditorInput): Html {
      ${pageItems.length === 0 ? html`
    • No pages yet.
    • ` : join(pageItems)}
    +

    ` : html`

    Pages

    -
      ${join(pageItems)}
    +
      ${join(pageItems)}
    +

    `}
    @@ -271,7 +288,7 @@ export function renderEditor(input: EditorInput): Html { Preview` : html`View live`} - ${set.state === 'published' + ${set.state === 'published' && !input.current ? html`
    diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 0f41af5..5d5e1b8 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -44,7 +44,9 @@ export interface AdminRouteDeps { } function sendHtml(reply: FastifyReply, body: string, status = 200): FastifyReply { - return reply.code(status).type(HTML).send(body); + // Admin pages carry a CSRF token and one-off notices, so never let a browser + // keep them. + return reply.code(status).header('cache-control', 'no-store').type(HTML).send(body); } /** Same-origin check: the second belt beside the CSRF token. */ @@ -212,6 +214,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute const section = sectionBySlug(config, set.section); if (section === undefined) return reply.code(404).send('Unknown Section.'); const query = request.query as { notice?: string; error?: string }; + const current = (await store.currentBySection()).get(set.section); return sendHtml( reply, renderEditor({ @@ -219,6 +222,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute section, set, csrf: csrfToken(sessionToken(request), deps.sessionSecret), + current: current?.manifest.id === set.id, notice: query.notice ?? null, error: query.error ?? null, }).value, diff --git a/src/server.ts b/src/server.ts index fd7befb..d2c4657 100644 --- a/src/server.ts +++ b/src/server.ts @@ -75,7 +75,9 @@ export async function buildApp(options: AppOptions): Promise { prefix: '/assets/', index: false, cacheControl: true, - maxAge: '1h', + // Short, because a deploy that fixes admin.js must not wait an hour to be + // picked up. Media keeps its own immutable caching in the media route. + maxAge: '5m', }); const store = new SetStore({ diff --git a/src/storage/sets.ts b/src/storage/sets.ts index 193d7c7..bf63196 100644 --- a/src/storage/sets.ts +++ b/src/storage/sets.ts @@ -276,9 +276,23 @@ export class SetStore { }); } + /** + * Reordering is allowed on a Draft and on the Current Menu Set, because order + * is presentation rather than content: media URLs are content-hashed and + * pages are rendered per request, so nothing stale can be served by moving a + * Page. Archived sets stay frozen. + */ + private async requireReorderable(setId: string): Promise { + const set = await this.get(setId); + if (set.state === 'draft') return set; + const current = (await this.currentBySection()).get(set.section); + if (current?.manifest.id === set.id) return set; + throw new StoreError('only a Draft or the Current Menu Set can be reordered', 409); + } + async reorder(setId: string, pageIds: readonly string[]): Promise { await this.exclusive(async () => { - const set = await this.requireDraft(setId); + const set = await this.requireReorderable(setId); const before = set.manifest.pages.map((page) => page.id); const after = [...pageIds]; if (before.length !== after.length) { diff --git a/test/admin.test.ts b/test/admin.test.ts index 772dc9c..af66695 100644 --- a/test/admin.test.ts +++ b/test/admin.test.ts @@ -673,6 +673,84 @@ describe('forms as a browser sends them', () => { }); }); +describe('reordering', () => { + async function publishBar(label = 'Bar Menu', validUntil: string | null = null) { + const { id } = await newSet('bar', label, validUntil); + await upload(id, 'one.jpg'); + await upload(id, 'two.jpg'); + const response = await app.inject({ + method: 'POST', + url: `/admin/sets/${id}/publish`, + cookies: cookies(), + headers: { 'x-csrf': csrf }, + }); + assert.equal(response.statusCode, 303); + return id; + } + + it('offers dragging on the Current set and the new order reaches customers', async () => { + const id = await publishBar('Bar Menu', '2026-06-26'); + + const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() }); + assert.equal(editor.statusCode, 200); + assert.match(editor.body, /data-pages-admin/); + assert.match(editor.body, /page-admin__handle/); + assert.equal((editor.body.match(/draggable="true"/g) ?? []).length, 2); + assert.match(editor.body, /the menu customers are seeing/); + assert.doesNotMatch(editor.body, /Make current/); + + const before = (await app.menu.store.get(id)).manifest.pages.map((page) => page.id); + const reorder = await app.inject({ + method: 'POST', + url: `/admin/sets/${id}/reorder`, + cookies: cookies(), + headers: { 'x-csrf': csrf, accept: 'application/json' }, + payload: { pageIds: [before[1], before[0]] }, + }); + assert.equal(reorder.statusCode, 200); + + const live = await app.inject({ method: 'GET', url: '/bar/' }); + const served = [...live.body.matchAll(/\/media\/([0-9a-f-]{36})\./g)].map((match) => match[1]); + assert.deepEqual([...new Set(served)], [before[1], before[0]]); + }); + + it('keeps an Archive read-only, with no handle to pull', async () => { + const archived = await publishBar('Bar Menu', '2026-06-26'); + + CLOCK.advance(60_000); + const next = await newSet('bar', 'Bar Menu, next'); + await upload(next.id, 'two.jpg'); + await app.inject({ + method: 'POST', + url: `/admin/sets/${next.id}/publish`, + cookies: cookies(), + headers: { 'x-csrf': csrf }, + }); + + const editor = await app.inject({ + method: 'GET', + url: `/admin/sets/${archived}`, + cookies: cookies(), + }); + assert.equal(editor.statusCode, 200); + assert.match(editor.body, /This is an older menu/); + assert.doesNotMatch(editor.body, /page-admin__handle/); + assert.doesNotMatch(editor.body, /data-pages-admin/); + assert.equal((editor.body.match(/draggable="false"/g) ?? []).length, 2); + assert.match(editor.body, /Make current/); + + const pages = (await app.menu.store.get(archived)).manifest.pages.map((page) => page.id); + const reorder = await app.inject({ + method: 'POST', + url: `/admin/sets/${archived}/reorder`, + cookies: cookies(), + headers: { 'x-csrf': csrf, accept: 'application/json' }, + payload: { pageIds: [pages[1], pages[0]] }, + }); + assert.equal(reorder.statusCode, 409); + }); +}); + describe('previewing a Draft', () => { it('renders the customer page with a banner, and serves its media to the session', async () => { const { id } = await newSet('bar', 'Bar Menu', '2026-06-26'); diff --git a/test/sets.test.ts b/test/sets.test.ts index 37a04e9..492fb81 100644 --- a/test/sets.test.ts +++ b/test/sets.test.ts @@ -194,18 +194,53 @@ describe('publishing', () => { await assert.rejects(() => store.publish('missing'), StoreError); }); - it('freezes a published set against editing', async () => { + it('freezes a published set against adding, removing and relabelling', async () => { const { store } = makeStore(dataDir); const set = await store.create('bar', { label: 'Bar Menu' }); const page = await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' }); await store.publish(set.id); await assert.rejects(() => store.addPage(set.id, { data: pngBytes('two'), originalName: 'b.jpg' }), StoreError); - await assert.rejects(() => store.reorder(set.id, [page.id]), StoreError); await assert.rejects(() => store.update(set.id, { label: 'New' }), StoreError); await assert.rejects(() => store.removePage(set.id, page.id), StoreError); }); + it('lets the Current set be reordered, because order is presentation not content', async () => { + const { store } = makeStore(dataDir); + const set = await store.create('bar', { label: 'Bar Menu' }); + const first = await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' }); + const second = await store.addPage(set.id, { data: pngBytes('two'), originalName: 'b.jpg' }); + const published = await store.publish(set.id); + + await store.reorder(published.id, [second.id, first.id]); + + const current = (await store.currentBySection()).get('bar'); + assert.equal(current?.manifest.id, published.id); + assert.deepEqual( + current?.manifest.pages.map((page) => page.id), + [second.id, first.id], + ); + assert.equal(current?.slug, '2026-01-01'); + }); + + it('refuses to reorder an Archive', async () => { + const clock = testClock('2026-01-05T04:00:00.000Z'); + const { store } = makeStore(dataDir, { clock }); + const first = await store.create('bar', { label: 'Bar Menu' }); + const page = await store.addPage(first.id, { data: pngBytes('one'), originalName: 'a.jpg' }); + await store.publish(first.id); + + clock.advance(60_000); + const second = await store.create('bar', { label: 'Bar Menu, next' }); + await store.addPage(second.id, { data: pngBytes('two'), originalName: 'b.jpg' }); + await store.publish(second.id); + + await assert.rejects( + () => store.reorder(first.id, [page.id]), + (error: unknown) => error instanceof StoreError && error.status === 409, + ); + }); + it('restores an archived set to Current without changing its URL', async () => { const clock = testClock('2026-01-05T04:00:00.000Z'); const { store } = makeStore(dataDir, { clock });