Files
tehran_taj/apps/api/src/modules/admin-users/admin-users.expert-sync.spec.ts
T
mahmoodandClaude Sonnet 5.5 5f3b00497e feat(auth): one mobile, one person across sales experts and customers
Experts (AdminUser) and customers (Customer) sign in to the same apps by mobile
but live in different tables, so the database cannot make the number unique
across them. A number could be given to an expert and to a customer, and the
login then silently merged the two (the customer row was adopted as the expert's
trading account), which left the expert on a customer session and the panel's
name edits unreachable.

The reference rejected a duplicate at entry (`unique:customers`, the agent being
a customers row too); this does the same where a number is typed in:

- customer create/update (wholesale, banker, walk-in): refused when an ACTIVE
  expert holds the number;
- expert create/update: refused when a customer holds it (the expert's own
  trading account excepted);
- the app's self sign-up: refused when an active expert holds it.

Only a number being created or CHANGED is checked, so existing duplicates keep
signing in and stay editable; the login's "expert wins" adoption is untouched.
Plain equality on the two unique columns, normalised with faToEn, so both
lookups are index hits.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 14:24:37 +03:30

118 lines
5.2 KiB
TypeScript

import { Prisma } from '@prisma/client';
import { AdminUsersService } from './admin-users.service';
/**
* Editing a sales expert in the panel must reach the app: the app shows the
* expert their own account through the linked trading Customer row, which is
* only seeded once at first sign-in.
*/
describe('AdminUsersService — expert edit reaches the trading account', () => {
const expertRow = {
id: 'exp1',
role: 'SALES_EXPERT',
fullName: 'سعید اسماعیلی',
firstName: 'سعید',
lastName: 'اسماعیلی',
mobile: '09902560722',
wagePercent: new Prisma.Decimal(12),
isActive: true,
salesExpertProfile: { tradingCustomerId: 'cust1', gramLevel: new Prisma.Decimal(0) },
};
function build(customerHolder: { id: string } | null = null) {
const customerUpdate = jest.fn(async () => ({}));
const tx = {
adminUser: {
update: jest.fn(async ({ data }: { data: Record<string, unknown> }) => ({
...expertRow,
...(data.firstName !== undefined ? { firstName: data.firstName, fullName: `${data.firstName} ${data.lastName}` } : {}),
...(data.lastName !== undefined ? { lastName: data.lastName } : {}),
...(data.mobile !== undefined ? { mobile: data.mobile } : {}),
...(data.wagePercent !== undefined ? { wagePercent: data.wagePercent } : {}),
salesExpertProfile: {
...expertRow.salesExpertProfile,
...((data.salesExpertProfile as { upsert?: { update?: { gramLevel?: Prisma.Decimal } } } | undefined)
?.upsert?.update?.gramLevel !== undefined
? { gramLevel: (data.salesExpertProfile as { upsert: { update: { gramLevel: Prisma.Decimal } } }).upsert.update.gramLevel }
: {}),
},
})),
},
customer: {
update: customerUpdate,
findUnique: jest.fn(async () => customerHolder),
// the cross-table check: does a customer (not the expert's own account) hold the number
findFirst: jest.fn(async () => customerHolder),
},
};
const prisma = {
...tx,
admin: { findUnique: jest.fn(async () => null) },
adminUser: {
...tx.adminUser,
findFirst: jest.fn(async () => expertRow),
// the expert-table mobile clash check: no other expert holds the number
findUnique: jest.fn(async () => null),
},
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
};
const service = new AdminUsersService(prisma as never, {} as never, {} as never);
return { service, customerUpdate };
}
it('copies a name change to the trading account', async () => {
const { service, customerUpdate } = build();
await service.update('exp1', { fname: 'سعید1', lname: 'اسماعیلی1' });
expect(customerUpdate).toHaveBeenCalledWith({
where: { id: 'cust1' },
data: { firstName: 'سعید1', lastName: 'اسماعیلی1' },
});
});
it('copies the credit limit and wage percent the operator changed', async () => {
const { service, customerUpdate } = build();
await service.update('exp1', { gram_level: '50', percent: '3' });
const data = (customerUpdate.mock.calls[0] as unknown as [{ data: Record<string, Prisma.Decimal> }])[0].data;
expect(data.goldCreditLimitGrams.toString()).toBe('50');
expect(data.makingChargeOffsetPercent.toString()).toBe('3');
expect(data).not.toHaveProperty('firstName');
});
it('copies a new mobile only when no other customer holds it', async () => {
const free = build(null);
await free.service.update('exp1', { mobile: '09120001111' });
expect(free.customerUpdate).toHaveBeenCalledWith({ where: { id: 'cust1' }, data: { mobile: '09120001111' } });
});
it('refuses a new mobile a customer already holds, and writes nothing', async () => {
const taken = build({ id: 'someone-else' });
await expect(taken.service.update('exp1', { mobile: '09120001111' })).rejects.toThrow(
'این شماره موبایل قبلاً برای یک مشتری ثبت شده است',
);
expect(taken.customerUpdate).not.toHaveBeenCalled();
});
it('does not ask about a mobile that is not changing, so an existing duplicate stays editable', async () => {
const { service, customerUpdate } = build({ id: 'someone-else' });
await expect(service.update('exp1', { mobile: '09902560722', fname: 'سعید1', lname: 'اسماعیلی1' })).resolves.toBeDefined();
expect(customerUpdate).toHaveBeenCalledWith({
where: { id: 'cust1' },
data: { firstName: 'سعید1', lastName: 'اسماعیلی1' },
});
});
it('refuses to create an expert on a number a customer holds', async () => {
const { service } = build({ id: 'someone-else' });
await expect(
service.create({ fname: 'علی', lname: 'رضایی', mobile: '09125550000', password: 'secret1', role: 'SALES_EXPERT' }),
).rejects.toThrow('این شماره موبایل قبلاً برای یک مشتری ثبت شده است');
});
it('writes nothing to the trading account when nothing it mirrors changed', async () => {
const { service, customerUpdate } = build();
await service.update('exp1', { address: 'تهران' });
expect(customerUpdate).not.toHaveBeenCalled();
});
});