166 lines
7.5 KiB
TypeScript
166 lines
7.5 KiB
TypeScript
import { ForbiddenException, HttpException, NotFoundException } from '@nestjs/common';
|
|||
|
|
import { AdminRole } from '@prisma/client';
|
||
|
|
|
||
|
|
// @nestjs/jwt v12 ships ESM only, which this CommonJS jest setup cannot load.
|
||
|
|
// Same stub as master-otp.spec.ts — the service gets a hand-built double.
|
||
|
|
jest.mock('@nestjs/jwt', () => ({ JwtService: class {} }));
|
||
|
|
|
||
|
|
import { CustomerAuthService } from './customer-auth.service';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Owner ruling 2026-10-12 — step 1 of the app login must say what went wrong.
|
||
|
|
*
|
||
|
|
* Until now an unknown number got `{ sent: true }` (opaque on purpose, to stop
|
||
|
|
* customer enumeration) and a failed SMS send also got `{ sent: true }` (a dead
|
||
|
|
* gateway is only a log line, D20). Both walked the person to the code box to
|
||
|
|
* wait for a message that was never going to arrive, and nothing on screen told
|
||
|
|
* the three real causes apart: the SMS is late, the gateway is down, or the
|
||
|
|
* number was never registered.
|
||
|
|
*
|
||
|
|
* `requestOtp` had NO test coverage at all before this file, so the whole suite
|
||
|
|
* stayed green through that change — these cases exist so it cannot again.
|
||
|
|
*/
|
||
|
|
describe('CustomerAuthService.requestOtp — the caller is told what happened', () => {
|
||
|
|
const saved = { ...process.env };
|
||
|
|
|
||
|
|
const CUSTOMER = { id: 'cus_1', mobile: '09121112233', isActive: true, isArchived: false };
|
||
|
|
|
||
|
|
function build(opts: {
|
||
|
|
customer?: unknown;
|
||
|
|
expert?: unknown;
|
||
|
|
smsResult?: { ok: boolean; error?: string };
|
||
|
|
} = {}) {
|
||
|
|
const prisma = {
|
||
|
|
customer: {
|
||
|
|
findFirst: jest.fn().mockResolvedValue('customer' in opts ? opts.customer : CUSTOMER),
|
||
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
||
|
|
},
|
||
|
|
customerOtp: {
|
||
|
|
findFirst: jest.fn().mockResolvedValue(null),
|
||
|
|
create: jest.fn().mockResolvedValue({}),
|
||
|
|
count: jest.fn().mockResolvedValue(0),
|
||
|
|
},
|
||
|
|
smsMessage: { create: jest.fn().mockResolvedValue({}) },
|
||
|
|
adminUser: { findFirst: jest.fn().mockResolvedValue(opts.expert ?? null) },
|
||
|
|
};
|
||
|
|
const sms = { send: jest.fn().mockResolvedValue(opts.smsResult ?? { ok: true }) };
|
||
|
|
const service = new CustomerAuthService(
|
||
|
|
prisma as never,
|
||
|
|
{ signAsync: jest.fn() } as never,
|
||
|
|
sms as never,
|
||
|
|
{ enabled: false, attestOtpLogin: jest.fn(), browserBootstrapUrl: () => null } as never,
|
||
|
|
{ newTokenId: jest.fn().mockReturnValue(null), record: jest.fn() } as never,
|
||
|
|
);
|
||
|
|
jest.spyOn(service['logger'], 'warn').mockImplementation(() => undefined);
|
||
|
|
jest.spyOn(service['logger'], 'error').mockImplementation(() => undefined);
|
||
|
|
return { service, prisma, sms };
|
||
|
|
}
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
process.env.NODE_ENV = 'test';
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
process.env = { ...saved };
|
||
|
|
});
|
||
|
|
|
||
|
|
it('sends the code for a registered, active customer', async () => {
|
||
|
|
const { service, sms, prisma } = build();
|
||
|
|
await expect(service.requestOtp('09121112233', '127.0.0.1')).resolves.toEqual({ sent: true });
|
||
|
|
expect(sms.send).toHaveBeenCalledTimes(1);
|
||
|
|
expect(prisma.customerOtp.create).toHaveBeenCalledTimes(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('refuses an unregistered number with «ثبت نشده»، not a silent success', async () => {
|
||
|
|
const { service, sms } = build({ customer: null });
|
||
|
|
await expect(service.requestOtp('09129999999', '127.0.0.1')).rejects.toThrow(NotFoundException);
|
||
|
|
await expect(service.requestOtp('09129999999', '127.0.0.1')).rejects.toThrow('ثبت نشده');
|
||
|
|
// Nothing was sent and no challenge was stored for a number nobody holds.
|
||
|
|
expect(sms.send).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('normalises Persian digits before deciding the number is unknown', async () => {
|
||
|
|
// Guards against a false «ثبت نشده» for someone typing on a Persian keypad.
|
||
|
|
const { service, prisma } = build();
|
||
|
|
await service.requestOtp('۰۹۱۲۱۱۱۲۲۳۳', '127.0.0.1');
|
||
|
|
expect(prisma.customer.findFirst).toHaveBeenCalledWith(
|
||
|
|
expect.objectContaining({ where: expect.objectContaining({ mobile: '09121112233' }) }),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reports a failed SMS send as 503 instead of claiming it was sent', async () => {
|
||
|
|
const { service } = build({ smsResult: { ok: false, error: 'gateway refused' } });
|
||
|
|
await expect(service.requestOtp('09121112233', '127.0.0.1')).rejects.toThrow(HttpException);
|
||
|
|
await expect(service.requestOtp('09121112233', '127.0.0.1')).rejects.toThrow('ارسال پیام کوتاه');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('still completes the login in development when there is no gateway at all', async () => {
|
||
|
|
// The local-dev path: SmsService sends nothing, returns its own
|
||
|
|
// `sms-not-configured`, and the code is logged instead — so this one failure
|
||
|
|
// must NOT become an error or nobody could sign in locally.
|
||
|
|
process.env.NODE_ENV = 'development';
|
||
|
|
const { service } = build({ smsResult: { ok: false, error: 'sms-not-configured' } });
|
||
|
|
await expect(service.requestOtp('09121112233', '127.0.0.1')).resolves.toEqual({ sent: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('still fails in development when a configured gateway refuses', async () => {
|
||
|
|
// D26's default `relay` driver counts as configured on any machine with an
|
||
|
|
// external IPv4, so a dev box that cannot reach the relay gets a real
|
||
|
|
// failure — which must be reported, not swallowed as "no gateway".
|
||
|
|
process.env.NODE_ENV = 'development';
|
||
|
|
const { service } = build({ smsResult: { ok: false, error: 'relay unreachable' } });
|
||
|
|
await expect(service.requestOtp('09121112233', '127.0.0.1')).rejects.toThrow('ارسال پیام کوتاه');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('refuses in production even when nothing is configured', async () => {
|
||
|
|
// The dev fallback must never let a production deployment report success
|
||
|
|
// while silently sending nothing.
|
||
|
|
process.env.NODE_ENV = 'production';
|
||
|
|
const { service } = build({ smsResult: { ok: false, error: 'sms-not-configured' } });
|
||
|
|
await expect(service.requestOtp('09121112233', '127.0.0.1')).rejects.toThrow('ارسال پیام کوتاه');
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A sales expert signs in through this same flow (D21). Three states return
|
||
|
|
* no trading account for an expert who DOES exist, and «ثبت نشده است» would
|
||
|
|
* be a lie in each — it would send support hunting a registration problem.
|
||
|
|
*/
|
||
|
|
describe('a sales expert whose trading account cannot be used', () => {
|
||
|
|
it('says the account is disabled, not that the number is unknown', async () => {
|
||
|
|
const { service } = build({
|
||
|
|
customer: null,
|
||
|
|
expert: {
|
||
|
|
id: 'adm_1',
|
||
|
|
mobile: '09121112233',
|
||
|
|
role: AdminRole.SALES_EXPERT,
|
||
|
|
fullName: 'کارشناس',
|
||
|
|
firstName: 'کار',
|
||
|
|
lastName: 'شناس',
|
||
|
|
wagePercent: '0',
|
||
|
|
salesExpertProfile: { id: 'sep_1', gramLevel: '0', nationalCode: null, tradingCustomerId: 'cus_dead' },
|
||
|
|
},
|
||
|
|
});
|
||
|
|
// findUnique returns null → the linked customer is gone or unusable.
|
||
|
|
await expect(service.requestOtp('09121112233', '127.0.0.1')).rejects.toThrow(ForbiddenException);
|
||
|
|
await expect(service.requestOtp('09121112233', '127.0.0.1')).rejects.toThrow('غیرفعال');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('says the account is incomplete when the expert has no profile', async () => {
|
||
|
|
const { service } = build({
|
||
|
|
customer: null,
|
||
|
|
expert: {
|
||
|
|
id: 'adm_2',
|
||
|
|
mobile: '09121112233',
|
||
|
|
role: AdminRole.SALES_EXPERT,
|
||
|
|
fullName: 'کارشناس',
|
||
|
|
firstName: 'کار',
|
||
|
|
lastName: 'شناس',
|
||
|
|
wagePercent: '0',
|
||
|
|
salesExpertProfile: null,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
await expect(service.requestOtp('09121112233', '127.0.0.1')).rejects.toThrow('کامل نیست');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|