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:
co-authored by
Claude Opus 5
parent
0983c42788
commit
8b7da90f51
@@ -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=
|
||||
|
||||
@@ -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
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user