/** * Delivery smoke test — the admin flows the owner listed (D19 step 3): * * login → create a customer → create and edit a sales expert * → create and edit a product with variants * → view an order and change its status * * This is a WORKING check, not a parity audit: each step asserts that the * operation actually took effect in the database, not merely that the HTTP * call returned 200. A 200 with a silently-dropped field is exactly the class * of bug this is meant to catch. * * Every Persian string is a literal in this file and travels as UTF-8 JSON — * never as a shell argument (D15). * * Usage: node scripts/smoke-admin.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 = (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); /** Records created by this run, removed at the end. */ const created = { customers: [], adminUsers: [], products: [] }; let H = {}; const call = async (method, path, body) => { const res = await fetch(`${API}/${path}`, { method, headers: H, body: body === undefined ? undefined : JSON.stringify(body), }); let payload = null; try { payload = await res.json(); } catch { /* empty body */ } return { status: res.status, ok: res.ok, body: payload }; }; (async () => { // ── 1. LOGIN ──────────────────────────────────────────────────────────── step('1 — ADMIN LOGIN'); 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(); chk(login.status === 200 || login.status === 201, `POST admin/auth/login -> ${login.status}`); chk(typeof auth.api_token === 'string', 'a token came back'); if (!auth.api_token) { console.log('\nCannot continue without a token.'); process.exit(1); } H = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + auth.api_token }; const unique = String(Date.now()).slice(-7); // ── 2. CREATE A CUSTOMER ──────────────────────────────────────────────── step('2 — CREATE A WHOLESALE CUSTOMER'); const custMobile = '0912' + unique; const cust = await call('POST', 'admin/customers-omde/create', { fname: 'سمیرا', lname: 'کریمی', name: 'سمیرا کریمی', mobile: custMobile, percent: 3, type: 2, store_name: 'گالری سمیرا', gram_level: 150, }); chk(cust.ok, `POST customers-omde/create -> ${cust.status}`); const custId = cust.body?.id; if (custId) created.customers.push(custId); chk(Boolean(custId), 'an id came back'); if (custId) { const row = await prisma.customer.findUnique({ where: { id: custId } }); chk(row?.firstName === 'سمیرا' && row?.lastName === 'کریمی', 'the name parts were stored separately'); chk(row?.fullName === 'سمیرا کریمی', 'fullName was rebuilt by the trigger'); chk(String(row?.makingChargeOffsetPercent) === '3', 'percent persisted as 3'); chk(row?.storeName === 'گالری سمیرا', 'store_name persisted'); } step('2b — Persian digits in the mobile are normalised (D19 gap 2)'); const faMobile = '۰۹۱۳' + unique; const faCust = await call('POST', 'admin/customers-omde/create', { fname: 'نگین', lname: 'مرادی', mobile: faMobile, percent: 3, type: 2, }); chk(faCust.ok, `create with Persian digits -> ${faCust.status}`); if (faCust.body?.id) { created.customers.push(faCust.body.id); const row = await prisma.customer.findUnique({ where: { id: faCust.body.id } }); chk(row?.mobile === '0913' + unique, `stored as ASCII "${row?.mobile}" (not Persian digits)`); } // ── 3. SALES EXPERT: CREATE, READ BACK, EDIT ──────────────────────────── step('3 — CREATE A SALES EXPERT'); const expMobile = '0914' + unique; const exp = await call('POST', 'admin/users', { fname: 'محمد علی', lname: 'احمدی', name: 'محمد علی احمدی', mobile: expMobile, password: 'Test12345', role: 'SALES_EXPERT', percent: 4.5, gram_level: 250, }); chk(exp.ok, `POST admin/users -> ${exp.status}`); const expId = exp.body?.id; if (expId) created.adminUsers.push(expId); chk(Boolean(expId), 'an id came back'); if (expId) { const row = await prisma.adminUser.findUnique({ where: { id: expId }, include: { salesExpertProfile: true }, }); chk(row?.firstName === 'محمد علی', 'a two-token first name was NOT re-split'); chk(row?.lastName === 'احمدی', 'lastName stored'); chk(Boolean(row?.salesExpertProfile), 'the SalesExpertProfile was auto-created'); step('3b — EDIT the sales expert (one field only)'); const edit = await call('PUT', `admin/users/${expId}`, { gram_level: 999 }); chk(edit.ok, `PUT admin/users/:id -> ${edit.status}`); const after = await prisma.adminUser.findUnique({ where: { id: expId }, include: { salesExpertProfile: true }, }); chk(String(after?.salesExpertProfile?.gramLevel) === '999', 'gram_level was persisted'); chk(after?.firstName === 'محمد علی' && after?.lastName === 'احمدی', 'the name was untouched'); const grid = await call('POST', 'admin/customers-omde/reagent/list', { page: 1, limit: 200 }); chk(grid.ok, `POST reagent/list -> ${grid.status}`); const mine = (grid.body?.rows ?? []).find((r) => r.id === expId); chk(Boolean(mine), 'the expert appears in the grid'); chk(mine?.fname === 'محمد علی' && mine?.lname === 'احمدی', 'the grid shows the real name parts'); } // ── 4. PRODUCT + VARIANTS: CREATE, READ BACK, EDIT ────────────────────── step('4 — CREATE A PRODUCT WITH VARIANTS'); const prod = await call('POST', 'admin/products/create', { title: 'انگشتر طلا ' + unique, body_html: 'توضیح آزمایشی', product_code: 'SMK-' + unique, show_type: 3, is_active: true, variants: [ { title: 'سایز ۱', grams: 2.5, percentage_pay: 7, qty: 4 }, { title: 'سایز ۲', grams: 3.25, percentage_pay: 7, qty: 0, is_pre_order: true }, ], }); chk(prod.ok, `POST admin/products/create -> ${prod.status}`); const prodId = prod.body?.id; if (prodId) created.products.push(prodId); chk(Boolean(prodId), 'an id came back'); if (prodId) { const variants = await prisma.productVariant.findMany({ where: { productId: prodId }, orderBy: { title: 'asc' }, }); chk(variants.length === 2, `both variants were created (got ${variants.length})`); chk( variants.some((v) => String(v.rashWeightGrams) === '2.5'), 'the 2.5g weight round-tripped exactly (no float drift)', ); chk( variants.some((v) => String(v.defaultWagePercent) === '7'), 'the making charge (اجرت) persisted as 7', ); chk(variants.every((v) => Boolean(v.sku)), 'every variant got a SKU'); chk( variants.some((v) => v.isPreOrder === true), 'the pre-order flag persisted', ); step('4b — OPEN the product for edit'); const show = await call('GET', `admin/products/${prodId}`); chk(show.ok, `GET admin/products/:id -> ${show.status}`); chk(show.body?.title === 'انگشتر طلا ' + unique, 'the title reads back intact'); // The form reads `variants_all` (AddProduct.vue:488), not `variants`. const shownVariants = show.body?.variants_all ?? []; chk(shownVariants.length === 2, `the edit form receives both variants (got ${shownVariants.length})`); step('4c — EDIT the product'); const upd = await call('PUT', `admin/products/${prodId}`, { title: 'انگشتر طلا ویرایش‌شده' }); chk(upd.ok, `PUT admin/products/:id -> ${upd.status}`); const afterP = await prisma.product.findUnique({ where: { id: prodId } }); chk(afterP?.title === 'انگشتر طلا ویرایش‌شده', 'the new title persisted'); chk(afterP?.productCode === 'SMK-' + unique, 'product_code was NOT wiped by the partial edit'); const stillThere = await prisma.productVariant.count({ where: { productId: prodId } }); chk(stillThere === 2, `the variants survived the edit (${stillThere} of 2)`); } // ── 5. ORDERS: LIST, VIEW, CHANGE STATUS ──────────────────────────────── step('5 — ORDER LIST / DETAIL / STATUS'); const orders = await call('POST', 'admin/orders/list', { page: 1, limit: 5 }); chk(orders.ok, `POST admin/orders/list -> ${orders.status}`); const wholesale = await call('POST', 'admin/orders/wholesale/list', { page: 1, limit: 5 }); chk(wholesale.ok, `POST admin/orders/wholesale/list -> ${wholesale.status}`); const statusOpts = await call('GET', 'admin/orders/status-options'); chk(statusOpts.ok, `GET admin/orders/status-options -> ${statusOpts.status}`); const anyOrder = (orders.body?.rows ?? [])[0] ?? (wholesale.body?.rows ?? [])[0] ?? null; if (!anyOrder) { console.log(' (no order exists in this database — detail/status checks skipped)'); } else { const detail = await call('GET', `admin/orders/${anyOrder.id}`); chk(detail.ok, `GET admin/orders/:id -> ${detail.status}`); chk(Boolean(detail.body?.id), 'the order detail came back'); const before = await prisma.order.findUnique({ where: { id: anyOrder.id } }); const options = Array.isArray(statusOpts.body) ? statusOpts.body : []; // Pick a status that is NOT the current one, so the write is observable. const targetOpt = options.find( (o) => String(o.id ?? o.value) !== String(before?.status), ); if (!targetOpt || !before) { console.log(' (no alternative status available — status change skipped)'); } else { const target = String(targetOpt.id ?? targetOpt.value); // UpdateOrderStatusDto requires `orderId` — the exact field the retail // List.vue screen was failing to send (fixed in this batch). const res = await call('POST', 'admin/orders/status', { orderId: anyOrder.id, status: target, }); chk(res.ok, `POST admin/orders/status -> ${res.status}`); const after = await prisma.order.findUnique({ where: { id: anyOrder.id } }); chk(String(after?.status) === target, `the status actually changed to ${target}`); // Put it back exactly as it was — this is live data, not a fixture. await prisma.order.update({ where: { id: anyOrder.id }, data: { status: before.status }, }); const restored = await prisma.order.findUnique({ where: { id: anyOrder.id } }); chk(restored?.status === before.status, 'the original status was restored'); } } // ── CLEANUP ───────────────────────────────────────────────────────────── step('CLEANUP — removing only the rows this run created'); if (created.products.length) { await prisma.productVariant.deleteMany({ where: { productId: { in: created.products } } }); await prisma.productImage.deleteMany({ where: { productId: { in: created.products } } }); await prisma.product.deleteMany({ where: { id: { in: created.products } } }); } if (created.adminUsers.length) { await prisma.salesExpertProfile.deleteMany({ where: { adminUserId: { in: created.adminUsers } }, }); await prisma.adminUser.deleteMany({ where: { id: { in: created.adminUsers } } }); } if (created.customers.length) { await prisma.customer.deleteMany({ where: { id: { in: created.customers } } }); } console.log( ` deleted: ${created.products.length} product(s) + their variants/images, ` + `${created.adminUsers.length} admin user(s) + profiles, ` + `${created.customers.length} customer(s). No pre-existing data was removed.`, ); console.log(`\n${'='.repeat(60)}`); console.log(`ADMIN 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('\nSMOKE ABORTED:', e); await prisma.$disconnect(); process.exit(1); });