#!/usr/bin/env node // End-to-end production-readiness validation against the running stack. // Usage: node scripts/e2e_validate.mjs // ENV: API=http://localhost:4000 EMAIL=admin@local.test PASSWORD=ChangeMe123! const API = process.env.API || 'http://localhost:4000'; const EMAIL = process.env.EMAIL || 'admin@local.test'; const PASSWORD = process.env.PASSWORD || 'ChangeMe123!'; let TOKEN = null; let failures = 0; let checks = 0; const fmt = (n) => Number(n).toFixed(2); const eq = (a, b, tol = 0.0001) => Math.abs(Number(a) - Number(b)) <= tol; function pass(msg) { checks++; console.log(` PASS ${msg}`); } function fail(msg, extra='') { checks++; failures++; console.log(` FAIL ${msg}${extra ? ' ('+extra+')' : ''}`); } function step(msg) { console.log(`\n== ${msg}`); } async function req(method, path, body) { const r = await fetch(`${API}${path}`, { method, headers: { 'content-type': 'application/json', ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}), }, body: body ? JSON.stringify(body) : undefined, }); const text = await r.text(); let json; try { json = JSON.parse(text); } catch { json = { raw: text }; } if (!r.ok) { const err = new Error(`${method} ${path} -> ${r.status} ${JSON.stringify(json)}`); err.body = json; throw err; } return json; } const rpc = (fn, args) => req('POST', `/rpc/${fn}`, args).then(r => r.data); const view = (v) => req('GET', `/from/${v}`).then(r => r.data); (async () => { step('login'); const login = await req('POST', '/auth/login', { email: EMAIL, password: PASSWORD }); TOKEN = login.token; const ADMIN_ID = login.user.id; pass(`login as ${login.user.email}`); step('discover shop + till'); const shops = await view('v_my_shops'); if (!shops.length) { fail('no shops visible'); process.exit(1); } const shopId = shops[0].shop_id; pass(`shop ${shops[0].name} (${shopId})`); const tillName = `E2E-Till-${Date.now()}`; const newTill = await rpc('create_till', { p_shop: shopId, p_name: tillName }); const tillId = typeof newTill === 'string' ? newTill : newTill?.id ?? newTill; pass(`created till ${tillName} (${tillId})`); step('open shift with opening float USD 100 / LBP 0 (owner assigns to self)'); const openingUsd = 100, openingLbp = 0; await rpc('open_shift', { p_till_id: tillId, p_opening_usd: openingUsd, p_opening_lbp: openingLbp, p_assigned_user_id: ADMIN_ID, }); const active = await rpc('my_active_shift', { p_shop: shopId }); const shift = Array.isArray(active) ? active[0] : active; if (!shift?.shift_id) { fail('shift did not open'); process.exit(1); } const shiftId = shift.shift_id; pass(`shift opened ${shiftId} status=${shift.status}`); step('record REPAIR USD 30 (cash-only path)'); const repair1 = await rpc('record_repair', { p_shop: shopId, p_till: tillId, p_payment_method: 'cash_usd', p_gross_usd: 30, p_gross_lbp: 0, p_fx_rate: null, p_device_type: 'iPhone 12', p_device_imei: null, p_issue_summary: 'screen replacement', p_warranty_days: 7, p_customer_id: null, p_notes: 'e2e r1', }); pass(`REPAIR #1 txn ${repair1}`); step('record REPAIR USD 20'); const repair2 = await rpc('record_repair', { p_shop: shopId, p_till: tillId, p_payment_method: 'cash_usd', p_gross_usd: 20, p_gross_lbp: 0, p_fx_rate: null, p_device_type: 'Samsung A50', p_device_imei: null, p_issue_summary: 'battery replacement', p_warranty_days: 30, p_customer_id: null, p_notes: 'e2e r2', }); pass(`REPAIR #2 txn ${repair2}`); step('midday safe drop USD 25'); await rpc('record_cash_drop', { p_shift_id: shiftId, p_drop_usd: 25, p_drop_lbp: 0, p_notes: 'e2e midday', }); pass('drop recorded'); step('ENFORCEMENT: void without voided_paper_photo evidence must be REJECTED'); let voidRejected = false; try { await rpc('void_transaction', { p_txn_id: repair1, p_reason: 'e2e void without evidence', }); } catch (e) { voidRejected = true; const msg = e.body?.error || e.message; if (/voided_paper_photo/i.test(msg)) { pass(`void correctly rejected: ${msg}`); } else { fail(`void rejected but for unexpected reason: ${msg}`); } } if (!voidRejected) fail('SECURITY: void with no evidence was accepted!'); step('ENFORCEMENT: cash_movements sign guard (drop with negative amount should fail)'); let dropGuardOk = false; try { await rpc('record_cash_drop', { p_shift_id: shiftId, p_drop_usd: -10, p_drop_lbp: 0, p_notes: 'e2e negative drop', }); } catch (e) { dropGuardOk = true; pass(`negative drop correctly rejected: ${e.body?.error || e.message}`); } if (!dropGuardOk) fail('SECURITY: negative drop amount was accepted!'); // Expected drawer math (USD, no void applied): // opening 100 // + REPAIR1 +30 // + REPAIR2 +20 // - drop -25 // = 125 const expectedUsd = 125; const expectedLbp = 0; const counted = 124; // intentional $1 short step('declare close USD 124 (intentional $1 short)'); await rpc('declare_close', { p_shift_id: shiftId, p_declared_close_usd: counted, p_declared_close_lbp: 0, }); pass('declared'); step('finalize close + assert expected/variance'); const fin = await rpc('finalize_close', { p_shift_id: shiftId }); const row = Array.isArray(fin) ? fin[0] : fin; console.log(' finalize_close ->', JSON.stringify(row)); eq(row.expected_usd, expectedUsd) ? pass(`expected_usd = ${fmt(row.expected_usd)} (== ${expectedUsd})`) : fail(`expected_usd = ${fmt(row.expected_usd)} (!= ${expectedUsd})`); eq(row.expected_lbp, expectedLbp) ? pass(`expected_lbp = ${fmt(row.expected_lbp)} (== ${expectedLbp})`) : fail(`expected_lbp = ${fmt(row.expected_lbp)} (!= ${expectedLbp})`); eq(row.variance_usd, counted - expectedUsd) ? pass(`variance_usd = ${fmt(row.variance_usd)} (== ${counted - expectedUsd})`) : fail(`variance_usd = ${fmt(row.variance_usd)} (expected ${counted - expectedUsd})`); // ------------------------------------------------------------------ // DB-level invariant checks via /rpc/... we don't // have such an RPC, so just print a SQL block for the runner to exec. // ------------------------------------------------------------------ step('SUMMARY'); console.log(` checks: ${checks} failures: ${failures}`); if (failures) process.exit(2); console.log(`\nShift under test: ${shiftId}`); })().catch(e => { console.error('FATAL', e.message); if (e.body) console.error(JSON.stringify(e.body, null, 2)); process.exit(1); });