/** * Delivery smoke test — the customer-app flows the owner listed (D19 step 3): * * login with the master OTP → see products → add to cart → place an order * → see my orders * * Both apps talk to ONE API through an identical `api` object * (apps/*\/src/api/client.ts), so the network flow is exercised once here. * What differs between the apps is the client-side cart logic, so the cart * rules are asserted against BOTH stores' real source, loaded from disk — * otherwise "the apps work" would only ever prove v1 works. * * Requires the API to be running WITH OTP_MASTER_CODE set (see below). * Every Persian string is a literal here, never a shell argument (D15). * * Usage: node scripts/smoke-apps.cjs [apiBaseUrl] [masterCode] */ const fs = require('fs'); const path = require('path'); const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, ''); const MASTER = process.argv[3] || '778899'; let pass = 0; let fail = 0; const failures = []; const chk = (cond, msg) => { if (cond) { pass++; console.log(' PASS ' + msg); } else { fail++; failures.push(msg); console.log(' FAIL ' + msg); } }; const step = (t) => console.log('\n' + t); /** Differences found between the two apps, reported at the end. */ const divergences = []; const createdOrders = []; let token = null; const H = () => ({ 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}), }); const call = async (method, p, body) => { const res = await fetch(`${API}/${p}`, { method, headers: H(), body: body === undefined ? undefined : JSON.stringify(body), }); let payload = null; try { payload = await res.json(); } catch { /* empty */ } return { status: res.status, ok: res.ok, body: payload }; }; // ── The cart rules, evaluated against each app's OWN source ──────────────── // // The stores are Pinia modules, so rather than boot Pinia we extract the two // pure functions the stock ceiling depends on and run them directly. If either // app's source stops defining them, that is itself the failure. function loadCartRules(app) { const file = path.join(__dirname, '..', '..', app, 'src', 'stores', 'cartStore.ts'); const src = fs.readFileSync(file, 'utf8'); const capBody = src.match(/capQty\(want: number, variant: \{[^}]*\}\) \{([\s\S]*?)\n \}/); const guard = /const target = data\.product\?\.variants\?\.find/.test(src); const clamp = /if \(field === 'qty'\) \{/.test(src); return { src, hasCapQty: Boolean(capBody), hasMaxQty: /maxQty\(item: Pick\)/.test(src), hasAddGuard: guard, hasQtyClamp: clamp, // Rebuild capQty from the app's own body so the rule under test is the // shipped one, not a copy written here. capQty: capBody ? new Function('want', 'variant', capBody[1].replace(/this\./g, '')) : null, }; } (async () => { // ── 0. BOTH APPS' CART RULES ──────────────────────────────────────────── step('0 — CART STOCK RULES, in each app\'s own source'); const v1 = loadCartRules('user-app'); const v2 = loadCartRules('user-app-v2'); for (const [name, m] of [['user-app', v1], ['user-app-v2', v2]]) { chk(m.hasCapQty, `${name}: capQty() exists`); chk(m.hasMaxQty, `${name}: maxQty() exists`); chk(m.hasAddGuard, `${name}: addToCart refuses an un-orderable variant`); chk(m.hasQtyClamp, `${name}: updateFeild clamps a qty change`); } if (v1.capQty && v2.capQty) { const cases = [ { want: 10, variant: { qty: 3 }, expect: 3, label: 'want 10 of 3 in stock -> 3' }, { want: 2, variant: { qty: 5 }, expect: 2, label: 'want 2 of 5 in stock -> 2' }, { want: 99, variant: { qty: 0, isPreOrder: true }, expect: 99, label: 'pre-order is uncapped' }, { want: 0, variant: { qty: 5 }, expect: 1, label: 'a zero quantity floors at 1' }, { want: -4, variant: { qty: 5 }, expect: 1, label: 'a negative quantity floors at 1' }, ]; for (const c of cases) { const a = v1.capQty(c.want, c.variant); const b = v2.capQty(c.want, c.variant); chk(a === c.expect, `user-app: ${c.label} (got ${a})`); chk(b === c.expect, `user-app-v2: ${c.label} (got ${b})`); if (a !== b) divergences.push(`capQty(${c.want}, ${JSON.stringify(c.variant)}): v1=${a} v2=${b}`); } } const sameStore = v1.src === v2.src; chk(sameStore, 'the two cart stores are byte-identical'); if (!sameStore) divergences.push('cartStore.ts differs between the apps'); // ── 1. LOGIN WITH THE MASTER CODE ─────────────────────────────────────── step('1 — LOGIN with the delivery master code'); const customer = await prisma.customer.findFirst({ where: { isActive: true, isArchived: false }, select: { id: true, mobile: true, fullName: true }, }); if (!customer) { console.log(' no active customer in this database — cannot continue'); process.exit(1); } console.log(` (as ${customer.fullName} / ${customer.mobile})`); const verify = await call('POST', 'customer/auth/verify-otp', { username: customer.mobile, password: MASTER, }); chk(verify.ok, `POST customer/auth/verify-otp -> ${verify.status}`); if (!verify.body?.token) { console.log('\n The master code was refused. Start the API with OTP_MASTER_CODE set.'); await prisma.$disconnect(); process.exit(1); } token = verify.body.token; chk(true, 'a customer token came back'); const me = await call('GET', 'customer/me'); chk(me.ok, `GET customer/me -> ${me.status}`); chk(me.body?.mobile === customer.mobile, 'it is THIS customer'); // ── 2. SEE PRODUCTS ───────────────────────────────────────────────────── step('2 — SEE PRODUCTS'); const products = await call('GET', 'catalog/products'); chk(products.ok, `GET catalog/products -> ${products.status}`); const list = Array.isArray(products.body) ? products.body : []; chk(list.length > 0, `the catalogue is not empty (${list.length} products)`); const withVariant = list.find((p) => (p.variants ?? []).length > 0); chk(Boolean(withVariant), 'at least one product has a variant'); if (withVariant) { const v = withVariant.variants[0]; chk(typeof v.rashWeightGrams === 'string', 'weights arrive as strings (no float drift)'); chk(v.qty !== undefined, 'variants carry qty, so the apps can cap the cart'); chk(v.isPreOrder !== undefined, 'variants carry isPreOrder'); } const price = await call('GET', 'pricing/latest?karatPerMille=750'); chk(price.ok, `GET pricing/latest -> ${price.status}`); // ── 3. ADD TO CART (the rule both apps apply before ordering) ─────────── step('3 — ADD TO CART'); // Pick something actually orderable, exactly as the apps' own guard does. let chosen = null; for (const p of list) { const v = (p.variants ?? []).find((x) => (x.qty ?? 0) > 0 || x.isPreOrder); if (v) { chosen = { product: p, variant: v }; break; } } chk(Boolean(chosen), 'found an orderable variant to order'); if (!chosen) { console.log('\n No orderable variant exists — cannot place an order.'); await prisma.$disconnect(); process.exit(1); } console.log(` (chose "${chosen.product.title}" / "${chosen.variant.title}")`); const ceiling = chosen.variant.isPreOrder ? null : chosen.variant.qty; if (v1.capQty) { const want = 999; const capped = v1.capQty(want, chosen.variant); chk( ceiling === null ? capped === want : capped === ceiling, `the cart caps ${want} at ${ceiling === null ? 'uncapped (pre-order)' : ceiling}`, ); } // ── 4. PLACE AN ORDER ─────────────────────────────────────────────────── step('4 — PLACE AN ORDER'); const qty = 1; const order = await call('POST', 'orders', { items: [ { productVariantId: chosen.variant.id, quantity: qty, wagePercent: Number(chosen.variant.defaultWagePercent), }, ], }); chk(order.ok, `POST orders -> ${order.status}`); const orderId = order.body?.id; if (orderId) createdOrders.push(orderId); chk(Boolean(orderId), 'an order id came back'); chk(Boolean(order.body?.orderNumber), 'it has an order number'); if (orderId) { const row = await prisma.order.findUnique({ where: { id: orderId }, include: { items: true }, }); chk(row?.customerId === customer.id, 'the order belongs to the TOKEN customer, not a body id'); chk(row?.items.length === 1, 'the item was saved'); // The reference formula: computed = rash * (1 + (wage - discount)/100) const rash = Number(chosen.variant.rashWeightGrams); const wage = Number(chosen.variant.defaultWagePercent); const expected = rash * (1 + wage / 100); const actual = Number(row?.items[0]?.computedWeightGrams ?? 0); chk( Math.abs(actual - expected) < 0.00005, `the making charge was applied: ${rash}g +${wage}% = ${expected.toFixed(4)} (got ${actual})`, ); } step('4b — NEGATIVE: an order with no items is rejected'); const empty = await call('POST', 'orders', { items: [] }); chk(empty.status === 400, `POST orders with items:[] -> ${empty.status} (expected 400)`); step('4c — NEGATIVE: ordering without a token is rejected'); const saved = token; token = null; const anon = await call('POST', 'orders', { items: [{ productVariantId: chosen.variant.id, quantity: 1, wagePercent: 7 }], }); chk(anon.status === 401, `POST orders unauthenticated -> ${anon.status} (expected 401)`); token = saved; // ── 5. SEE MY ORDERS ──────────────────────────────────────────────────── step('5 — SEE MY ORDERS'); const mine = await call('GET', 'customer/me/orders'); chk(mine.ok, `GET customer/me/orders -> ${mine.status}`); const rows = Array.isArray(mine.body) ? mine.body : []; chk(rows.some((o) => o.id === orderId), 'the new order appears in my orders'); const ledger = await call('GET', 'customer/me/ledger-entries'); chk(ledger.ok, `GET customer/me/ledger-entries -> ${ledger.status}`); // ── CLEANUP ───────────────────────────────────────────────────────────── step('CLEANUP'); if (createdOrders.length) { await prisma.orderItem.deleteMany({ where: { orderId: { in: createdOrders } } }); await prisma.orderStatusEvent.deleteMany({ where: { orderId: { in: createdOrders } } }); await prisma.ledgerEntry.deleteMany({ where: { orderId: { in: createdOrders } } }); await prisma.order.deleteMany({ where: { id: { in: createdOrders } } }); } console.log( ` deleted ${createdOrders.length} order(s) this run created, with their items, ` + `status events and ledger entries. No pre-existing data was removed.`, ); console.log(`\n${'='.repeat(60)}`); console.log(`APPS SMOKE: passed ${pass} failed ${fail}`); if (fail) { console.log('\nFailures:'); failures.forEach((f) => console.log(' - ' + f)); } console.log( divergences.length ? `\nDIVERGENCES between the apps:\n${divergences.map((d) => ' - ' + d).join('\n')}` : '\nNo behavioural divergence between user-app and user-app-v2.', ); console.log('='.repeat(60)); await prisma.$disconnect(); process.exit(fail ? 1 : 0); })().catch(async (e) => { console.error('\nSMOKE ABORTED:', e); await prisma.$disconnect(); process.exit(1); });