Files
hedena_gold/apps/api/scripts/test-sales-expert-app-login.cjs
T
2026-10-07 20:20:27 +03:30

286 lines
14 KiB
JavaScript

/**
* 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: '!NO_PASSWORD_SET_YET', 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: '!OTP_ONLY_NO_PASSWORD_LOGIN', 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);
});