Files
menu/test/sets.test.ts
T
tanshu 7bf67cc299 Make reordering work on the page you are actually looking at
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.
2026-09-22 22:01:52 +05:30

312 lines
13 KiB
TypeScript

import { strict as assert } from 'node:assert';
import { readdir, readFile, rm } from 'node:fs/promises';
import path from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { StoreError, sitePaths } from '../src/storage/sets.ts';
import { makeStore, pngBytes, tempDir, testClock } from './support/harness.ts';
let dataDir: string;
beforeEach(async () => {
dataDir = await tempDir('sets');
});
afterEach(async () => {
await rm(dataDir, { recursive: true, force: true });
});
async function filesIn(dir: string): Promise<string[]> {
return (await readdir(dir)).sort();
}
describe('creating a Menu Set', () => {
it('writes a Draft manifest under its Section', async () => {
const { store } = makeStore(dataDir);
const set = await store.create('bar', { label: 'Bar Menu' });
assert.equal(set.state, 'draft');
assert.equal(set.manifest.publishedAt, null);
assert.deepEqual(set.manifest.pages, []);
assert.equal(
set.dir,
path.join(sitePaths(dataDir).drafts, 'bar', set.manifest.id),
);
assert.deepEqual(await filesIn(set.dir), ['manifest.json']);
const listed = await store.list();
assert.equal(listed.length, 1);
assert.equal(listed[0]?.manifest.label, 'Bar Menu');
});
it('refuses an unknown Section, an empty label and a bad date', async () => {
const { store } = makeStore(dataDir);
await assert.rejects(() => store.create('wine', { label: 'Wine' }), StoreError);
await assert.rejects(() => store.create('bar', { label: ' ' }), StoreError);
await assert.rejects(
() => store.create('bar', { label: 'Bar', validUntil: '26-06-2026' }),
StoreError,
);
});
});
describe('adding Pages', () => {
it('stores both variants and the Original, and records the Page', async () => {
const { store, pipeline } = makeStore(dataDir);
const set = await store.create('bar', { label: 'Bar Menu', validUntil: '2026-06-26' });
const page = await store.addPage(set.id, {
data: pngBytes('one'),
originalName: 'IMG_0001.JPG',
});
assert.equal(pipeline.calls, 1);
assert.equal(page.sourceExt, 'jpg');
assert.equal(page.width, 3000);
assert.equal(page.originalName, 'IMG_0001.JPG');
const names = (await filesIn(set.dir)).filter((name) => name !== 'manifest.json');
assert.equal(names.length, 3);
assert.ok(names.some((name) => name.endsWith('.webp') && !name.startsWith('thumb-')));
assert.ok(names.some((name) => name.endsWith('.jpg')));
assert.ok(names.some((name) => name.startsWith('thumb-') && name.endsWith('.webp')));
const originals = await filesIn(path.join(sitePaths(dataDir).originals, 'bar', set.id));
assert.deepEqual(originals, [`${page.id}.${page.hash}.jpg`]);
assert.deepEqual(
await readFile(path.join(sitePaths(dataDir).originals, 'bar', set.id, originals[0]!)),
pngBytes('one'),
);
const reread = await store.get(set.id);
assert.equal(reread.manifest.pages.length, 1);
assert.equal(reread.manifest.pages[0]?.id, page.id);
});
it('enforces the per-set Page limit', async () => {
const { store } = makeStore(dataDir, { maxPagesPerSet: 2 });
const set = await store.create('bar', { label: 'Bar Menu' });
await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' });
await store.addPage(set.id, { data: pngBytes('two'), originalName: 'b.jpg' });
await assert.rejects(
() => store.addPage(set.id, { data: pngBytes('three'), originalName: 'c.jpg' }),
(error: unknown) => error instanceof StoreError && error.status === 409,
);
});
it('reorders Pages and refuses an incomplete order', 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' });
await store.reorder(set.id, [second.id, first.id]);
assert.deepEqual(
(await store.get(set.id)).manifest.pages.map((page) => page.id),
[second.id, first.id],
);
await assert.rejects(() => store.reorder(set.id, [second.id]), StoreError);
await assert.rejects(() => store.reorder(set.id, [second.id, second.id]), StoreError);
await assert.rejects(() => store.reorder(set.id, [second.id, 'nope']), StoreError);
});
it('removes a Page and its files', 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.removePage(set.id, page.id);
assert.deepEqual((await store.get(set.id)).manifest.pages, []);
assert.deepEqual(await filesIn(set.dir), ['manifest.json']);
assert.deepEqual(await filesIn(path.join(sitePaths(dataDir).originals, 'bar', set.id)), []);
});
it('leaves no temporary manifest files behind', async () => {
const { store } = makeStore(dataDir);
const set = await store.create('bar', { label: 'Bar Menu' });
await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' });
const leftovers = (await filesIn(set.dir)).filter((name) => name.endsWith('.tmp'));
assert.deepEqual(leftovers, []);
});
});
describe('publishing', () => {
it('moves the Draft to its date URL and makes it Current', async () => {
const clock = testClock('2026-01-05T04:00:00.000Z');
const { store } = makeStore(dataDir, { clock });
const set = await store.create('bar', { label: 'Bar Menu', validUntil: '2026-06-26' });
await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' });
const published = await store.publish(set.id);
assert.equal(published.slug, '2026-06-26');
assert.equal(
published.dir,
path.join(sitePaths(dataDir).published, 'bar', '2026-06-26'),
);
assert.equal(published.manifest.publishedAt, '2026-01-05T04:00:00.000Z');
assert.deepEqual(await filesIn(sitePaths(dataDir).drafts), ['bar']);
const current = (await store.currentBySection()).get('bar');
assert.equal(current?.manifest.id, set.id);
});
it('falls back to the publication date, in the Site time zone', async () => {
// 20:00 UTC is already the next day in Asia/Kolkata.
const clock = testClock('2026-03-01T20:00:00.000Z');
const { store } = makeStore(dataDir, { clock });
const set = await store.create('food', { label: 'Food Menu' });
await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' });
const published = await store.publish(set.id);
assert.equal(published.slug, '2026-03-02');
});
it('suffixes a colliding date URL', async () => {
const clock = testClock('2026-01-05T04:00:00.000Z');
const { store } = makeStore(dataDir, { clock });
const first = await store.create('bar', { label: 'Bar Menu', validUntil: '2026-06-26' });
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, revised', validUntil: '2026-06-26' });
await store.addPage(second.id, { data: pngBytes('two'), originalName: 'b.jpg' });
const published = await store.publish(second.id);
assert.equal(published.slug, '2026-06-26-2');
const current = (await store.currentBySection()).get('bar');
assert.equal(current?.manifest.id, second.id);
const archives = await store.archives('bar');
assert.deepEqual(
archives.map((set) => set.manifest.id),
[first.id],
);
});
it('refuses to publish an empty set or one with no Pages', async () => {
const { store } = makeStore(dataDir);
const set = await store.create('bar', { label: 'Bar Menu' });
await assert.rejects(
() => store.publish(set.id),
(error: unknown) => error instanceof StoreError && error.status === 409,
);
await assert.rejects(() => store.publish('missing'), StoreError);
});
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.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 });
const first = await store.create('bar', { label: 'Bar Menu', validUntil: '2026-06-26' });
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, revised' });
await store.addPage(second.id, { data: pngBytes('two'), originalName: 'b.jpg' });
await store.publish(second.id);
assert.equal((await store.currentBySection()).get('bar')?.manifest.id, second.id);
clock.advance(60_000);
const restored = await store.restore(first.id);
assert.equal(restored.slug, '2026-06-26');
assert.equal((await store.currentBySection()).get('bar')?.manifest.id, first.id);
assert.deepEqual(
(await store.archives('bar')).map((set) => set.manifest.id),
[second.id],
);
});
});
describe('deleting', () => {
it('refuses the Current set unless the deletion is confirmed, then removes everything', async () => {
const { store } = makeStore(dataDir);
const set = await store.create('bar', { label: 'Bar Menu' });
await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' });
await store.publish(set.id);
await assert.rejects(
() => store.remove(set.id),
(error: unknown) => error instanceof StoreError && error.status === 409,
);
await store.remove(set.id, { confirmCurrent: true });
assert.deepEqual(await store.list(), []);
assert.deepEqual(await filesIn(path.join(sitePaths(dataDir).published, 'bar')), []);
assert.deepEqual(await filesIn(path.join(sitePaths(dataDir).originals, 'bar')), []);
});
it('deletes a Draft without confirmation', async () => {
const { store } = makeStore(dataDir);
const set = await store.create('food', { label: 'Food Menu' });
await store.remove(set.id);
assert.deepEqual(await store.list(), []);
});
});
describe('page index', () => {
it('maps every Page to its owning set, draft or published', async () => {
const { store } = makeStore(dataDir);
const draft = await store.create('bar', { label: 'Bar Menu' });
const published = await store.create('food', { label: 'Food Menu' });
const draftPage = await store.addPage(draft.id, { data: pngBytes('one'), originalName: 'a.jpg' });
const publishedPage = await store.addPage(published.id, {
data: pngBytes('two'),
originalName: 'b.jpg',
});
await store.publish(published.id);
const index = await store.pageIndex();
assert.equal(index.get(draftPage.id)?.set.state, 'draft');
assert.equal(index.get(publishedPage.id)?.set.state, 'published');
assert.equal(index.get(publishedPage.id)?.set.slug, '2026-01-01');
});
});