Files
menu/test/routes.test.ts
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

317 lines
12 KiB
TypeScript

import { strict as assert } from 'node:assert';
import path from 'node:path';
import { rm } from 'node:fs/promises';
import { afterEach, beforeEach, describe, it } from 'node:test';
import type { FastifyInstance } from 'fastify';
import { parseSiteConfig } from '../src/config.ts';
import { createSessionToken } from '../src/auth.ts';
import { buildApp } from '../src/server.ts';
import { mediaUrls } from '../src/render/pages.ts';
import { FakePipeline, pngBytes, tempDir, testClock } from './support/harness.ts';
const ASSETS = path.join(import.meta.dirname, '..', 'public');
const SECRET = 'test-session-secret';
let dataDir: string;
let app: FastifyInstance;
const CLOCK = testClock('2026-05-01T06:00:00.000Z');
function config(overrides: Record<string, unknown> = {}) {
return parseSiteConfig({
site: 'menu.example.test',
brand: { name: 'Hops n Grains', tagline: 'Handcrafted Microbrewery' },
sections: [
{ slug: 'food', label: 'Food', order: 1 },
{ slug: 'bar', label: 'Bar', order: 2 },
{ slug: 'wine', label: 'Wine', order: 3 },
],
...overrides,
});
}
async function build(
options: { config?: ReturnType<typeof config> } = {},
): Promise<FastifyInstance> {
const built = await buildApp({
config: options.config ?? config(),
dataDir,
sessionSecret: SECRET,
adminPasswordHash: null,
assetsDir: ASSETS,
secureCookies: false,
logLevel: 'silent',
pipeline: new FakePipeline(),
now: CLOCK.now,
});
await built.ready();
return built;
}
beforeEach(async () => {
dataDir = await tempDir('routes');
app = await build();
});
afterEach(async () => {
await app.close();
await rm(dataDir, { recursive: true, force: true });
});
async function seedPublishedBar() {
const store = app.menu.store;
const set = await store.create('bar', { label: 'Bar Menu', validUntil: '2026-06-26' });
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);
return { set, first, second, published };
}
describe('an empty Site', () => {
it('shows the empty state rather than a bare page', async () => {
const response = await app.inject({ method: 'GET', url: '/' });
assert.equal(response.statusCode, 200);
assert.match(response.body, /The menus are being updated/);
assert.match(response.headers['content-type'] ?? '', /text\/html/);
});
it('404s a Section with no Current set, offering the Sections that exist', async () => {
const response = await app.inject({ method: 'GET', url: '/bar/' });
assert.equal(response.statusCode, 404);
assert.match(response.body, /That menu does not exist/);
assert.match(response.body, /href="\/bar\/"/);
});
});
describe('the landing page', () => {
it('lists Sections that have a Current Menu Set, with page counts', async () => {
await seedPublishedBar();
const response = await app.inject({ method: 'GET', url: '/' });
assert.equal(response.statusCode, 200);
assert.match(response.body, /Hops n Grains/);
assert.match(response.body, /Handcrafted Microbrewery/);
assert.match(response.body, /class="shelf" href="\/bar\/"/);
assert.match(response.body, /<span class="shelf__name">Bar<\/span>/);
assert.match(response.body, /2 pages/);
assert.match(
response.body,
/class="shelf__peek"/,
'the shelf shows a sliver of the menu paper',
);
assert.doesNotMatch(response.body, /shelf__name">Wine/);
});
it('escapes a label that tries to inject markup', async () => {
const store = app.menu.store;
const set = await store.create('bar', { label: '<script>alert(1)</script>' });
await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' });
await store.publish(set.id);
const response = await app.inject({ method: 'GET', url: '/bar/' });
assert.equal(response.statusCode, 200);
assert.doesNotMatch(response.body, /<script>alert\(1\)<\/script>/);
assert.match(response.body, /&lt;script&gt;alert\(1\)&lt;\/script&gt;/);
});
});
describe('a Section page', () => {
it('renders every Page with its variants, dimensions and alt text', async () => {
const { first, second } = await seedPublishedBar();
const response = await app.inject({ method: 'GET', url: '/bar/' });
const body = response.body;
assert.equal(response.statusCode, 200);
assert.match(body, /Bar Menu/);
assert.match(body, /until 26 June 2026/);
assert.match(body, new RegExp(mediaUrls(first).webp));
assert.match(body, new RegExp(mediaUrls(second).jpeg));
assert.match(body, /alt="Bar, page 1 of 2"/);
assert.match(body, /alt="Bar, page 2 of 2"/);
assert.match(body, /width="3000"/);
assert.match(body, /loading="eager"/);
assert.match(body, /loading="lazy"/);
assert.match(body, /id="page-1"/);
assert.match(body, /href="#page-2"/);
assert.doesNotMatch(body, /Older menus/);
assert.doesNotMatch(body, /This is an older menu/);
assert.match(body, /src="\/assets\/app\.js"/);
assert.match(body, /class="bar__nav"/, 'the bar links the other menus');
assert.match(
body,
/class="bar__link is-current"[^>]*aria-current="page"/s,
'the current one is marked',
);
});
it('serves both the trailing-slash and bare URLs', async () => {
await seedPublishedBar();
assert.equal((await app.inject({ method: 'GET', url: '/bar/' })).statusCode, 200);
assert.equal((await app.inject({ method: 'GET', url: '/bar' })).statusCode, 200);
});
it('404s an unknown Section', async () => {
const response = await app.inject({ method: 'GET', url: '/pizza/' });
assert.equal(response.statusCode, 404);
});
});
describe('the bar', () => {
it('links every Section that has a menu, in category order, marking the one being read', async () => {
await seedPublishedBar();
const store = app.menu.store;
const food = await store.create('food', { label: 'Food Menu' });
await store.addPage(food.id, { data: pngBytes('food'), originalName: 'f.jpg' });
await store.publish(food.id);
const page = await app.inject({ method: 'GET', url: '/bar/' });
const linked = [...page.body.matchAll(/class="bar__link[^"]*"\s+href="\/([a-z-]+)\//g)].map(
(match) => match[1],
);
assert.deepEqual(linked, ['food', 'bar']);
assert.equal((page.body.match(/bar__link is-current/g) ?? []).length, 1);
assert.match(page.body, /class="bar__link is-current"[^>]*href="\/bar\/"/s);
});
it('leaves a category with no menu out until it has one', async () => {
await seedPublishedBar();
const page = await app.inject({ method: 'GET', url: '/bar/' });
assert.doesNotMatch(page.body, /bar__link[^>]*>Wine</);
assert.equal((await app.inject({ method: 'GET', url: '/wine/' })).statusCode, 404);
});
});
describe('an archived Menu Set', () => {
it('is reachable at its date URL, marked as older, and does not list itself', async () => {
const store = app.menu.store;
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);
const response = await app.inject({ method: 'GET', url: '/bar/2026-06-26/' });
assert.equal(response.statusCode, 200);
assert.match(response.body, /This is an older menu/);
assert.match(response.body, /See the current menu/);
assert.match(response.body, /href="\/bar\/"/);
assert.match(response.body, /name="robots" content="noindex"/);
assert.doesNotMatch(response.body, /Older menus/);
const current = await app.inject({ method: 'GET', url: '/bar/' });
assert.match(current.body, /Older menus/);
assert.match(current.body, /href="\/bar\/2026-06-26\/"/);
});
it('404s a slug that does not exist', async () => {
await seedPublishedBar();
const response = await app.inject({ method: 'GET', url: '/bar/1999-01-01/' });
assert.equal(response.statusCode, 404);
});
});
describe('media', () => {
it('serves a Viewing image with an immutable cache and the right type', async () => {
const { first } = await seedPublishedBar();
const urls = mediaUrls(first);
const webp = await app.inject({ method: 'GET', url: urls.webp });
assert.equal(webp.statusCode, 200);
assert.equal(webp.headers['content-type'], 'image/webp');
assert.match(String(webp.headers['cache-control']), /immutable/);
assert.equal(webp.rawPayload.length > 0, true);
const jpeg = await app.inject({ method: 'GET', url: urls.jpeg });
assert.equal(jpeg.statusCode, 200);
assert.equal(jpeg.headers['content-type'], 'image/jpeg');
const thumb = await app.inject({ method: 'GET', url: urls.thumb });
assert.equal(thumb.statusCode, 200);
assert.equal(thumb.headers['content-type'], 'image/webp');
});
it('404s a wrong hash, an unknown Page and a malformed name, and never serves an Original', async () => {
const { first } = await seedPublishedBar();
const wrongHash = `/media/${first.id}.${'0'.repeat(12)}.webp`;
assert.equal((await app.inject({ method: 'GET', url: wrongHash })).statusCode, 404);
assert.equal(
(await app.inject({ method: 'GET', url: `/media/${first.id}.abcdefabcdef.webp` })).statusCode,
404,
);
assert.equal(
(await app.inject({ method: 'GET', url: `/media/${first.id}.${first.hash}.gif` })).statusCode,
404,
);
assert.equal(
(await app.inject({ method: 'GET', url: '/media/..%2F..%2Fetc%2Fpasswd' })).statusCode,
404,
);
// A JPEG source shares its extension with the Viewing image, so the bytes
// prove which file was sent: the variant, never the Original beside it.
const jpeg = await app.inject({ method: 'GET', url: mediaUrls(first).jpeg });
assert.equal(jpeg.statusCode, 200);
assert.equal(jpeg.rawPayload.toString(), `jpeg:${first.hash}`);
assert.notEqual(jpeg.rawPayload.toString(), pngBytes('one').toString());
});
it('hides a Draft Page from the public but serves it to a session holder', async () => {
const store = app.menu.store;
const draft = await store.create('bar', { label: 'Bar Menu' });
const page = await store.addPage(draft.id, { data: pngBytes('one'), originalName: 'a.jpg' });
const url = mediaUrls(page).webp;
const anonymous = await app.inject({ method: 'GET', url });
assert.equal(anonymous.statusCode, 404);
const token = createSessionToken(SECRET, 60_000, new Date());
const authed = await app.inject({
method: 'GET',
url,
cookies: { menu_session: token },
});
assert.equal(authed.statusCode, 200);
assert.match(String(authed.headers['cache-control']), /private/);
const tampered = await app.inject({
method: 'GET',
url,
cookies: { menu_session: `${token}tampered` },
});
assert.equal(tampered.statusCode, 404);
});
});
describe('site furniture', () => {
it('answers health, robots and assets', async () => {
assert.equal((await app.inject({ method: 'GET', url: '/healthz' })).body, 'ok');
const robots = await app.inject({ method: 'GET', url: '/robots.txt' });
assert.match(robots.body, /Disallow: \/admin/);
const css = await app.inject({ method: 'GET', url: '/assets/styles.css' });
assert.equal(css.statusCode, 200);
assert.match(css.headers['content-type'] ?? '', /text\/css/);
const font = await app.inject({
method: 'GET',
url: '/assets/fonts/playfair-display-latin.woff2',
});
assert.equal(font.statusCode, 200);
});
it('404s an unknown path with the not-found page', async () => {
const response = await app.inject({ method: 'GET', url: '/nope' });
assert.equal(response.statusCode, 404);
assert.match(response.body, /That menu does not exist/);
});
it('carries the Site theme into the document', async () => {
const custom = config({ theme: { accent: '#aa0088' } });
const customApp = await build({ config: custom });
const response = await customApp.inject({ method: 'GET', url: '/' });
assert.match(response.body, /--accent: #aa0088/);
await customApp.close();
});
});