Prepare shop-ready deployment docs and fix transaction modal UI
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
#!/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/<admin diagnostic>... 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);
|
||||
});
|
||||
Reference in New Issue
Block a user