From 6d0f5c53593305128002d1baa9a9d8f98940b7e7 Mon Sep 17 00:00:00 2001 From: "s.esmaeili" Date: Wed, 23 Sep 2026 00:25:53 +0330 Subject: [PATCH] test(api): B2B admin scan + write-path smoke, both green scan-admin-b2b.cjs (34 ok, 0 broken) hits every read endpoint the wholesale desk uses daily. smoke-admin-b2b-writes.cjs (25/25) covers the money-touching writes: admin order placement and a cash payment document, asserting the ledger and the customer balance rather than the HTTP status. Verified: the making charge computes exactly, a document is created unconfirmed, confirming moves the gold debt, and confirming again toggles back and restores the balance to the byte. Retail/storefront surfaces were deliberately not scanned (D13). No B2B breakage found. Three first-draft failures were test bugs, corrected against the real callers: cities/list -> admin/cities/list, by-ids takes products_id, and document type 4 is cash (2 is a transfer). Co-Authored-By: Claude Opus 5 --- apps/api/scripts/scan-admin-b2b.cjs | 168 ++++++++++++++ apps/api/scripts/smoke-admin-b2b-writes.cjs | 244 ++++++++++++++++++++ docs/parity/CHANGELOG.md | 37 +++ 3 files changed, 449 insertions(+) create mode 100644 apps/api/scripts/scan-admin-b2b.cjs create mode 100644 apps/api/scripts/smoke-admin-b2b-writes.cjs diff --git a/apps/api/scripts/scan-admin-b2b.cjs b/apps/api/scripts/scan-admin-b2b.cjs new file mode 100644 index 0000000..ff7b806 --- /dev/null +++ b/apps/api/scripts/scan-admin-b2b.cjs @@ -0,0 +1,168 @@ +/** + * B2B admin scan (D19 step 2) — hits every read endpoint the wholesale side of + * the admin panel uses on a normal working day, looking for things that are + * OUTRIGHT BROKEN (500s, crashes, empty required shapes). + * + * Scope per the owner: wholesale customers, sales experts, products and + * variants, wholesale orders, documents. Retail/storefront surfaces + * (single*, wallet, comments) are D13 — preserved, not scanned. + * + * This is a breakage scan, not a parity audit: a 200 with plausible data + * passes. Anything non-2xx is reported with its body so it can be triaged. + * + * Usage: node scripts/scan-admin-b2b.cjs [apiBaseUrl] + */ +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); +const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, ''); + +const broken = []; +const ok = []; +let H = {}; + +async function probe(label, method, path, body) { + let res; + try { + res = await fetch(`${API}/${path}`, { + method, + headers: H, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (e) { + broken.push({ label, method, path, status: 'NETWORK', detail: String(e) }); + console.log(` ERR ${label} (${String(e)})`); + return null; + } + + let payload = null; + const text = await res.text(); + try { + payload = JSON.parse(text); + } catch { + /* non-JSON */ + } + + if (res.ok) { + ok.push(label); + const n = Array.isArray(payload) + ? payload.length + : Array.isArray(payload?.rows) + ? payload.rows.length + : null; + console.log(` ok ${label}${n === null ? '' : ` (${n} rows)`}`); + } else { + const detail = + (payload && (payload.message || payload.error)) || text.slice(0, 200); + broken.push({ label, method, path, status: res.status, detail }); + console.log(` FAIL ${label} -> ${res.status} ${JSON.stringify(detail).slice(0, 160)}`); + } + return payload; +} + +const page = { page: 1, limit: 5 }; + +(async () => { + const login = await fetch(`${API}/admin/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phone: '09128081392', password: '09128081392' }), + }); + const auth = await login.json(); + if (!auth.api_token) { + console.log('login failed'); + process.exit(1); + } + H = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + auth.api_token }; + + console.log('\n── WHOLESALE CUSTOMERS ──────────────────────────────'); + const omde = await probe('customers: omde list', 'POST', 'admin/customers-omde/list', page); + await probe('customers: banker list', 'POST', 'admin/customers-omde/banker/list', page); + await probe('customers: shopkeeper list', 'POST', 'admin/customers-omde/shopkeeper/list', page); + await probe('customers: non-verified list', 'POST', 'admin/customers-omde/non-verified/list', page); + await probe('customers: archived list', 'POST', 'admin/customers-omde/archived/list', page); + await probe('customers: referrer dropdown', 'GET', 'admin/customers-omde/query/reagent'); + + const aCustomer = omde?.rows?.[0]; + if (aCustomer) { + await probe('customer: detail', 'GET', `admin/customers-omde/${aCustomer.id}`); + await probe('customer: dashboard', 'GET', `admin/customers-omde/dashboard/${aCustomer.id}`); + await probe('customer: orders tab', 'GET', `admin/customers-omde/${aCustomer.id}/orders`); + await probe('customer: documents tab', 'GET', `admin/customers-omde/${aCustomer.id}/documents`); + } + + console.log('\n── SALES EXPERTS ────────────────────────────────────'); + const reagents = await probe('experts: list', 'POST', 'admin/customers-omde/reagent/list', page); + const anExpert = reagents?.rows?.[0]; + if (anExpert) { + await probe('expert: detail', 'GET', `admin/customers-omde/reagent/${anExpert.id}`); + await probe('expert: as admin user', 'GET', `admin/users/${anExpert.id}`); + } + + console.log('\n── PRODUCTS & VARIANTS ──────────────────────────────'); + const prods = await probe('products: list', 'POST', 'admin/products/list', page); + await probe('products: variants list', 'POST', 'admin/products/variants/list', page); + await probe('products: brands', 'GET', 'admin/products/brands'); + await probe('products: brands table', 'POST', 'admin/products/brands-table', page); + await probe('products: collections', 'GET', 'admin/products/collections'); + await probe('products: show-type options', 'GET', 'admin/products/show-type-options'); + const aProduct = prods?.rows?.[0]; + if (aProduct) { + await probe('product: detail (edit form)', 'GET', `admin/products/${aProduct.id}`); + await probe('product: its variants', 'GET', `admin/products/${aProduct.id}/variants`); + // The order builder posts `products_id` (stores/cart.ts:102), not `ids`. + const byIds = await probe('product: by-ids', 'POST', 'admin/products/by-ids', { + products_id: [aProduct.id], + }); + if (byIds && Array.isArray(byIds) && byIds.length === 0) { + broken.push({ + label: 'product: by-ids returned nothing for a known id', + method: 'POST', + path: 'admin/products/by-ids', + status: '200-empty', + detail: `asked for ${aProduct.id}, got []`, + }); + console.log(' FAIL product: by-ids returned [] for a product that exists'); + } + } + + console.log('\n── WHOLESALE ORDERS ─────────────────────────────────'); + const wOrders = await probe('orders: wholesale list', 'POST', 'admin/orders/wholesale/list', page); + await probe('orders: tamin list', 'POST', 'admin/orders/tamin/list', page); + await probe('orders: status options', 'GET', 'admin/orders/status-options'); + await probe('orders: type options', 'GET', 'admin/orders/type-options'); + const anOrder = wOrders?.rows?.[0]; + if (anOrder) { + await probe('order: detail', 'GET', `admin/orders/${anOrder.id}`); + } + + console.log('\n── DOCUMENTS ────────────────────────────────────────'); + const docs = await probe('documents: list', 'POST', 'admin/documents/list', page); + const aDoc = docs?.rows?.[0]; + if (aDoc) await probe('document: detail', 'GET', `admin/documents/${aDoc.id}`); + + console.log('\n── SUPPORTING LOOKUPS THE B2B FORMS NEED ────────────'); + await probe('cities: list', 'POST', 'admin/cities/list', { page: 1, limit: 500 }); + await probe('branches: list', 'POST', 'admin/branches/list', page); + await probe('workshops: list', 'POST', 'admin/workshops/list', page); + await probe('pre-orders: list', 'POST', 'admin/pre-orders/list', page); + await probe('users: list', 'POST', 'admin/users/list', page); + + console.log(`\n${'='.repeat(62)}`); + console.log(`B2B SCAN: ${ok.length} ok, ${broken.length} broken`); + if (broken.length) { + console.log('\nBROKEN:'); + for (const b of broken) { + console.log(` - [${b.status}] ${b.label}`); + console.log(` ${b.method} ${b.path}`); + console.log(` ${JSON.stringify(b.detail).slice(0, 300)}`); + } + } + console.log('='.repeat(62)); + + await prisma.$disconnect(); + process.exit(broken.length ? 1 : 0); +})().catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/apps/api/scripts/smoke-admin-b2b-writes.cjs b/apps/api/scripts/smoke-admin-b2b-writes.cjs new file mode 100644 index 0000000..956703f --- /dev/null +++ b/apps/api/scripts/smoke-admin-b2b-writes.cjs @@ -0,0 +1,244 @@ +/** + * B2B admin WRITE paths (D19 step 2) — the money-touching operations the + * wholesale desk performs daily, which a read-only scan cannot cover: + * + * place an order for a customer from the admin panel + * record a payment document (cash) and confirm it + * + * Both move a customer's gold/rial debt, so each assertion checks the LEDGER, + * not just the HTTP status. Everything created here is removed again, and the + * customer's balances are compared before and after to prove nothing was left + * behind. + * + * Usage: node scripts/smoke-admin-b2b-writes.cjs [apiBaseUrl] + */ +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); +const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, ''); + +let pass = 0; +let fail = 0; +const failures = []; +const chk = (c, m) => { + if (c) { + pass++; + console.log(' PASS ' + m); + } else { + fail++; + failures.push(m); + console.log(' FAIL ' + m); + } +}; +const step = (t) => console.log('\n' + t); + +let H = {}; +const call = async (method, p, body) => { + const res = await fetch(`${API}/${p}`, { + method, + headers: H, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + let payload = null; + try { + payload = JSON.parse(text); + } catch { + /* empty */ + } + return { status: res.status, ok: res.ok, body: payload, raw: text }; +}; + +const createdOrders = []; +const createdEntries = []; + +(async () => { + const login = await fetch(`${API}/admin/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phone: '09128081392', password: '09128081392' }), + }); + const auth = await login.json(); + if (!auth.api_token) { + console.log('login failed'); + process.exit(1); + } + H = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + auth.api_token }; + + // A WHOLESALE customer — retail is out of scope (D13). + const customer = await prisma.customer.findFirst({ + where: { kind: { not: 'RETAIL' }, isActive: true, isArchived: false }, + select: { id: true, fullName: true, goldDebtGrams: true, fiatDebtRial: true }, + }); + if (!customer) { + console.log('no wholesale customer available'); + process.exit(1); + } + console.log(`Customer under test: ${customer.fullName}`); + console.log(` gold debt before: ${customer.goldDebtGrams} rial debt before: ${customer.fiatDebtRial}`); + + const variant = await prisma.productVariant.findFirst({ + where: { OR: [{ qty: { gt: 0 } }, { isPreOrder: true }] }, + select: { id: true, title: true, rashWeightGrams: true, defaultWagePercent: true }, + }); + if (!variant) { + console.log('no orderable variant available'); + process.exit(1); + } + + // ── 1. ADMIN PLACES AN ORDER ──────────────────────────────────────────── + step('1 — PLACE A WHOLESALE ORDER FROM THE ADMIN PANEL'); + const wage = Number(variant.defaultWagePercent); + const order = await call('POST', 'admin/orders/create', { + customer_id: customer.id, + order_items: [{ vid: variant.id, qty: 2, percent: wage }], + note: 'سفارش آزمایشی تحویل', + }); + chk(order.ok, `POST admin/orders/create -> ${order.status}` + (order.ok ? '' : ` ${order.raw.slice(0, 200)}`)); + + const orderId = order.body?.id ?? order.body?.order?.id; + if (orderId) createdOrders.push(orderId); + chk(Boolean(orderId), 'an order id came back'); + + if (orderId) { + const row = await prisma.order.findUnique({ + where: { id: orderId }, + include: { items: true }, + }); + chk(row?.customerId === customer.id, 'the order is attached to the chosen customer'); + chk(row?.items.length === 1, 'the line item was saved'); + chk(row?.items[0]?.quantity === 2, 'the quantity (2) was saved'); + + // The reference formula, per unit: rash * (1 + (wage - discount)/100) + const rash = Number(variant.rashWeightGrams); + const expectedPerUnit = rash * (1 + wage / 100); + const actualPerUnit = Number(row?.items[0]?.computedWeightGrams ?? 0); + chk( + Math.abs(actualPerUnit - expectedPerUnit) < 0.00005, + `making charge per unit: ${rash}g +${wage}% = ${expectedPerUnit.toFixed(4)} (got ${actualPerUnit})`, + ); + chk( + Math.abs(Number(row?.totalGoldWeightGrams ?? 0) - expectedPerUnit * 2) < 0.00005, + `the order total is the per-unit weight x2 (${(expectedPerUnit * 2).toFixed(4)})`, + ); + + step('1b — the order appears in the wholesale grid'); + const grid = await call('POST', 'admin/orders/wholesale/list', { page: 1, limit: 50 }); + chk(grid.ok, `POST admin/orders/wholesale/list -> ${grid.status}`); + chk( + (grid.body?.rows ?? []).some((r) => r.id === orderId), + 'the new order is listed', + ); + + step('1c — its detail opens'); + const detail = await call('GET', `admin/orders/${orderId}`); + chk(detail.ok, `GET admin/orders/:id -> ${detail.status}`); + chk(Array.isArray(detail.body?.activity), 'the status timeline came back'); + } + + // ── 2. RECORD A PAYMENT DOCUMENT ──────────────────────────────────────── + step('2 — RECORD A CASH PAYMENT DOCUMENT'); + const before = await prisma.customer.findUnique({ + where: { id: customer.id }, + select: { goldDebtGrams: true, fiatDebtRial: true }, + }); + + // type 4 = «پولی» (cash), which takes `price` + `price_per_gram` + // (addFinDoc.vue:33,104-115). Type 2 is «حواله» and takes a weight in + // `value` — sending cash fields with type 2 yields a zero amount and a 400. + const doc = await call('POST', 'admin/documents', { + type: 4, + customer_id: customer.id, + price: 10000000, + price_per_gram: 50000000, + note: 'پرداخت آزمایشی تحویل', + }); + chk(doc.ok, `POST admin/documents -> ${doc.status}` + (doc.ok ? '' : ` ${doc.raw.slice(0, 200)}`)); + const docId = doc.body?.id; + if (docId) createdEntries.push(docId); + chk(Boolean(docId), 'a document id came back'); + + if (docId) { + const entry = await prisma.ledgerEntry.findUnique({ where: { id: docId } }); + chk(Boolean(entry), 'a ledger entry was written'); + chk(entry?.customerId === customer.id, 'it is attached to the right customer'); + chk(entry?.confirmed === false, 'it starts UNCONFIRMED, as the reference does'); + + step('2b — it appears in the documents grid'); + const dlist = await call('POST', 'admin/documents/list', { page: 1, limit: 50 }); + chk(dlist.ok, `POST admin/documents/list -> ${dlist.status}`); + chk((dlist.body?.rows ?? []).some((r) => r.id === docId), 'the new document is listed'); + + step('2c — CONFIRM it, and the customer balance moves'); + const conf = await call('POST', `admin/documents/${docId}/confirm`); + chk(conf.ok, `POST admin/documents/:id/confirm -> ${conf.status}`); + const afterConfirm = await prisma.ledgerEntry.findUnique({ where: { id: docId } }); + chk(afterConfirm?.confirmed === true, 'the entry is now confirmed'); + + const balAfter = await prisma.customer.findUnique({ + where: { id: customer.id }, + select: { goldDebtGrams: true, fiatDebtRial: true }, + }); + const moved = + String(balAfter?.goldDebtGrams) !== String(before?.goldDebtGrams) || + String(balAfter?.fiatDebtRial) !== String(before?.fiatDebtRial); + chk(moved, `confirming moved the balance (gold ${before?.goldDebtGrams} -> ${balAfter?.goldDebtGrams})`); + + step('2d — confirm is a TOGGLE, as the reference has it'); + const unconf = await call('POST', `admin/documents/${docId}/confirm`); + chk(unconf.ok, `second confirm -> ${unconf.status}`); + const toggled = await prisma.ledgerEntry.findUnique({ where: { id: docId } }); + chk(toggled?.confirmed === false, 'it toggled back to unconfirmed'); + + const balBack = await prisma.customer.findUnique({ + where: { id: customer.id }, + select: { goldDebtGrams: true, fiatDebtRial: true }, + }); + chk( + String(balBack?.goldDebtGrams) === String(before?.goldDebtGrams) && + String(balBack?.fiatDebtRial) === String(before?.fiatDebtRial), + 'un-confirming restored the balance exactly', + ); + } + + // ── CLEANUP ───────────────────────────────────────────────────────────── + step('CLEANUP'); + if (createdEntries.length) { + await prisma.ledgerEntry.deleteMany({ where: { id: { in: createdEntries } } }); + } + 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 } } }); + } + + const finalBal = await prisma.customer.findUnique({ + where: { id: customer.id }, + select: { goldDebtGrams: true, fiatDebtRial: true }, + }); + chk( + String(finalBal?.goldDebtGrams) === String(customer.goldDebtGrams) && + String(finalBal?.fiatDebtRial) === String(customer.fiatDebtRial), + `the customer's balances are exactly as they started ` + + `(gold ${finalBal?.goldDebtGrams}, rial ${finalBal?.fiatDebtRial})`, + ); + console.log( + ` deleted ${createdOrders.length} order(s) and ${createdEntries.length} ledger entry/entries ` + + 'created by this run. No pre-existing data was removed.', + ); + + console.log(`\n${'='.repeat(60)}`); + console.log(`B2B WRITE SMOKE: passed ${pass} failed ${fail}`); + if (fail) { + console.log('\nFailures:'); + failures.forEach((f) => console.log(' - ' + f)); + } + console.log('='.repeat(60)); + + await prisma.$disconnect(); + process.exit(fail ? 1 : 0); +})().catch(async (e) => { + console.error('\nABORTED:', e); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/docs/parity/CHANGELOG.md b/docs/parity/CHANGELOG.md index 8bd9c13..69f34a4 100644 --- a/docs/parity/CHANGELOG.md +++ b/docs/parity/CHANGELOG.md @@ -692,3 +692,40 @@ 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. + +## 2026-09-23 — B2B admin scan (D19 step 2) + +Two new scripts, scoped to the B2B surfaces the owner named. Retail/storefront +(`single*`, wallet, comments) was **not** scanned — D13, preserved untouched. + +**`scripts/scan-admin-b2b.cjs` — 34 ok, 0 broken.** Every read endpoint the +wholesale desk opens daily: the five customer grids, the referrer dropdown, +customer detail/dashboard/orders/documents, the sales-expert list and detail, +products, variants, brands, collections, product detail and its variants, +wholesale and tamin order grids, order detail, the documents grid and detail, +and the supporting lookups (cities, branches, workshops, pre-orders, users). + +**`scripts/smoke-admin-b2b-writes.cjs` — 25/25.** The money-touching writes a +read-only scan cannot cover: + +- Placing a wholesale order from the admin panel: the making charge computes + exactly (`8.133g +2% = 8.2957` per unit, ×2 = `16.5913` total), the order + lands on the right customer and appears in the grid with a status timeline. +- Recording a cash payment document: the reference's formula + `price / (price_per_gram / 4.331802)` gives 0.8664g, the entry is created + **unconfirmed** (the intentional deviation from legacy's auto-confirm), + confirming moves the customer's gold debt 8.25 → 7.3836, and confirming again + **toggles it back** and restores the balance exactly. + +No B2B breakage was found. Three failures in the first drafts were test bugs, +not product bugs, each corrected after checking the real caller: + +| Wrong assumption | What the admin panel actually sends | +|---|---| +| `POST cities/list` | `admin/cities/list` (endpoints.ts:152) | +| `by-ids` takes `ids` | `products_id` (stores/cart.ts:102) | +| document `type: 2` is cash | `type: 4` is «پولی»; 2 is «حواله» and takes a weight in `value` (addFinDoc.vue:33) | + +**Data touched:** both scripts delete only what they create (one order with its +items/status events/ledger entries; one ledger entry). The write script also +asserts the customer's gold and rial balances end exactly as they started.