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
This commit is contained in:
avi 2026-09-28 15:09:49 -05:00
commit 582e571eff
10 changed files with 656 additions and 1096 deletions

View file

@ -1,121 +1,124 @@
/**
* E2E test: boots mock-btcpay + server, then exercises:
* health → settings save → webhook auto-register → create invoice →
* poll status → (mock settles + fires signed webhook) → status Paid → SSE saw the update
* 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 crypto from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const PORT = 8787, MOCK_PORT = 8899, BASE = `http://localhost:${PORT}`;
let failures = 0;
function check(name, cond, extra = '') {
console.log((cond ? ' PASS ' : ' FAIL ') + name + (extra ? ` [${extra}]` : ''));
if (!cond) failures++;
}
async function j(method, path, body) {
const r = await fetch(BASE + path, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
return { status: r.status, body: await r.json().catch(() => ({})) };
}
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-'));
function boot(file, env) {
const c = spawn(process.execPath, [file], {
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
c.stdout.on('data', d => process.env.QUIET || console.log(' [' + file + '] ' + d.toString().trim()));
c.stderr.on('data', d => process.stderr.write(d));
return c;
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));
/* boot */
const mock = boot('mock-btcpay.js', { MOCK_PORT: String(MOCK_PORT), MOCK_SETTLE_MS: '6000' });
const srv = boot('server.js', {
PORT: String(PORT),
DATA_FILE: '/tmp/fest484-e2e-invoices.json',
WEBHOOK_PUBLIC_URL: BASE,
MOCK_API_KEY: 'mock-store-key',
});
try { fs.rmSync('/tmp/fest484-e2e-invoices.json'); } catch { }
await sleep(1200);
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');
try {
/* 1 health */
const h = await j('GET', '/health');
check('health ok', h.status === 200 && h.body.ok === true);
// 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 save settings (points at the mock) */
const s = await j('POST', '/api/settings', { url: `http://localhost:${MOCK_PORT}`, store: 'mock-store-1234', apiKey: 'mock-store-key' });
check('settings saved', s.status === 200 && s.body.ok === true, JSON.stringify(s.body));
// 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 auto-register webhook (mock returns a secret) */
const w = await j('POST', '/api/webhook/register', {});
check('webhook registered', w.status === 200 && w.body.ok === true, JSON.stringify(w.body));
// 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 create invoice */
const inv = await j('POST', '/api/invoices', {
amount: 24.5, currency: 'USD', orderCode: 'F484-TEST1', description: 'E2E test order',
metadata: { pickup: '11:00 – 12:00', name: 'E2E' },
});
check('invoice created', inv.status === 201 && inv.body.id, inv.body.id || JSON.stringify(inv.body));
check('has bolt11', typeof inv.body.bolt11 === 'string' && inv.body.bolt11.startsWith('lnbc'));
check('has btc address', typeof inv.body.btcAddress === 'string' && inv.body.btcAddress.startsWith('bc1'));
// 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 open SSE and wait for the status event */
const sseEvents = [];
const ac = new AbortController();
const es = fetch(BASE + '/api/invoices/' + inv.body.id + '/events', { signal: ac.signal })
.then(async r => {
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf('\n\n')) >= 0) {
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
for (const line of frame.split('\n')) if (line.startsWith('data: ')) sseEvents.push(JSON.parse(line.slice(6)));
// 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(() => { });
await sleep(300);
check('sse initial status New', sseEvents.some(e => e.type === 'status' && e.status === 'New'), JSON.stringify(sseEvents));
})().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);
/* 6 poll until Paid (webhook from mock settles it after ~6s) */
let paid = null;
for (let i = 0; i < 20; i++) {
const st = await j('GET', '/api/invoices/' + inv.body.id);
if (st.body.status === 'Paid') { paid = st.body; break; }
await sleep(1000);
}
check('invoice reached Paid (webhook or poll)', Boolean(paid));
check('sse received Paid event', sseEvents.some(e => e.type === 'status' && e.status === 'Paid'), JSON.stringify(sseEvents));
ac.abort();
// 8. status visible via poll
const got2 = await (await fetch(BASE + '/api/orders/' + order.code)).json();
ok('poll shows READY', got2.status === 'READY');
/* 7 webhook rejects bad signature */
const bad = await fetch(BASE + '/api/btcpay/webhook', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'BTCPay-Sig': 'sha256=' + 'ab'.repeat(32) },
body: JSON.stringify({ event: 'InvoiceSettled', invoice: inv.body.id }),
});
check('webhook rejects bad sig (401)', bad.status === 401);
// 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);
/* 8 rate limit sanity (not critical) */
const rl = await j('POST', '/api/invoices', { amount: 1, currency: 'USD' });
check('invoice create still works for 2nd invoice', rl.status === 201);
// 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(failures === 0 ? '\nE2E: ALL PASS ✅' : `\nE2E: ${failures} FAILURE(S) ❌`);
process.exit(failures === 0 ? 0 : 1);
} catch (e) {
console.error('E2E crashed:', e);
process.exit(1);
} finally {
mock.kill('SIGTERM');
srv.kill('SIGTERM');
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); });