Files
menu/test/admin.test.ts
T
tanshu e64f65642f Add ESLint and Prettier
Prettier owns formatting: .prettierrc.json, format and format:check scripts, and
a one-off pass over the repo so the check is green from the start. Embedded
language formatting is off deliberately - the renderers build HTML in html
tagged templates and letting Prettier reflow those would change the markup
customers receive. docs/ is excluded because it is hand-wrapped prose, and shell
scripts have no Prettier parser.

ESLint covers the JavaScript: the two browser scripts, the build and check
scripts, and its own config. no-var is scoped out for the browser scripts on
purpose; they are plain ES5-shaped IIFEs and a hoisting difference would only
show up in a browser.

ESLint cannot lint the TypeScript here: typescript@7 is the native port with no
JavaScript compiler API, and typescript-eslint requires a 4.8-6.1 peer, so it
refuses to install and forcing it through would leave a parser that cannot read
the compiler. tsc --noEmit stays the TypeScript gate; README records the two
ways to change that.

lint and format:check now run in deploy.sh, and VS Code formats on save with
Prettier and surfaces ESLint findings.
2026-09-22 23:15:08 +05:30

943 lines
32 KiB
TypeScript

import { strict as assert } from 'node:assert';
import { rm } from 'node:fs/promises';
import path from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import type { FastifyInstance } from 'fastify';
import {
LoginThrottle,
SESSION_COOKIE,
createSessionToken,
csrfToken,
hashPassword,
} from '../src/auth.ts';
import { parseSiteConfig } from '../src/config.ts';
import { buildApp } from '../src/server.ts';
import { FakePipeline, pngBytes, tempDir, testClock } from './support/harness.ts';
const ASSETS = path.join(import.meta.dirname, '..', 'public');
const SECRET = 'admin-test-secret';
const PASSWORD = 'a good long password';
let dataDir: string;
let app: FastifyInstance;
let session: string;
let csrf: string;
const CLOCK = testClock('2026-05-01T06:00:00.000Z');
async function build(
throttle?: LoginThrottle,
limits?: { maxUploadBytes?: number; maxPagesPerSet?: number },
): Promise<FastifyInstance> {
const built = await buildApp({
config: parseSiteConfig({
site: 'menu.example.test',
brand: { name: 'Hops n Grains' },
sections: [
{ slug: 'food', label: 'Food', order: 1 },
{ slug: 'bar', label: 'Bar', order: 2 },
],
...(limits === undefined ? {} : { limits }),
}),
dataDir,
sessionSecret: SECRET,
adminPasswordHash: await hashPassword(PASSWORD),
assetsDir: ASSETS,
secureCookies: false,
logLevel: 'silent',
pipeline: new FakePipeline(),
now: CLOCK.now,
...(throttle === undefined ? {} : { throttle }),
});
await built.ready();
return built;
}
beforeEach(async () => {
dataDir = await tempDir('admin');
app = await build();
session = createSessionToken(SECRET, 60 * 60 * 1000, new Date());
csrf = csrfToken(session, SECRET);
});
afterEach(async () => {
await app.close();
await rm(dataDir, { recursive: true, force: true });
});
const cookies = () => ({ [SESSION_COOKIE]: session });
async function newSet(section = 'bar', label = 'Bar Menu', validUntil: string | null = null) {
const response = await app.inject({
method: 'POST',
url: '/admin/sets',
cookies: cookies(),
headers: { 'x-csrf': csrf },
payload: { section, label, ...(validUntil === null ? {} : { validUntil }) },
});
const location = String(response.headers['location'] ?? '');
return { response, id: location.replace('/admin/sets/', '') };
}
async function upload(id: string, name: string) {
const boundary = '----menuadminboundary';
const payload = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="pages"; filename="${name}"\r\nContent-Type: image/jpeg\r\n\r\n`,
),
pngBytes(name),
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
return app.inject({
method: 'POST',
url: `/admin/sets/${id}/pages`,
cookies: cookies(),
headers: {
'x-csrf': csrf,
accept: 'application/json',
'content-type': `multipart/form-data; boundary=${boundary}`,
},
payload,
});
}
describe('signing in', () => {
it('sends an anonymous visitor to the login form', async () => {
const dashboard = await app.inject({ method: 'GET', url: '/admin' });
assert.equal(dashboard.statusCode, 303);
assert.equal(dashboard.headers['location'], '/admin/login');
const login = await app.inject({ method: 'GET', url: '/admin/login' });
assert.equal(login.statusCode, 200);
assert.match(login.body, /type="password"/);
});
it('rejects a wrong password and accepts the right one', async () => {
const wrong = await app.inject({
method: 'POST',
url: '/admin/login',
payload: { password: 'not it' },
});
assert.equal(wrong.statusCode, 401);
assert.equal(wrong.headers['set-cookie'], undefined);
assert.match(wrong.body, /not right/);
const right = await app.inject({
method: 'POST',
url: '/admin/login',
payload: { password: PASSWORD },
});
assert.equal(right.statusCode, 303);
const cookie = String(right.headers['set-cookie']);
assert.match(cookie, /menu_session=/);
assert.match(cookie, /HttpOnly/);
assert.match(cookie, /SameSite=Lax/);
});
it('throttles repeated failures per IP', async () => {
const throttled = await build(new LoginThrottle({ maxFailures: 2, windowMs: 60_000 }));
for (let attempt = 0; attempt < 2; attempt += 1) {
await throttled.inject({ method: 'POST', url: '/admin/login', payload: { password: 'no' } });
}
const blocked = await throttled.inject({
method: 'POST',
url: '/admin/login',
payload: { password: 'no' },
});
assert.equal(blocked.statusCode, 429);
assert.ok(Number(blocked.headers['retry-after']) > 0);
await throttled.close();
});
it('signs out', async () => {
const response = await app.inject({ method: 'POST', url: '/admin/logout', cookies: cookies() });
assert.equal(response.statusCode, 303);
assert.match(String(response.headers['set-cookie']), /menu_session=;/);
});
});
describe('guarding the admin', () => {
it('needs a session for the dashboard and the editor', async () => {
assert.equal((await app.inject({ method: 'GET', url: '/admin' })).statusCode, 303);
const { id } = await newSet();
assert.equal((await app.inject({ method: 'GET', url: `/admin/sets/${id}` })).statusCode, 303);
});
it('needs a CSRF token on mutations', async () => {
const missing = await app.inject({
method: 'POST',
url: '/admin/sets',
cookies: cookies(),
payload: { section: 'bar', label: 'Bar Menu' },
});
assert.equal(missing.statusCode, 403);
const wrong = await app.inject({
method: 'POST',
url: '/admin/sets',
cookies: cookies(),
headers: { 'x-csrf': 'not-the-token' },
payload: { section: 'bar', label: 'Bar Menu' },
});
assert.equal(wrong.statusCode, 403);
// A token minted for a different session must not work either.
const otherSession = createSessionToken(SECRET, 60_000, new Date());
const stolen = await app.inject({
method: 'POST',
url: '/admin/sets',
cookies: cookies(),
headers: { 'x-csrf': csrfToken(otherSession, SECRET) },
payload: { section: 'bar', label: 'Bar Menu' },
});
assert.equal(stolen.statusCode, 403);
});
it('rejects a cross-site mutation even with a session', async () => {
const response = await app.inject({
method: 'POST',
url: '/admin/sets',
cookies: cookies(),
headers: { 'x-csrf': csrf, 'sec-fetch-site': 'cross-site' },
payload: { section: 'bar', label: 'Bar Menu' },
});
assert.equal(response.statusCode, 403);
});
});
describe('the dashboard', () => {
it('lists Sections, their Current set and their Drafts', async () => {
const { id } = await newSet('bar', 'Bar Menu');
await upload(id, 'one.jpg');
await app.inject({
method: 'POST',
url: `/admin/sets/${id}/publish`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
});
await newSet('bar', 'Bar Menu, next');
const response = await app.inject({ method: 'GET', url: '/admin', cookies: cookies() });
assert.equal(response.statusCode, 200);
assert.match(response.body, /Bar Menu/);
assert.match(response.body, /Bar Menu, next/);
assert.match(response.body, /pill--current/);
assert.match(response.body, /pill--draft/);
assert.match(response.body, /No menu published yet/);
assert.match(response.body, /Food Menu/);
});
it('carries a notice through a redirect', async () => {
const response = await app.inject({
method: 'GET',
url: '/admin?notice=Saved.',
cookies: cookies(),
});
assert.match(response.body, /Saved\./);
});
});
describe('the editor', () => {
it('creates a Draft and shows the upload controls', async () => {
const { response, id } = await newSet('bar', 'Bar Menu', '2026-06-26');
assert.equal(response.statusCode, 303);
assert.equal(response.headers['location'], `/admin/sets/${id}`);
const editor = await app.inject({
method: 'GET',
url: `/admin/sets/${id}`,
cookies: cookies(),
});
assert.equal(editor.statusCode, 200);
assert.match(editor.body, /data-drop/);
assert.match(editor.body, /data-pages-admin/);
assert.match(editor.body, /enctype="multipart\/form-data"/);
assert.match(editor.body, new RegExp(`data-csrf="${csrf}"`));
assert.match(editor.body, /No pages yet/);
assert.match(editor.body, /value="2026-06-26"/);
});
it('refuses a Section it does not know', async () => {
const response = await app.inject({
method: 'POST',
url: '/admin/sets',
cookies: cookies(),
headers: { 'x-csrf': csrf },
payload: { section: 'pizza', label: 'Pizza' },
});
assert.equal(response.statusCode, 400);
});
it('updates the label and valid-until date', async () => {
const { id } = await newSet();
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/update`,
cookies: cookies(),
headers: { 'x-csrf': csrf, referer: `/admin/sets/${id}` },
payload: { label: 'Bar Menu, summer', validUntil: '2026-09-30' },
});
assert.equal(response.statusCode, 303);
const set = await app.menu.store.get(id);
assert.equal(set.manifest.label, 'Bar Menu, summer');
assert.equal(set.manifest.validUntil, '2026-09-30');
});
});
describe('uploading Pages', () => {
it('accepts a multipart file and reports the count', async () => {
const { id } = await newSet();
const response = await upload(id, 'IMG_0001.JPG');
assert.equal(response.statusCode, 200);
assert.deepEqual(response.json(), { added: 1, failed: [] });
const editor = await app.inject({
method: 'GET',
url: `/admin/sets/${id}`,
cookies: cookies(),
});
assert.match(editor.body, /IMG_0001\.JPG/);
assert.match(editor.body, /\/media\/thumb-/);
});
it('reports a file the pipeline will not accept, by name', async () => {
const { id } = await newSet();
const boundary = '----x';
const payload = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="pages"; filename="notes.pdf"\r\nContent-Type: application/pdf\r\n\r\nnot an image\r\n--${boundary}--\r\n`,
),
]);
// The fake pipeline accepts anything, so this exercises the wiring only:
// a real rejection is covered by the pipeline tests.
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/pages`,
cookies: cookies(),
headers: {
'x-csrf': csrf,
accept: 'application/json',
'content-type': `multipart/form-data; boundary=${boundary}`,
},
payload,
});
assert.equal(response.statusCode, 200);
});
it('refuses to add a Page to a published set', async () => {
const { id } = await newSet();
await upload(id, 'one.jpg');
await app.inject({
method: 'POST',
url: `/admin/sets/${id}/publish`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
});
const response = await upload(id, 'two.jpg');
assert.equal(response.statusCode, 409);
});
it('reports a file over the upload limit, stores nothing, and stays healthy', async () => {
const limited = await build(undefined, { maxUploadBytes: 2048 });
const created = await limited.inject({
method: 'POST',
url: '/admin/sets',
cookies: cookies(),
headers: { 'x-csrf': csrf },
payload: { section: 'bar', label: 'Bar Menu' },
});
const id = String(created.headers['location']).replace('/admin/sets/', '');
const boundary = '----oversize';
const payload = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="pages"; filename="huge.jpg"\r\nContent-Type: image/jpeg\r\n\r\n`,
),
Buffer.alloc(8 * 1024, 7),
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
const response = await limited.inject({
method: 'POST',
url: `/admin/sets/${id}/pages`,
cookies: cookies(),
headers: {
'x-csrf': csrf,
accept: 'application/json',
'content-type': `multipart/form-data; boundary=${boundary}`,
},
payload,
});
const body = response.json() as { added: number; failed: { error: string }[] };
assert.equal(response.statusCode, 413);
assert.equal(body.added, 0);
assert.equal(body.failed.length, 1, 'the reason is reported once, not twice');
assert.match(body.failed.map((item) => item.error).join(' '), /larger than the 2 KB limit/);
assert.deepEqual((await limited.menu.store.get(id)).manifest.pages, []);
assert.equal((await limited.inject({ method: 'GET', url: '/healthz' })).statusCode, 200);
await limited.close();
});
it('removes a Page', async () => {
const { id } = await newSet();
await upload(id, 'one.jpg');
const set = await app.menu.store.get(id);
const pageId = set.manifest.pages[0]!.id;
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/pages/${pageId}/delete`,
cookies: cookies(),
headers: { 'x-csrf': csrf, referer: `/admin/sets/${id}` },
});
assert.equal(response.statusCode, 303);
assert.equal((await app.menu.store.get(id)).manifest.pages.length, 0);
});
});
describe('ordering', () => {
async function threePages() {
const { id } = await newSet();
await upload(id, 'one.jpg');
await upload(id, 'two.jpg');
await upload(id, 'three.jpg');
const set = await app.menu.store.get(id);
return { id, ids: set.manifest.pages.map((page) => page.id) };
}
it('moves a Page up with a form post', async () => {
const { id, ids } = await threePages();
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/move`,
cookies: cookies(),
headers: { 'x-csrf': csrf, referer: `/admin/sets/${id}` },
payload: { pageId: ids[1], direction: 'up' },
});
assert.equal(response.statusCode, 303);
assert.deepEqual(
(await app.menu.store.get(id)).manifest.pages.map((page) => page.id),
[ids[1], ids[0], ids[2]],
);
});
it('ignores a move past either end', async () => {
const { id, ids } = await threePages();
await app.inject({
method: 'POST',
url: `/admin/sets/${id}/move`,
cookies: cookies(),
headers: { 'x-csrf': csrf, referer: `/admin/sets/${id}` },
payload: { pageId: ids[0], direction: 'up' },
});
assert.deepEqual(
(await app.menu.store.get(id)).manifest.pages.map((page) => page.id),
ids,
);
});
it('reorders from a JSON list, and rejects a bad one', async () => {
const { id, ids } = await threePages();
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/reorder`,
cookies: cookies(),
headers: { 'x-csrf': csrf, accept: 'application/json' },
payload: { pageIds: [ids[2], ids[0], ids[1]] },
});
assert.equal(response.statusCode, 200);
assert.deepEqual(
(await app.menu.store.get(id)).manifest.pages.map((page) => page.id),
[ids[2], ids[0], ids[1]],
);
const bad = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/reorder`,
cookies: cookies(),
headers: { 'x-csrf': csrf, accept: 'application/json' },
payload: { pageIds: [ids[0]] },
});
assert.equal(bad.statusCode, 400);
});
});
describe('publishing, restoring and deleting', () => {
it('publishes a Draft and sends the admin to the live menu', async () => {
const { id } = await newSet('bar', 'Bar Menu', '2026-06-26');
await upload(id, 'one.jpg');
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/publish`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
});
assert.equal(response.statusCode, 303);
assert.equal(response.headers['location'], '/bar/');
assert.equal((await app.menu.store.get(id)).slug, '2026-06-26');
});
it('refuses to publish an empty set', async () => {
const { id } = await newSet();
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/publish`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
});
assert.equal(response.statusCode, 409);
});
it('makes an archived set Current again', async () => {
const first = await newSet('bar', 'Bar Menu', '2026-06-26');
await upload(first.id, 'one.jpg');
await app.inject({
method: 'POST',
url: `/admin/sets/${first.id}/publish`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
});
CLOCK.advance(60_000);
const second = await newSet('bar', 'Bar Menu, next');
await upload(second.id, 'two.jpg');
await app.inject({
method: 'POST',
url: `/admin/sets/${second.id}/publish`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
});
CLOCK.advance(60_000);
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${first.id}/restore`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
});
assert.equal(response.statusCode, 303);
const current = (await app.menu.store.currentBySection()).get('bar');
assert.equal(current?.manifest.id, first.id);
});
it('deletes a Draft and refuses to delete the Current set without confirmation', async () => {
const { id } = await newSet();
await upload(id, 'one.jpg');
await app.inject({
method: 'POST',
url: `/admin/sets/${id}/publish`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
});
const unconfirmed = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/delete`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
payload: {},
});
assert.equal(unconfirmed.statusCode, 409);
const confirmed = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/delete`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
payload: { confirm: 'yes' },
});
assert.equal(confirmed.statusCode, 303);
assert.deepEqual(await app.menu.store.list(), []);
});
});
describe('forms as a browser sends them', () => {
// A real browser posts form fields, not an x-csrf header. These tests exist
// because the header path passed while every button in the UI was broken.
function csrfFrom(body: string): string {
const match = body.match(/name="csrf" value="([^"]+)"/);
assert.ok(match, 'the page carries no CSRF field');
return match[1]!;
}
const encoded = (fields: Record<string, string>) => ({
headers: { 'content-type': 'application/x-www-form-urlencoded' },
payload: new URLSearchParams(fields).toString(),
});
it('creates a Draft from the dashboard form, with no CSRF header', async () => {
const dashboard = await app.inject({ method: 'GET', url: '/admin', cookies: cookies() });
const response = await app.inject({
method: 'POST',
url: '/admin/sets',
cookies: cookies(),
...encoded({ csrf: csrfFrom(dashboard.body), section: 'bar', label: 'Bar Menu' }),
});
assert.equal(response.statusCode, 303);
assert.match(String(response.headers['location']), /^\/admin\/sets\//);
});
it('publishes from the editor form, with no CSRF header', async () => {
const { id } = await newSet();
await upload(id, 'one.jpg');
const editor = await app.inject({
method: 'GET',
url: `/admin/sets/${id}`,
cookies: cookies(),
});
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/publish`,
cookies: cookies(),
...encoded({ csrf: csrfFrom(editor.body) }),
});
assert.equal(response.statusCode, 303);
assert.equal(response.headers['location'], '/bar/');
});
it('moves a Page with the up button as a browser sends it', async () => {
const { id } = await newSet();
await upload(id, 'one.jpg');
await upload(id, 'two.jpg');
const before = (await app.menu.store.get(id)).manifest.pages.map((page) => page.id);
const editor = await app.inject({
method: 'GET',
url: `/admin/sets/${id}`,
cookies: cookies(),
});
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/move`,
cookies: cookies(),
headers: {
'content-type': 'application/x-www-form-urlencoded',
referer: `/admin/sets/${id}`,
},
payload: new URLSearchParams({
csrf: csrfFrom(editor.body),
pageId: before[1]!,
direction: 'up',
}).toString(),
});
assert.equal(response.statusCode, 303);
assert.deepEqual(
(await app.menu.store.get(id)).manifest.pages.map((page) => page.id),
[before[1], before[0]],
);
});
it('accepts the token as a multipart field, the way the no-JS upload form sends it', async () => {
const { id } = await newSet();
const editor = await app.inject({
method: 'GET',
url: `/admin/sets/${id}`,
cookies: cookies(),
});
const token = csrfFrom(editor.body);
const boundary = '----formboundary';
const payload = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="csrf"\r\n\r\n${token}\r\n`,
),
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="pages"; filename="one.jpg"\r\nContent-Type: image/jpeg\r\n\r\n`,
),
pngBytes('one'),
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/pages`,
cookies: cookies(),
headers: {
'content-type': `multipart/form-data; boundary=${boundary}`,
referer: `/admin/sets/${id}`,
},
payload,
});
assert.equal(response.statusCode, 303);
assert.equal((await app.menu.store.get(id)).manifest.pages.length, 1);
});
it('refuses a multipart upload that carries no token at all', async () => {
const { id } = await newSet();
const boundary = '----formboundary';
const payload = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="pages"; filename="one.jpg"\r\nContent-Type: image/jpeg\r\n\r\n`,
),
pngBytes('one'),
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/pages`,
cookies: cookies(),
headers: { 'content-type': `multipart/form-data; boundary=${boundary}` },
payload,
});
assert.equal(response.statusCode, 403);
assert.equal((await app.menu.store.get(id)).manifest.pages.length, 0);
});
it('puts a CSRF field in every form that mutates', async () => {
const { id } = await newSet('bar', 'Bar Menu');
await upload(id, 'one.jpg');
const dashboard = (await app.inject({ method: 'GET', url: '/admin', cookies: cookies() })).body;
const editor = (
await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() })
).body;
for (const [where, body] of [
['dashboard', dashboard],
['editor', editor],
] as const) {
const forms = [...body.matchAll(/<form\b[^>]*>[\s\S]*?<\/form>/g)].map((match) => match[0]);
assert.ok(forms.length > 0, `no forms found in the ${where}`);
for (const form of forms) {
assert.match(form, /name="csrf"/, `a ${where} form carries no CSRF field:\n${form}`);
}
}
});
});
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('managing categories', () => {
const form = (fields: Record<string, string>, referer?: string) => ({
headers: {
'content-type': 'application/x-www-form-urlencoded',
...(referer === undefined ? {} : { referer }),
},
payload: new URLSearchParams(fields).toString(),
});
async function token(): Promise<string> {
const dashboard = await app.inject({ method: 'GET', url: '/admin', cookies: cookies() });
const match = dashboard.body.match(/name="csrf" value="([^"]+)"/);
assert.ok(match, 'the dashboard carries no CSRF field');
return match[1]!;
}
const slugs = () => app.menu.sections.list().map((section) => section.slug);
it('lists the categories with the web address each one keeps', async () => {
const dashboard = await app.inject({ method: 'GET', url: '/admin', cookies: cookies() });
assert.match(dashboard.body, /Categories \(2\)/);
assert.match(dashboard.body, /\/food\//);
assert.match(dashboard.body, /\/bar\//);
});
it('adds a category, slugged from its name, and customers do not see it until it has a menu', async () => {
const response = await app.inject({
method: 'POST',
url: '/admin/sections',
cookies: cookies(),
...form({ csrf: await token(), label: 'Straight Drinks' }),
headers: { 'content-type': 'application/x-www-form-urlencoded', referer: '/admin' },
});
assert.equal(response.statusCode, 303);
assert.match(String(response.headers['location']), /notice=/);
assert.deepEqual(slugs(), ['food', 'bar', 'straight-drinks']);
const landing = await app.inject({ method: 'GET', url: '/' });
assert.doesNotMatch(landing.body, /straight-drinks/);
assert.equal((await app.inject({ method: 'GET', url: '/straight-drinks/' })).statusCode, 404);
});
it('renames a category without moving the web address', async () => {
const referer = '/admin';
const response = await app.inject({
method: 'POST',
url: '/admin/sections/bar/rename',
cookies: cookies(),
...form({ csrf: await token(), label: 'Bar & Cocktails' }, referer),
});
assert.equal(response.statusCode, 303);
assert.equal(app.menu.sections.bySlug('bar')?.label, 'Bar & Cocktails');
assert.equal(app.menu.sections.bySlug('bar-and-cocktails'), undefined);
});
it('reorders categories', async () => {
assert.deepEqual(slugs(), ['food', 'bar']);
const response = await app.inject({
method: 'POST',
url: '/admin/sections/bar/move',
cookies: cookies(),
...form({ csrf: await token(), direction: 'up' }, '/admin'),
});
assert.equal(response.statusCode, 303);
assert.deepEqual(slugs(), ['bar', 'food']);
const dashboard = await app.inject({ method: 'GET', url: '/admin', cookies: cookies() });
assert.ok(
dashboard.body.indexOf('value="Bar"') < dashboard.body.indexOf('value="Food"'),
'the manager lists them in the new order',
);
});
it('refuses to remove a category that still holds menus, then allows it once empty', async () => {
const { id } = await newSet('bar', 'Bar Menu');
await upload(id, 'one.jpg');
const blocked = await app.inject({
method: 'POST',
url: '/admin/sections/bar/delete',
cookies: cookies(),
...form({ csrf: await token() }, '/admin'),
});
assert.equal(blocked.statusCode, 303);
const blockedUrl = String(blocked.headers['location']);
assert.match(blockedUrl, /error=/);
assert.deepEqual(slugs(), ['food', 'bar']);
const dashboard = await app.inject({ method: 'GET', url: blockedUrl, cookies: cookies() });
assert.match(dashboard.body, /still holds 1 menu set/);
await app.inject({
method: 'POST',
url: `/admin/sets/${id}/delete`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
payload: { confirm: 'yes' },
});
const removed = await app.inject({
method: 'POST',
url: '/admin/sections/bar/delete',
cookies: cookies(),
...form({ csrf: await token() }, '/admin'),
});
assert.match(String(removed.headers['location']), /notice=/);
assert.deepEqual(slugs(), ['food']);
});
it('needs a token to change the categories', async () => {
const response = await app.inject({
method: 'POST',
url: '/admin/sections',
cookies: cookies(),
headers: { 'content-type': 'application/x-www-form-urlencoded' },
payload: 'label=Desserts',
});
assert.equal(response.statusCode, 403);
assert.deepEqual(slugs(), ['food', 'bar']);
});
});
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');
await upload(id, 'one.jpg');
const set = await app.menu.store.get(id);
const page = set.manifest.pages[0]!;
const preview = await app.inject({
method: 'GET',
url: `/admin/sets/${id}/preview`,
cookies: cookies(),
});
assert.equal(preview.statusCode, 200);
assert.match(preview.body, /Preview of a Draft/);
assert.match(preview.body, /alt="Bar, page 1 of 1"/);
assert.match(preview.body, /name="robots" content="noindex"/);
assert.doesNotMatch(preview.body, /Older menus/);
const media = `/media/${page.id}.${page.hash}.webp`;
assert.equal((await app.inject({ method: 'GET', url: media })).statusCode, 404);
assert.equal(
(await app.inject({ method: 'GET', url: media, cookies: cookies() })).statusCode,
200,
);
});
it('needs a session to preview', async () => {
const { id } = await newSet();
const response = await app.inject({ method: 'GET', url: `/admin/sets/${id}/preview` });
assert.equal(response.statusCode, 303);
});
});