Menu Set store, image pipeline and Site config
The store keeps a Menu Set's state in the directory layout (drafts/ versus published/) and its metadata in a manifest, with publishing as a single directory rename. Editing is allowed on Drafts only, so a published menu can never change under a customer. Stages 2 and 3 of docs/plan.md, with tests.
This commit is contained in:
commit
edea83228c
22 files changed
+3770
No files matched your search
@@ -0,0 +1,276 @@
|
||||
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 editing', 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('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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user