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:
parent
601c85f236
commit
582e571eff
10 changed files with 656 additions and 1096 deletions
|
|
@ -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); });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue