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>
This commit is contained in:
s.esmaeili
2026-09-23 00:22:50 +03:30
co-authored by Claude Opus 5
parent 0983c42788
commit 8b7da90f51
4 changed files with 370 additions and 1 deletions
+3
View File
@@ -42,6 +42,9 @@ JWT_CUSTOMER_EXPIRES_IN=
# ⚠ DELIVERY ONLY — REMOVE BEFORE THE REAL RELEASE (D20 / ISSUES REL-01)
# ---------------------------------------------------------------------------
# A fixed OTP that signs in ANY active customer, alongside the real code.
# MUST BE EXACTLY 4 DIGITS: the apps' login box has four single-character
# inputs and only submits at four (LoginView.vue:43). A longer code still works
# over the API but cannot be typed into either app.
# Leave it EMPTY or unset to disable it completely — that is the correct state
# for production. While it is set, the API prints a warning banner at boot.
OTP_MASTER_CODE=
+297
View File
@@ -0,0 +1,297 @@
/**
* 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);
});
+15 -1
View File
@@ -8,7 +8,7 @@ import { AppModule } from './app.module';
import { prepareDatabase } from './prisma/bootstrap';
import { PrismaExceptionFilter } from './prisma/prisma-exception.filter';
import { assertSmsConfigured } from './modules/sms/sms.service';
import { masterOtpEnabled } from './modules/customer-auth/customer-auth.service';
import { masterOtpCode, masterOtpEnabled } from './modules/customer-auth/customer-auth.service';
// serve-static's bundled `mime` version predates .avif, so it falls back to
// application/octet-stream — browsers still render the image (they sniff
@@ -25,6 +25,14 @@ async function bootstrap() {
// D20 — a loud, unmissable banner whenever the delivery bypass is armed, so
// it cannot reach the real release unnoticed.
if (masterOtpEnabled()) {
const code = masterOtpCode() ?? '';
// Both apps render the login OTP as FOUR single-character boxes and only
// submit on @on-complete, i.e. at exactly 4 digits
// (LoginView.vue:43 in each app, OtpInput.vue:43). The API itself accepts
// any length, so a longer master code works over HTTP but can never be
// typed into the apps. Warn rather than reject: an operator may be using it
// for API testing only.
const typable = /^\d{4}$/.test(code);
// eslint-disable-next-line no-console
console.warn(
'\n' +
@@ -32,6 +40,12 @@ async function bootstrap() {
'\n ⚠ OTP_MASTER_CODE فعال است — قبل از انتشار اصلی حذف شود\n' +
' ⚠ OTP_MASTER_CODE IS ACTIVE: any active customer can sign in with\n' +
' ⚠ this fixed code. Remove it from the environment before release.\n' +
(typable
? ''
: ' ⚠\n' +
' ⚠ NOTE: this code is not 4 digits, so it CANNOT be entered in the\n' +
' ⚠ customer apps — their OTP box has exactly 4 boxes. Use a\n' +
' ⚠ 4-digit code for the apps; this one works over the API only.\n') +
'='.repeat(72) +
'\n',
);
+55
View File
@@ -637,3 +637,58 @@ takes `orderId` (not `id`/`sts`). Both corrected.
(1 product + its variants/images, 1 admin user + profile, 2 customers per run).
The order status change is made against an existing order and then **restored to
its original value** in the same run; no pre-existing row is left modified.
## 2026-09-23 — Apps smoke test (D19 step 1)
New `apps/api/scripts/smoke-apps.cjs` — **43/43**. Walks the customer flow the
owner listed: login with the master OTP → see products → add to cart → place an
order → see my orders, plus the negatives (empty order → 400, unauthenticated
order → 401).
Because both apps share one API client, the network flow is exercised once; the
**cart rules are asserted against BOTH apps' real source**, loaded from disk and
executed, so "the apps work" cannot be proved by v1 alone. `capQty` was checked
over five cases (over-stock, under-stock, pre-order, zero, negative) in each app.
**Result: no behavioural divergence between user-app and user-app-v2.**
Confirmed by direct comparison:
| Surface | Result |
|---|---|
| All 9 Pinia stores | **byte-identical** |
| Router (route names) | identical |
| API client call list | identical |
| Composables | identical except `useProduct.image` — v1's variant-image picker, **PROTECTED** |
| Components | none missing from v2; its 4 extra home components are its own design |
The making-charge formula was verified against a real order:
`5.65g +15% = 6.4975` exactly, matching the reference's
`rash × (1 + (wage − discount)/100)`.
### APP-01 (constraint, not a code change) — the master code must be 4 digits
Both apps render the login OTP as **four single-character boxes** and submit
only on `@on-complete`, i.e. at exactly four digits
(`LoginView.vue:43` in each app; `OtpInput.vue:43`). The API accepts any length,
so a longer `OTP_MASTER_CODE` works over HTTP but **cannot be typed into either
app**. The real OTP is 4 digits (`Math.floor(1000 + random*9000)`), so the box
is correct — only the master code has to match it.
The apps were NOT changed (that would touch the shipped login UI for a
delivery-only feature). Instead `main.ts` now checks the code at boot and adds
an explicit warning when it is not 4 digits, and `.env.example` states the rule.
Re-ran the full apps smoke with a 4-digit code: **43/43**.
### APP-02 (logged, not fixed) — v2 does not show the admin's home slider
`user-app` calls `GET /home/slider` and prefers admin-configured slides, falling
back to category-derived ones (`HomeView.vue:258`). `user-app-v2` has no
`getHomeSlider` in its API client at all and always derives its hero from
categories. So slides configured in the admin panel appear in v1 only.
Not fixed tonight: v2's home is its own design (HeroCarousel/ProductPanel), and
wiring a different slide source into it during a delivery risks breaking the
page that is actually being demonstrated. Listed under known issues.
**Data touched:** the script creates one order and deletes it again, with its
items, status events and ledger entries. No pre-existing data is modified.