initial commit

This commit is contained in:
Olga Bregar Dunaeva committed 2026-10-30 17:20:47 +01:00
commit b6f0791243
24 files changed
+3966

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
// 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);
});