/** * D21 — sales-expert login to the customer apps. * * Covers exactly what the owner asked for: * * an expert signs in with their AdminUser mobile, via the normal app flow * * the master OTP works for an expert too * * the expert sees THEIR OWN referred customers, and not another expert's * * the expert can place an order themselves * * an ordinary customer is refused both expert screens * * ordinary customer login and ordering still work exactly as before * * an expert's app token is refused on admin routes, and an admin token is * refused on expert routes * * Every Persian string is a literal here and travels as UTF-8 JSON (D15). * * Requires the API running WITH OTP_MASTER_CODE set. * Usage: node scripts/test-sales-expert-app-login.cjs [apiBaseUrl] [masterCode] */ const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, ''); const MASTER = process.argv[3] || '1379'; let pass = 0, 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); const call = async (method, p, body, token) => { const res = await fetch(`${API}/${p}`, { method, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}), }, 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 signIn = (mobile, code = MASTER) => call('POST', 'customer/auth/verify-otp', { username: mobile, password: code }); /** Everything this run creates, removed at the end. */ const made = { admins: [], customers: [], orders: [] }; const uniq = String(Date.now()).slice(-7); (async () => { // ── SETUP: two experts, each with their own referred customer ─────────── step('SETUP — two sales experts, each with one referred customer'); const mkExpert = async (suffix, first, last) => { const admin = await prisma.adminUser.create({ data: { fullName: `${first} ${last}`, firstName: first, lastName: last, mobile: `0915${uniq.slice(0, 3)}${suffix}`, passwordHash: '', role: 'SALES_EXPERT', isActive: true, wagePercent: '4.5', }, }); await prisma.salesExpertProfile.create({ data: { adminUserId: admin.id, gramLevel: '250.5' }, }); made.admins.push(admin.id); return admin; }; const expertA = await mkExpert('001', 'کارشناس', 'الف'); const expertB = await mkExpert('002', 'کارشناس', 'ب'); console.log(` expert A: ${expertA.fullName} (${expertA.mobile})`); console.log(` expert B: ${expertB.fullName} (${expertB.mobile})`); const mkCustomer = async (suffix, name, agentId) => { const c = await prisma.customer.create({ data: { fullName: name, firstName: name.split(' ')[0], lastName: name.split(' ').slice(1).join(' '), mobile: `0916${uniq.slice(0, 3)}${suffix}`, nationalCode: `nc${uniq}${suffix}`, passwordHash: '', kind: 'APP_SHOPKEEPER', isActive: true, referredByAgentId: agentId, goldCreditLimitGrams: '100', }, }); made.customers.push(c.id); return c; }; const custOfA = await mkCustomer('11', 'مشتری الف', expertA.id); const custOfB = await mkCustomer('22', 'مشتری ب', expertB.id); const plainCust = await mkCustomer('33', 'مشتری ساده', null); console.log(` A's customer: ${custOfA.fullName} · B's: ${custOfB.fullName} · unreferred: ${plainCust.fullName}`); // ── 1. EXPERT SIGNS IN ────────────────────────────────────────────────── step('1 — THE EXPERT SIGNS INTO THE APP with their AdminUser mobile'); // requestOtp is what creates the trading account, exactly as a real login would. const req = await call('POST', 'customer/auth/request-otp', { username: expertA.mobile }); chk(req.ok, `POST customer/auth/request-otp -> ${req.status}`); const loginA = await signIn(expertA.mobile); chk(loginA.ok, `verify-otp with the master code -> ${loginA.status}`); const tokenA = loginA.body?.token; chk(Boolean(tokenA), 'an app token came back'); const profA = await prisma.salesExpertProfile.findUnique({ where: { adminUserId: expertA.id } }); chk(Boolean(profA?.tradingCustomerId), 'a trading Customer account was created for the expert'); if (profA?.tradingCustomerId) made.customers.push(profA.tradingCustomerId); const trading = profA?.tradingCustomerId ? await prisma.customer.findUnique({ where: { id: profA.tradingCustomerId } }) : null; chk(String(trading?.goldCreditLimitGrams) === '250.5', `the credit limit came from the profile's gramLevel (${trading?.goldCreditLimitGrams})`); chk(String(trading?.makingChargeOffsetPercent) === '4.5', `the making charge came from the expert's wagePercent (${trading?.makingChargeOffsetPercent})`); chk(trading?.referredByAgentId === null, 'the trading account is NOT one of the expert\'s own referred customers'); step('1b — whoami reports an expert'); const who = await call('GET', 'expert/whoami', undefined, tokenA); chk(who.body?.isSalesExpert === true, `isSalesExpert=${who.body?.isSalesExpert}, type=${who.body?.type}`); // ── 2. THE EXPERT SEES ONLY THEIR OWN CUSTOMERS ───────────────────────── step('2 — "انتخاب مشتری": the expert sees THEIR customers only'); const mine = await call('POST', 'expert/my-customers', {}, tokenA); chk(mine.ok, `POST expert/my-customers -> ${mine.status}`); const ids = (mine.body ?? []).map((c) => c.id); chk(ids.includes(custOfA.id), "A's own customer is listed"); chk(!ids.includes(custOfB.id), "NEGATIVE: B's customer is NOT visible to A"); chk(!ids.includes(plainCust.id), 'NEGATIVE: an unreferred customer is not listed'); step('2b — the search filter still scopes to the expert'); const searched = await call('POST', 'expert/my-customers', { search: 'مشتری' }, tokenA); const sIds = (searched.body ?? []).map((c) => c.id); chk(sIds.includes(custOfA.id), 'search finds A\'s customer'); chk(!sIds.includes(custOfB.id), "NEGATIVE: search cannot reach B's customer"); // ── 3. CHILD ORDERS ───────────────────────────────────────────────────── step('3 — "سفارش مشتریان": only this expert\'s customers\' orders, at DELIVERED_TO_AGENT'); const variant = await prisma.productVariant.findFirst({ where: { OR: [{ qty: { gt: 0 } }, { isPreOrder: true }] }, select: { id: true, rashWeightGrams: true, defaultWagePercent: true }, }); const mkOrder = async (customerId, status) => { const o = await prisma.order.create({ data: { orderNumber: `T-${uniq}-${Math.floor(Math.random() * 100000)}`, customerId, status, totalGoldWeightGrams: '5.5', wageWeightGrams: '0.5', flatRialCharge: '0', channel: 'WHOLESALE', }, }); made.orders.push(o.id); return o; }; const orderA = await mkOrder(custOfA.id, 'DELIVERED_TO_AGENT'); const orderB = await mkOrder(custOfB.id, 'DELIVERED_TO_AGENT'); const orderAEarly = await mkOrder(custOfA.id, 'PLACED'); const childA = await call('POST', 'expert/child-orders', { page: 1, limit: 50 }, tokenA); chk(childA.ok, `POST expert/child-orders -> ${childA.status}`); const cIds = (childA.body?.rows ?? []).map((o) => o.id); chk(cIds.includes(orderA.id), "A's customer's order is listed"); chk(!cIds.includes(orderB.id), "NEGATIVE: B's customer's order is NOT visible to A"); chk(!cIds.includes(orderAEarly.id), 'NEGATIVE: an order not yet at «تحویل مسئول فروش» is excluded (reference sts=5)'); const row = (childA.body?.rows ?? []).find((o) => o.id === orderA.id); chk(row?.customer?.name === custOfA.fullName, 'the card carries the customer name'); chk(row?.w !== undefined, 'the card carries the total weight as `w`, as the reference does'); // ── 4. THE EXPERT ORDERS FOR THEMSELVES ───────────────────────────────── step('4 — the expert places an order of their OWN'); if (variant) { const ord = await call('POST', 'orders', { items: [{ productVariantId: variant.id, quantity: 1, wagePercent: Number(variant.defaultWagePercent), }], }, tokenA); chk(ord.ok, `POST orders as the expert -> ${ord.status}` + (ord.ok ? '' : ` ${ord.raw.slice(0, 160)}`)); const oid = ord.body?.id; if (oid) made.orders.push(oid); if (oid) { const saved = await prisma.order.findUnique({ where: { id: oid } }); chk(saved?.customerId === profA?.tradingCustomerId, "the order landed on the expert's OWN trading account, not a customer's"); } step('4b — the expert has the ordinary app too'); const me = await call('GET', 'customer/me', undefined, tokenA); chk(me.ok, `GET customer/me as the expert -> ${me.status}`); const myOrders = await call('GET', 'customer/me/orders', undefined, tokenA); chk(myOrders.ok, `GET customer/me/orders as the expert -> ${myOrders.status}`); chk((myOrders.body ?? []).some((o) => o.id === oid), "the expert's own order is in their order history"); } // ── 5. AN ORDINARY CUSTOMER IS REFUSED ────────────────────────────────── step('5 — an ORDINARY customer cannot reach either expert screen'); const loginC = await signIn(plainCust.mobile); chk(loginC.ok, `the ordinary customer still signs in -> ${loginC.status}`); const tokenC = loginC.body?.token; const whoC = await call('GET', 'expert/whoami', undefined, tokenC); chk(whoC.body?.isSalesExpert === false, 'whoami says NOT an expert, so the app hides the menu entries'); const cChild = await call('POST', 'expert/child-orders', {}, tokenC); chk(cChild.status === 403, `NEGATIVE: child-orders as a customer -> ${cChild.status} (expected 403)`); const cMine = await call('POST', 'expert/my-customers', {}, tokenC); chk(cMine.status === 403, `NEGATIVE: my-customers as a customer -> ${cMine.status} (expected 403)`); step('5b — and their ordinary app still works exactly as before'); const cMe = await call('GET', 'customer/me', undefined, tokenC); chk(cMe.ok && cMe.body?.id === plainCust.id, `GET customer/me -> ${cMe.status}, own record`); if (variant) { const cOrd = await call('POST', 'orders', { items: [{ productVariantId: variant.id, quantity: 1, wagePercent: Number(variant.defaultWagePercent) }], }, tokenC); chk(cOrd.ok, `an ordinary customer can still place an order -> ${cOrd.status}`); if (cOrd.body?.id) { made.orders.push(cOrd.body.id); const saved = await prisma.order.findUnique({ where: { id: cOrd.body.id } }); chk(saved?.customerId === plainCust.id, 'it landed on their own account'); } } // ── 6. TOKEN BOUNDARIES ───────────────────────────────────────────────── step('6 — token boundaries'); const adminLogin = await fetch(`${API}/admin/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone: '09128081392', password: '09128081392' }), }); const adminToken = (await adminLogin.json()).api_token; const expertOnAdmin = await call('POST', 'admin/customers-omde/list', { page: 1, limit: 1 }, tokenA); chk(expertOnAdmin.status === 401 || expertOnAdmin.status === 403, `NEGATIVE: the expert's APP token on an admin route -> ${expertOnAdmin.status}`); const customerOnAdmin = await call('POST', 'admin/customers-omde/list', { page: 1, limit: 1 }, tokenC); chk(customerOnAdmin.status === 401 || customerOnAdmin.status === 403, `NEGATIVE: a customer token on an admin route -> ${customerOnAdmin.status}`); const adminOnExpert = await call('POST', 'expert/my-customers', {}, adminToken); chk(adminOnExpert.status === 401, `NEGATIVE: an ADMIN token on an expert route -> ${adminOnExpert.status} (expected 401)`); step('6b — a deactivated expert loses app access immediately'); await prisma.adminUser.update({ where: { id: expertA.id }, data: { isActive: false } }); const afterDeactivate = await call('GET', 'expert/me', undefined, tokenA); chk(afterDeactivate.status === 401, `NEGATIVE: the same token after deactivation -> ${afterDeactivate.status} (expected 401)`); await prisma.adminUser.update({ where: { id: expertA.id }, data: { isActive: true } }); // ── CLEANUP ───────────────────────────────────────────────────────────── step('CLEANUP'); await prisma.orderItem.deleteMany({ where: { orderId: { in: made.orders } } }); await prisma.orderStatusEvent.deleteMany({ where: { orderId: { in: made.orders } } }); await prisma.ledgerEntry.deleteMany({ where: { orderId: { in: made.orders } } }); await prisma.order.deleteMany({ where: { id: { in: made.orders } } }); await prisma.customerOtp.deleteMany({ where: { customerId: { in: made.customers } } }); await prisma.salesExpertProfile.deleteMany({ where: { adminUserId: { in: made.admins } } }); await prisma.customer.deleteMany({ where: { id: { in: made.customers } } }); await prisma.adminUser.deleteMany({ where: { id: { in: made.admins } } }); console.log( ` deleted ${made.orders.length} order(s), ${made.customers.length} customer(s) ` + `(including the expert trading account this run created) and ${made.admins.length} admin user(s). ` + 'All were created by this run; no pre-existing data was touched.', ); console.log(`\n${'='.repeat(60)}`); console.log(`SALES-EXPERT APP LOGIN: 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); });