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 });