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('کامل نیست'); }); }); });