SolLunar-Kitchen/server/e2e-test.js
avi 582e571eff Replace BTCPay payments with free pre-orders + live ready-status
- 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
2026-09-28 15:09:49 -05:00

124 lines
5.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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); });