298 lines
12 KiB
JavaScript
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);
|
||
|
|
});
|