import { NetworkInterfaceInfo, networkInterfaces } from 'node:os'; import { Logger } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; import { DEFAULT_TREZ_URL, trezConfig } from './drivers/trez.driver'; import { relayConfig, relayRejection } from './drivers/relay.driver'; import { SmsService, assertSmsConfigured, smsConfigured, smsProvider, } from './sms.service'; const SMS_ENV = [ 'SMS_PROVIDER', 'SMS_RELAY_URL', 'SMS_RELAY_SECRET', 'SMS_RELAY_HOST', 'SMS_RELAY_PLATFORM', 'SMS_TREZ_URL', 'SMS_TREZ_USERNAME', 'SMS_TREZ_PASSWORD', 'SMS_TREZ_SENDER_LINE', 'NODE_ENV', ]; // Node's own module namespace cannot be spied on, so the lookup is mocked. jest.mock('node:os', () => ({ ...jest.requireActual('node:os'), networkInterfaces: jest.fn() })); const interfaces = networkInterfaces as jest.MockedFunction; const iface = (address: string, internal: boolean) => ({ address, internal, family: 'IPv4' }) as NetworkInterfaceInfo; /** A machine with only loopback, so the relay has no host to report. */ const noExternalIp = () => interfaces.mockReturnValue({ lo: [iface('127.0.0.1', true)] }); const saved = { ...process.env }; beforeEach(() => { for (const key of SMS_ENV) delete process.env[key]; jest.restoreAllMocks(); interfaces.mockReturnValue({ eth0: [iface('10.9.9.9', false)] }); }); afterAll(() => { process.env = { ...saved }; }); /** * Covers the boot-time contract as REVISED by D20 and the driver choice of D26: * * a missing key NEVER stops the boot, in any environment — it warns, and * sending silently declines; * * `relay` is the default provider; `trez` stays selectable. */ describe('SMS configuration', () => { // Silence the warnings these tests deliberately provoke. const logger = { warn: jest.fn(), log: jest.fn(), error: jest.fn() } as unknown as Logger; const warned = () => (logger.warn as jest.Mock).mock.calls.flat().join(' '); beforeEach(() => jest.clearAllMocks()); describe('provider selection (D26)', () => { it('defaults to relay', () => { expect(smsProvider()).toBe('relay'); }); it('treats a blank SMS_PROVIDER, as docker-compose passes it, as the default', () => { process.env.SMS_PROVIDER = ''; expect(smsProvider()).toBe('relay'); }); it('still selects trez on request', () => { process.env.SMS_PROVIDER = 'Trez'; expect(smsProvider()).toBe('trez'); }); }); describe('relay', () => { it('works out of the box', () => { process.env.SMS_RELAY_HOST = '10.0.0.5'; const c = relayConfig(); expect(c.url).toBe('http://185.231.112.101/api/createsms'); expect(c.secret).toBeTruthy(); expect(smsConfigured()).toBe(true); }); it('falls back to the server IP for host, skipping loopback', () => { interfaces.mockReturnValue({ lo: [iface('127.0.0.1', true)], eth0: [iface('192.168.1.20', false)], }); expect(relayConfig().host).toBe('192.168.1.20'); }); it('lets SMS_RELAY_HOST override the server IP', () => { process.env.SMS_RELAY_HOST = 'gold.example'; expect(relayConfig().host).toBe('gold.example'); }); it('is not configured, and warns by name, when no host can be found', () => { noExternalIp(); expect(smsConfigured()).toBe(false); expect(() => assertSmsConfigured(logger)).not.toThrow(); expect(warned()).toMatch(/SMS_RELAY_HOST/); }); it('passes silently once a host is known', () => { process.env.SMS_RELAY_HOST = '10.0.0.5'; assertSmsConfigured(logger); expect(logger.warn).not.toHaveBeenCalled(); }); }); describe('trez', () => { beforeEach(() => { process.env.SMS_PROVIDER = 'trez'; }); const configureFully = () => { process.env.SMS_TREZ_USERNAME = 'u'; process.env.SMS_TREZ_PASSWORD = 'p'; process.env.SMS_TREZ_SENDER_LINE = '9998882797'; }; it('defaults the URL to the reference endpoint and lets staging override it', () => { expect(trezConfig().url).toBe(DEFAULT_TREZ_URL); process.env.SMS_TREZ_URL = 'https://staging.example/send'; expect(trezConfig().url).toBe('https://staging.example/send'); }); it('is configured only once all three credentials are set', () => { expect(smsConfigured()).toBe(false); process.env.SMS_TREZ_USERNAME = 'u'; process.env.SMS_TREZ_PASSWORD = 'p'; expect(smsConfigured()).toBe(false); process.env.SMS_TREZ_SENDER_LINE = '9998882797'; expect(smsConfigured()).toBe(true); }); it('warns in production when keys are missing, naming them, and still boots', () => { process.env.NODE_ENV = 'production'; expect(() => assertSmsConfigured(logger)).not.toThrow(); expect(warned()).toMatch(/SMS_TREZ_USERNAME/); expect(warned()).toMatch(/SMS_TREZ_PASSWORD/); expect(warned()).toMatch(/SMS_TREZ_SENDER_LINE/); }); it('passes silently in production once everything is set', () => { process.env.NODE_ENV = 'production'; configureFully(); assertSmsConfigured(logger); expect(logger.warn).not.toHaveBeenCalled(); }); }); describe('none and unknown providers (D20 — never throws)', () => { it('allows SMS to be explicitly disabled, in production too', () => { process.env.NODE_ENV = 'production'; process.env.SMS_PROVIDER = 'none'; process.env.SMS_RELAY_HOST = '10.0.0.5'; expect(smsConfigured()).toBe(false); expect(() => assertSmsConfigured(logger)).not.toThrow(); expect(logger.warn).toHaveBeenCalled(); }); it('warns about an unknown provider name instead of refusing to boot', () => { process.env.SMS_PROVIDER = 'kavenegar'; expect(smsConfigured()).toBe(false); expect(() => assertSmsConfigured(logger)).not.toThrow(); expect(warned()).toMatch(/Unknown SMS_PROVIDER/); expect(warned()).toMatch(/"relay", "trez", "none"/); }); }); }); describe('relayRejection', () => { it('accepts empty and neutral JSON bodies', () => { expect(relayRejection('')).toBeNull(); expect(relayRejection('{"status":true,"id":12}')).toBeNull(); expect(relayRejection('{"status":"success"}')).toBeNull(); expect(relayRejection('[1,2]')).toBeNull(); }); it('rejects bodies that say they failed', () => { expect(relayRejection('{"status":false}')).toBe('rejected-by-gateway'); expect(relayRejection('{"success":false,"message":"bad phone"}')).toBe('bad phone'); expect(relayRejection('{"status":"error"}')).toBe('rejected-by-gateway'); expect(relayRejection('{"error":"invalid key"}')).toBe('invalid key'); }); it('rejects a bare-text body — the gateway reports its own PHP errors that way', () => { expect(relayRejection('cURL library is not loaded')).toBe('cURL library is not loaded'); }); it('reads the trailing JSON when the gateway prints debug text before it', () => { const debug = '1. Function Start at: 13:07:25:586
2. Request Sending (Standard) at: 13:07:25:586
' + 'Total Duration: 0.2961 seconds
572381951'; expect(relayRejection(debug + '{"data":"sent"}')).toBeNull(); expect(relayRejection(debug + '{"status":false,"message":"bad phone"}')).toBe('bad phone'); }); }); describe('SmsService.send', () => { const create = jest.fn(); const service = new SmsService({ smsMessage: { create } } as unknown as PrismaService); let fetchMock: jest.SpyInstance; const reply = (status: number, body: string) => fetchMock.mockResolvedValue(new Response(body, { status })); beforeEach(() => { create.mockReset().mockResolvedValue({}); fetchMock = jest.spyOn(globalThis, 'fetch'); // Keep the deliberate failure logs out of the test output. jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); process.env.SMS_RELAY_HOST = '10.0.0.5'; }); it('posts the shape of the owner PHP client to the relay', async () => { reply(200, '{"status":true}'); const result = await service.send('09120000000', ' سلام '); expect(result).toEqual({ ok: true, response: '{"status":true}' }); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe('http://185.231.112.101/api/createsms'); expect(init.method).toBe('POST'); expect(init.headers).toEqual({ 'Content-Type': 'application/json', secret_key: relayConfig().secret, }); expect(JSON.parse(init.body)).toEqual({ phone: '09120000000', host: '10.0.0.5', text: 'سلام', }); expect(create).toHaveBeenCalledWith({ data: expect.objectContaining({ recipients: '09120000000', body: 'سلام' }), }); }); it('reports an HTTP error without throwing, and still records the message', async () => { reply(500, 'boom'); await expect(service.send('09120000000', 'x')).resolves.toEqual({ ok: false, response: 'boom', error: 'http-500', }); expect(create).toHaveBeenCalled(); }); it('reports a 200 that the gateway marks as failed', async () => { reply(200, '{"status":false,"message":"no credit"}'); const result = await service.send('09120000000', 'x'); expect(result.ok).toBe(false); expect(result.error).toBe('no credit'); }); it('turns a network failure into ok:false', async () => { fetchMock.mockRejectedValue(new Error('ECONNREFUSED')); await expect(service.send('09120000000', 'x')).resolves.toEqual({ ok: false, error: 'ECONNREFUSED', }); }); it('reports the gateway bare-text PHP error, as seen live', async () => { reply(200, 'cURL library is not loaded'); await expect(service.send('09120000000', 'x')).resolves.toEqual({ ok: false, response: 'cURL library is not loaded', error: 'cURL library is not loaded', }); }); it('honours persist:false', async () => { reply(200, '{}'); await service.send('09120000000', 'x', { persist: false }); expect(create).not.toHaveBeenCalled(); }); it('sends nothing when the provider is disabled', async () => { process.env.SMS_PROVIDER = 'none'; const result = await service.send('09120000000', 'x'); expect(result).toEqual({ ok: false, error: 'sms-not-configured' }); expect(fetchMock).not.toHaveBeenCalled(); }); it('still drives trez when selected', async () => { process.env.SMS_PROVIDER = 'trez'; process.env.SMS_TREZ_USERNAME = 'u'; process.env.SMS_TREZ_PASSWORD = 'p'; process.env.SMS_TREZ_SENDER_LINE = '9998882797'; reply(200, '1'); await service.send('09120000000', 'x'); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe(DEFAULT_TREZ_URL); expect(Object.fromEntries(new URLSearchParams(init.body))).toEqual({ PhoneNumber: '9998882797', Username: 'u', Password: 'p', Mobile: '09120000000', Message: 'x', }); }); }); /** * The OTP text keeps the reference's shape (ApiMobileController.php:47) with * the brand منتزر — the first line and the trailing token are the Android SMS * Retriever hash, and autofill breaks if either changes. */ describe('OTP message template', () => { const build = (code: string) => `<#> code is ${code}\n` + `رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن منتزر ${code} می باشد\n` + `aMo6iFyUQaY`; it('matches the expected text byte for byte', () => { expect(build('1234')).toBe( '<#> code is 1234\nرمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن منتزر 1234 می باشد\naMo6iFyUQaY', ); }); it('keeps the SMS Retriever markers', () => { const text = build('9876'); expect(text.startsWith('<#> code is ')).toBe(true); expect(text.endsWith('\naMo6iFyUQaY')).toBe(true); expect(text.split('\n')).toHaveLength(3); }); });