diff --git a/.env.example b/.env.example index 26fda3b..bc88bd0 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/apps/api/scripts/smoke-apps.cjs b/apps/api/scripts/smoke-apps.cjs new file mode 100644 index 0000000..6b09078 --- /dev/null +++ b/apps/api/scripts/smoke-apps.cjs @@ -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\)/.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); +}); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 96b8a5c..20d1d59 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -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', ); diff --git a/docs/parity/CHANGELOG.md b/docs/parity/CHANGELOG.md index 420ab09..8bd9c13 100644 --- a/docs/parity/CHANGELOG.md +++ b/docs/parity/CHANGELOG.md @@ -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.