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 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-23 00:25:53 +03:30
co-authored by Claude Opus 5
parent 8b7da90f51
commit 6d0f5c5359
3 changed files with 449 additions and 0 deletions
+168
View File
@@ -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);
});
+244
View File
@@ -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);
});
+37
View File
@@ -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.