import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' // Imported lazily after env stubs so the config getter reads the test values. let fazpassRequestOtp let fazpassVerifyOtp let FazpassError const setFazpassEnv = () => { vi.stubEnv('FAZPASS_ENABLED', 'true') vi.stubEnv('FAZPASS_BASE_URL', 'https://api.fazpass.example') vi.stubEnv('FAZPASS_MERCHANT_KEY', 'test-merchant-key') vi.stubEnv('FAZPASS_GATEWAY_KEY', 'test-gateway-key') vi.stubEnv('FAZPASS_TIMEOUT_MS', '5000') } beforeEach(async () => { setFazpassEnv() // Re-import so the module's top-level closures use the stubbed env. // getFazpassConfig reads process.env at call time so this is mostly a safety // belt — but it also ensures the test isn't depending on import order. const mod = await import('../../src/services/fazpass.service.js') fazpassRequestOtp = mod.fazpassRequestOtp fazpassVerifyOtp = mod.fazpassVerifyOtp FazpassError = mod.FazpassError }) afterEach(() => { vi.restoreAllMocks() vi.unstubAllEnvs() }) const mockFetch = (impl) => vi.spyOn(globalThis, 'fetch').mockImplementation(impl) const jsonResponse = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }) describe('fazpassRequestOtp', () => { it('POSTs phone + gateway_key with Bearer auth and returns reference', async () => { let captured mockFetch(async (url, init) => { captured = { url, init } return jsonResponse(200, { status: true, message: 'Request generated successfully', code: '2000200', data: { id: 'abc-123', otp: 'XXXXXX', otp_length: 6, channel: 'WA_GENERIC_OTP', provider: 'Fazpass', purpose: 'testing', }, }) }) const result = await fazpassRequestOtp({ phone: '+628111' }) expect(result).toEqual({ reference: 'abc-123', channel_used: 'WA_GENERIC_OTP', provider: 'Fazpass', }) expect(captured.url).toBe('https://api.fazpass.example/v1/otp/request') expect(captured.init.method).toBe('POST') expect(captured.init.headers.Authorization).toBe('Bearer test-merchant-key') expect(captured.init.headers['Content-Type']).toBe('application/json') expect(JSON.parse(captured.init.body)).toEqual({ phone: '+628111', gateway_key: 'test-gateway-key', }) }) it('throws FazpassError on non-2xx with provider code surfaced', async () => { mockFetch(async () => jsonResponse(400, { status: false, code: '4000400', message: 'bad gateway_key' })) await expect(fazpassRequestOtp({ phone: '+628111' })) .rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', statusCode: 502, httpStatus: 400, providerCode: '4000400', providerMessage: 'bad gateway_key', }) }) it('throws FazpassError when 2xx body has status:false', async () => { mockFetch(async () => jsonResponse(200, { status: false, code: '5000500', message: 'gateway down' })) await expect(fazpassRequestOtp({ phone: '+628111' })) .rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', providerCode: '5000500' }) }) it('throws FazpassError when 2xx body is missing data.id', async () => { mockFetch(async () => jsonResponse(200, { status: true, data: { otp: 'XXXXXX' } })) await expect(fazpassRequestOtp({ phone: '+628111' })) .rejects.toBeInstanceOf(FazpassError) }) it('throws FazpassError on transport / timeout error', async () => { mockFetch(async () => { throw new Error('network down') }) await expect(fazpassRequestOtp({ phone: '+628111' })) .rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', httpStatus: null }) }) it('throws when FAZPASS_ENABLED is false', async () => { vi.stubEnv('FAZPASS_ENABLED', 'false') await expect(fazpassRequestOtp({ phone: '+628111' })) .rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED' }) }) it('throws when merchantKey or gatewayKey are blank', async () => { vi.stubEnv('FAZPASS_MERCHANT_KEY', '') await expect(fazpassRequestOtp({ phone: '+628111' })) .rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED' }) }) }) describe('fazpassVerifyOtp', () => { it('POSTs otp_id + otp with Bearer auth and returns valid:true on status:true', async () => { let captured mockFetch(async (url, init) => { captured = { url, init } return jsonResponse(200, { status: true, message: 'Validate otp successfully', code: '2000200', }) }) const result = await fazpassVerifyOtp({ reference: 'abc-123', code: '424242' }) expect(result).toEqual({ valid: true, providerCode: '2000200', providerMessage: 'Validate otp successfully', }) expect(captured.url).toBe('https://api.fazpass.example/v1/otp/verify') expect(JSON.parse(captured.init.body)).toEqual({ otp_id: 'abc-123', otp: '424242', }) }) it('returns valid:false on 2xx + status:false (the "wrong OTP" path)', async () => { mockFetch(async () => jsonResponse(200, { status: false, message: 'Invalid OTP', code: '4000401', })) const result = await fazpassVerifyOtp({ reference: 'abc-123', code: '000000' }) expect(result.valid).toBe(false) expect(result.providerCode).toBe('4000401') }) it('throws FazpassError on non-2xx (provider outage)', async () => { mockFetch(async () => jsonResponse(503, { status: false, code: '5030503', message: 'gateway unavailable' })) await expect(fazpassVerifyOtp({ reference: 'abc-123', code: '000000' })) .rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', httpStatus: 503, providerCode: '5030503', }) }) it('throws FazpassError on malformed body (no status field)', async () => { mockFetch(async () => new Response('not json', { status: 200, headers: { 'Content-Type': 'text/plain' } })) await expect(fazpassVerifyOtp({ reference: 'abc-123', code: '000000' })) .rejects.toBeInstanceOf(FazpassError) }) it('throws FazpassError on network error', async () => { mockFetch(async () => { throw new Error('connection reset') }) await expect(fazpassVerifyOtp({ reference: 'abc-123', code: '000000' })) .rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED' }) }) })