// End-to-end API tests: starts the real server on a free port with a temp database. // Run: npm test (Node.js >= 22.13, no dependencies) import { test, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createServer } from 'node:net'; const ADMIN_KEY = 'test-admin-key-1234567890'; let proc, base, dataDir, week; const freePort = () => new Promise((resolve) => { const s = createServer().listen(0, () => { const { port } = s.address(); s.close(() => resolve(port)); }); }); async function api(path, body, headers = {}) { const res = await fetch(base + path, { method: body ? 'POST' : 'GET', headers: { 'Content-Type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined, }); return { status: res.status, data: await res.json().catch(() => null), headers: res.headers }; } const dev = (n) => `test0000-0000-0000-0000-${String(n).padStart(12, '0')}`; const mark = (d, cell, on = true, w = week) => api('/api/mark', { d, week: w, cell, on }); before(async () => { dataDir = mkdtempSync(join(tmpdir(), 'bingo-test-')); const port = await freePort(); base = `http://127.0.0.1:${port}`; proc = spawn(process.execPath, ['--disable-warning=ExperimentalWarning', 'server/server.mjs'], { env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, ADMIN_KEY, SEASON_START: '2020-01-06', SEASON_END: '2099-12-31', GOAL: '500', BACKUP_KEEP: '0' }, stdio: ['ignore', 'pipe', 'inherit'], }); for (let i = 0; i < 50; i++) { try { if ((await fetch(base + '/healthz')).ok) break; } catch { /* not up yet */ } await new Promise((r) => setTimeout(r, 100)); } week = (await api('/api/total')).data.week; }); after(() => { proc?.kill(); rmSync(dataDir, { recursive: true, force: true }); }); test('total reports season settings', async () => { const { status, data } = await api('/api/total'); assert.equal(status, 200); assert.equal(data.goal, 500); assert.equal(data.phase, 'active'); assert.match(data.week, /^\d{4}-\d{2}-\d{2}$/); assert.equal(new Date(data.week + 'T00:00:00Z').getUTCDay(), 1, 'week key is a Monday'); }); test('scoring: cells, row, column, diagonal, idempotency, unmark', async () => { const d = dev(1); const t0 = (await api('/api/total')).data.total; const steps = async () => (await api('/api/total')).data.total - t0; for (const c of [1, 2, 3]) await mark(d, c); assert.equal(await steps(), 3); await mark(d, 4); // row 1 complete assert.equal(await steps(), 4 + 5); await mark(d, 4); // same cell again: no change assert.equal(await steps(), 9); for (const c of [5, 9]) await mark(d, c); await mark(d, 13); // column 01-13 complete assert.equal(await steps(), 7 + 10); for (const c of [6, 11]) await mark(d, c); await mark(d, 16); // diagonal 01-16 complete assert.equal(await steps(), 10 + 15); await mark(d, 1, false); // breaks row, column and diagonal at once assert.equal(await steps(), 9); const me = (await api(`/api/me?d=${d}`)).data; assert.deepEqual(me.cells, [2, 3, 4, 5, 6, 9, 11, 13, 16]); }); test('full card is worth 66 steps', async () => { const d = dev(2); const t0 = (await api('/api/total')).data.total; let last; for (let c = 1; c <= 16; c++) last = await mark(d, c); assert.equal(last.data.total - t0, 16 + 10 * 5); }); test('rejects wrong week, bad input and oversized bodies', async () => { assert.equal((await mark(dev(3), 1, true, '2020-01-06')).status, 409); assert.equal((await mark(dev(3), 17)).status, 400); assert.equal((await mark('short', 1)).status, 400); assert.equal((await api('/api/mark', { d: dev(3), week, cell: 1, on: 'yes' })).status, 400); const big = await fetch(base + '/api/mark', { method: 'POST', body: 'x'.repeat(5000) }); assert.equal(big.status, 413); }); test('pulse answer is stored once per device and week', async () => { const d = dev(4); assert.equal((await api('/api/pulse', { d, week, answer: 'mid' })).status, 200); assert.equal((await api('/api/pulse', { d, week, answer: 'low' })).status, 200); assert.equal((await api(`/api/me?d=${d}`)).data.pulse, 'low'); assert.equal((await api('/api/pulse', { d, week, answer: 'meh' })).status, 400); }); test('admin stats need the key and add up', async () => { assert.equal((await api('/api/admin/stats')).status, 403); assert.equal((await api('/api/admin/stats', null, { Authorization: 'Bearer wrong' })).status, 403); const { status, data } = await api('/api/admin/stats', null, { Authorization: `Bearer ${ADMIN_KEY}` }); assert.equal(status, 200); const w = data.weeks.find((x) => x.week === week); assert.equal(w.steps, w.cells + 5 * (w.rows + w.cols + w.diags)); assert.equal(data.total, data.weeks.reduce((n, x) => n + x.steps, 0)); assert.equal(w.pulse.low, 1); assert.equal(data.cells.length, 16); }); test('static pages and security headers', async () => { for (const p of ['/', '/admin', '/admin.html']) { const res = await fetch(base + p); assert.equal(res.status, 200, p); assert.match(res.headers.get('content-security-policy'), /frame-ancestors 'self' https:\/\/teams\.microsoft\.com/); assert.equal(res.headers.get('x-frame-options'), null, 'X-Frame-Options would break the Teams tab'); } assert.equal((await fetch(base + '/fonts/manrope-latin-wght-normal.woff2')).headers.get('content-type'), 'font/woff2'); assert.equal((await fetch(base + '/../server/server.mjs')).status, 404); assert.equal((await fetch(base + '/nope')).status, 404); });