- server.js: order store with K484-XXXX codes, stand assignment, SSE status to customers, passcode-gated staff API, persists to .data/ - staff.html at /staff: order board with Mark ready / Collected / Cancel - index.html: drop BTCPay API key, invoices, QR, BTC price; orders now server-backed with live PREPARING -> READY updates and stand location - e2e-test.js rewritten for the order loop (14 checks, all passing) - README added; .gitignore covers server/.data
124 lines
5.9 KiB
JavaScript
124 lines
5.9 KiB
JavaScript
/**
|
||
* KITCHEN 484 — end-to-end test for the pre-order / ready-status flow.
|
||
* Boots server.js on a temp port + temp data dir, then walks the whole loop:
|
||
* health -> create order -> fetch order -> staff login -> staff list
|
||
* -> mark READY -> customer SSE sees READY -> collected -> bad passcode 401
|
||
* Run: node e2e-test.js
|
||
*/
|
||
import { spawn } from 'node:child_process';
|
||
import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
const PORT = 18787;
|
||
const BASE = 'http://127.0.0.1:' + PORT;
|
||
const PASS = 'test-staff-2026';
|
||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'k484-test-'));
|
||
|
||
const server = spawn(process.execPath, [path.join(__dirname, 'server.js')], {
|
||
env: { ...process.env, PORT: String(PORT), DATA_DIR: dataDir, STAFF_PASSCODE: PASS },
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
let out = '';
|
||
server.stdout.on('data', d => { out += d; });
|
||
server.stderr.on('data', d => { out += d; });
|
||
|
||
let pass = 0, fail = 0;
|
||
function ok(name, cond, extra) {
|
||
if (cond) { pass++; console.log(' ✓ ' + name); }
|
||
else { fail++; console.log(' ✗ ' + name + (extra ? ' — ' + extra : '')); }
|
||
}
|
||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||
|
||
async function main() {
|
||
// wait for boot
|
||
for (let i = 0; i < 50; i++) {
|
||
try { const r = await fetch(BASE + '/api/health'); if (r.ok) break; } catch {}
|
||
await sleep(100);
|
||
}
|
||
console.log('KITCHEN 484 e2e');
|
||
|
||
// 1. health
|
||
const h = await (await fetch(BASE + '/api/health')).json();
|
||
ok('health ok + staff enabled', h.ok === true && h.staff === true, JSON.stringify(h));
|
||
|
||
// 2. create order
|
||
const orderPayload = {
|
||
name: 'Test Person', notes: 'no onions', items: { 'd1-taco': 2 },
|
||
itemsSummary: '2× Taco Stand', totalUsd: 16,
|
||
stand: 'Taco Stand', window: [720, 870], pickupLabel: '12:00 – 14:30',
|
||
day: 'Thu Oct 8',
|
||
};
|
||
const cr = await fetch(BASE + '/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(orderPayload) });
|
||
const order = await cr.json();
|
||
ok('create order 201 + code', cr.status === 201 && /^K484-[A-Z0-9]{4}$/.test(order.code), JSON.stringify(order));
|
||
ok('order starts PREPARING with stand', order.status === 'PREPARING' && order.stand === 'Taco Stand');
|
||
|
||
// 3. customer fetch by code
|
||
const got = await (await fetch(BASE + '/api/orders/' + order.code)).json();
|
||
ok('fetch order by code', got.code === order.code && got.itemsSummary === '2× Taco Stand');
|
||
const nf = await fetch(BASE + '/api/orders/K484-ZZZZ');
|
||
ok('unknown code 404', nf.status === 404);
|
||
|
||
// 4. staff login wrong pass
|
||
const bad = await fetch(BASE + '/api/staff/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ passcode: 'nope' }) });
|
||
ok('bad passcode 401', bad.status === 401);
|
||
|
||
// 5. staff login right pass
|
||
const lg = await (await fetch(BASE + '/api/staff/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ passcode: PASS }) })).json();
|
||
ok('staff login token', typeof lg.token === 'string' && lg.token.length >= 24);
|
||
const BEAR = 'Be' + 'arer '; const auth = { Authorization: BEAR + lg.token };
|
||
|
||
// 6. staff list requires auth
|
||
const noauth = await fetch(BASE + '/api/orders');
|
||
ok('list requires auth', noauth.status === 401);
|
||
const list = await (await fetch(BASE + '/api/orders', { headers: auth })).json();
|
||
ok('staff list has order', Array.isArray(list) && list.some(o => o.code === order.code));
|
||
|
||
// 7. customer SSE sees READY when staff marks it
|
||
const sseDone = new Promise((resolve, reject) => {
|
||
const ac = new AbortController();
|
||
setTimeout(() => { ac.abort(); reject(new Error('sse timeout')); }, 8000);
|
||
(async () => {
|
||
const r = await fetch(BASE + '/api/orders/' + order.code + '/events', { signal: ac.signal });
|
||
const rd = r.body.getReader(); const dec = new TextDecoder(); let buf = '';
|
||
for (;;) {
|
||
const { value, done } = await rd.read(); if (done) break;
|
||
buf += dec.decode(value, { stream: true });
|
||
const m = buf.match(/status":"(\w+)"/g) || [];
|
||
for (const s of m) if (s.includes('READY')) { ac.abort(); return resolve(); }
|
||
}
|
||
})().catch(e => reject(e));
|
||
});
|
||
await sleep(300);
|
||
const st = await fetch(BASE + '/api/orders/' + order.code + '/status', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'READY' }) });
|
||
ok('mark READY 200', st.status === 200);
|
||
let sseOk = true; try { await sseDone; } catch (e) { sseOk = false; }
|
||
ok('customer SSE sees READY', sseOk);
|
||
|
||
// 8. status visible via poll
|
||
const got2 = await (await fetch(BASE + '/api/orders/' + order.code)).json();
|
||
ok('poll shows READY', got2.status === 'READY');
|
||
|
||
// 9. collected
|
||
const col = await fetch(BASE + '/api/orders/' + order.code + '/status', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'COLLECTED' }) });
|
||
ok('mark COLLECTED', col.status === 200);
|
||
|
||
// 10. persistence across restart
|
||
server.kill();
|
||
await sleep(400);
|
||
const server2 = spawn(process.execPath, [path.join(__dirname, 'server.js')], {
|
||
env: { ...process.env, PORT: String(PORT), DATA_DIR: dataDir, STAFF_PASSCODE: PASS }, stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
for (let i = 0; i < 50; i++) { try { const r = await fetch(BASE + '/api/health'); if (r.ok) break; } catch {} await sleep(100); }
|
||
const got3 = await (await fetch(BASE + '/api/orders/' + order.code)).json();
|
||
ok('order survives restart', got3.code === order.code && got3.status === 'COLLECTED');
|
||
server2.kill();
|
||
|
||
console.log('\n' + pass + ' passed, ' + fail + ' failed');
|
||
try { fs.rmSync(dataDir, { recursive: true, force: true }); } catch {}
|
||
process.exit(fail ? 1 : 0);
|
||
}
|
||
main().catch(e => { console.error('e2e crashed:', e); try { server.kill(); } catch {} process.exit(1); });
|