Files
hedena_gold/apps/api/src/modules/customer-auth/request-otp.spec.ts
T

166 lines
7.5 KiB
TypeScript
Raw Normal View History

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