Files
tehran_taj/apps/api/scripts/smoke-apps.cjs
s.esmaeiliandClaude Opus 5 8b7da90f51 test(apps): customer-flow smoke test for both apps + 4-digit master-code guard
smoke-apps.cjs walks login (master OTP), catalogue, cart cap, order placement
and order history, with the empty-order and unauthenticated negatives: 43/43.
The cart rules are executed from BOTH apps' own source so the result cannot be
carried by v1 alone.

No behavioural divergence found: all 9 stores are byte-identical, and the router,
API client and composables match except v1's protected variant-image picker.

APP-01: both apps' OTP box is exactly 4 inputs and only submits at 4, so a longer
OTP_MASTER_CODE cannot be typed into them. The login UI was left alone; main.ts
now warns at boot when the code is not 4 digits and .env.example states the rule.

APP-02 logged, not fixed: user-app-v2 has no getHomeSlider, so admin-configured
home slides show in v1 only. Changing v2's hero during a delivery is not worth
the risk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 00:22:50 +03:30

298 lines
12 KiB
JavaScript

/**
* 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<CartItem, 'pid' \| 'vid'>\)/.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);
});