/** * Ozonetel Agent Service — unit tests * * QA coverage: agent auth (login/logout), manual dial, set disposition, * change agent state, call control. Covers the Ozonetel HTTP layer that * backs TC-IB-01→06, TC-OB-01→06, TC-FU-01→02 via disposition flows. * * axios is mocked — no real HTTP to Ozonetel. */ import { Test } from '@nestjs/testing'; import { OzonetelAgentService } from './ozonetel-agent.service'; import { TelephonyConfigService } from '../config/telephony-config.service'; import axios from 'axios'; import { AGENT_AUTH_LOGIN_SUCCESS, AGENT_AUTH_LOGIN_ALREADY, AGENT_AUTH_LOGOUT_SUCCESS, AGENT_AUTH_INVALID, DISPOSITION_SET_DURING_CALL, DISPOSITION_SET_AFTER_CALL, DISPOSITION_INVALID_UCID, } from '../__fixtures__/ozonetel-payloads'; jest.mock('axios'); const mockedAxios = axios as jest.Mocked; describe('OzonetelAgentService', () => { let service: OzonetelAgentService; const mockTelephonyConfig = { exotel: { apiKey: 'KK_TEST_KEY', accountSid: 'test_account', subdomain: 'in1-ccaas-api.ozonetel.com', }, ozonetel: { agentId: 'global', agentPassword: 'Test123$', sipId: '523590', campaignName: 'Inbound_918041763400', did: '918041763400', }, sip: { domain: 'blr-pub-rtc4.ozonetel.com', wsPort: '444' }, }; beforeEach(async () => { jest.clearAllMocks(); // Mock token generation (needed before most API calls) mockedAxios.post.mockImplementation(async (url: string, data?: any) => { if (url.includes('generateToken')) { return { data: { token: 'mock-bearer-token', status: 'success' } }; } return { data: {} }; }); const module = await Test.createTestingModule({ providers: [ OzonetelAgentService, { provide: TelephonyConfigService, useValue: { getConfig: () => mockTelephonyConfig }, }, ], }).compile(); service = module.get(OzonetelAgentService); }); // ── Agent Login ────────────────────────────────────────────── describe('loginAgent', () => { it('should send correct params to Ozonetel auth endpoint', async () => { mockedAxios.post.mockResolvedValueOnce({ data: AGENT_AUTH_LOGIN_SUCCESS }); const result = await service.loginAgent({ agentId: 'global', password: 'Test123$', phoneNumber: '523590', mode: 'blended', }); expect(result).toEqual(AGENT_AUTH_LOGIN_SUCCESS); const authCall = mockedAxios.post.mock.calls[0]; expect(authCall[0]).toContain('AgentAuthenticationV2'); // Body is URLSearchParams string expect(authCall[1]).toContain('userName=test_account'); expect(authCall[1]).toContain('apiKey=KK_TEST_KEY'); expect(authCall[1]).toContain('phoneNumber=523590'); expect(authCall[1]).toContain('action=login'); expect(authCall[1]).toContain('mode=blended'); // Basic auth expect(authCall[2]?.auth).toEqual({ username: 'global', password: 'Test123$' }); }); it('should auto-retry on "already logged in" response', async () => { // First call: already logged in mockedAxios.post .mockResolvedValueOnce({ data: AGENT_AUTH_LOGIN_ALREADY }) // Logout call .mockResolvedValueOnce({ data: AGENT_AUTH_LOGOUT_SUCCESS }) // Re-login call .mockResolvedValueOnce({ data: AGENT_AUTH_LOGIN_SUCCESS }); const result = await service.loginAgent({ agentId: 'global', password: 'Test123$', phoneNumber: '523590', }); // Should have made 3 calls: login, logout, re-login expect(mockedAxios.post).toHaveBeenCalledTimes(3); expect(result).toEqual(AGENT_AUTH_LOGIN_SUCCESS); }); it('should throw on invalid authentication', async () => { mockedAxios.post.mockRejectedValueOnce({ response: { status: 401, data: AGENT_AUTH_INVALID }, }); await expect( service.loginAgent({ agentId: 'bad', password: 'wrong', phoneNumber: '000' }), ).rejects.toBeDefined(); }); }); // ── Agent Logout ───────────────────────────────────────────── describe('logoutAgent', () => { it('should send logout action', async () => { mockedAxios.post.mockResolvedValueOnce({ data: AGENT_AUTH_LOGOUT_SUCCESS }); const result = await service.logoutAgent({ agentId: 'global', password: 'Test123$' }); expect(result).toEqual(AGENT_AUTH_LOGOUT_SUCCESS); const call = mockedAxios.post.mock.calls[0]; expect(call[1]).toContain('action=logout'); }); }); // ── Manual Dial ────────────────────────────────────────────── describe('manualDial', () => { it('should send correct params with bearer token', async () => { // First call: token generation, second: manual dial mockedAxios.post .mockResolvedValueOnce({ data: { token: 'mock-token', status: 'success' } }) .mockResolvedValueOnce({ data: { status: 'success', ucid: '31712345678901234', message: 'Call initiated' }, }); const result = await service.manualDial({ agentId: 'global', campaignName: 'Inbound_918041763400', customerNumber: '9949879837', }); expect(result).toEqual(expect.objectContaining({ status: 'success' })); // The dial call (second post) const dialCall = mockedAxios.post.mock.calls[1]; expect(dialCall[0]).toContain('AgentManualDial'); expect(dialCall[1]).toMatchObject({ userName: 'test_account', agentID: 'global', campaignName: 'Inbound_918041763400', customerNumber: '9949879837', }); expect(dialCall[2]?.headers?.Authorization).toBe('Bearer mock-token'); }); }); // ── Set Disposition ────────────────────────────────────────── describe('setDisposition', () => { it('should send disposition with correct fields', async () => { mockedAxios.post .mockResolvedValueOnce({ data: { token: 'mock-token', status: 'success' } }) .mockResolvedValueOnce({ data: DISPOSITION_SET_AFTER_CALL }); const result = await service.setDisposition({ agentId: 'global', ucid: '31712345678901234', disposition: 'General Enquiry', }); expect(result).toEqual(DISPOSITION_SET_AFTER_CALL); const dispCall = mockedAxios.post.mock.calls[1]; expect(dispCall[0]).toContain('DispositionAPIV2'); expect(dispCall[1]).toMatchObject({ userName: 'test_account', agentID: 'global', ucid: '31712345678901234', action: 'Set', disposition: 'General Enquiry', did: '918041763400', autoRelease: 'true', }); }); it('should handle queued disposition (during call)', async () => { mockedAxios.post .mockResolvedValueOnce({ data: { token: 'mock-token', status: 'success' } }) .mockResolvedValueOnce({ data: DISPOSITION_SET_DURING_CALL }); const result = await service.setDisposition({ agentId: 'global', ucid: '31712345678901234', disposition: 'Appointment Booked', }); expect(result.status).toBe('Success'); expect(result.message).toContain('Queued'); }); }); // ── Change Agent State ─────────────────────────────────────── describe('changeAgentState', () => { it('should send Ready state', async () => { mockedAxios.post .mockResolvedValueOnce({ data: { token: 'mock-token', status: 'success' } }) .mockResolvedValueOnce({ data: { status: 'success', message: 'State changed' } }); await service.changeAgentState({ agentId: 'global', state: 'Ready' }); const stateCall = mockedAxios.post.mock.calls[1]; expect(stateCall[0]).toContain('changeAgentState'); expect(stateCall[1]).toMatchObject({ userName: 'test_account', agentId: 'global', state: 'Ready', }); }); it('should include pauseReason when pausing', async () => { mockedAxios.post .mockResolvedValueOnce({ data: { token: 'mock-token', status: 'success' } }) .mockResolvedValueOnce({ data: { status: 'success', message: 'Agent paused' } }); await service.changeAgentState({ agentId: 'global', state: 'Pause', pauseReason: 'Break' }); const stateCall = mockedAxios.post.mock.calls[1]; expect(stateCall[1]).toMatchObject({ pauseReason: 'Break' }); }); }); // ── Token caching ──────────────────────────────────────────── describe('token management', () => { it('should cache token and reuse for subsequent calls', async () => { // First call generates token mockedAxios.post .mockResolvedValueOnce({ data: { token: 'cached-token', status: 'success' } }) .mockResolvedValueOnce({ data: { status: 'success' } }) // Second API call should reuse token .mockResolvedValueOnce({ data: { status: 'success' } }); await service.manualDial({ agentId: 'a', campaignName: 'c', customerNumber: '1' }); await service.manualDial({ agentId: 'a', campaignName: 'c', customerNumber: '2' }); // Token generation should only be called once const tokenCalls = mockedAxios.post.mock.calls.filter(c => c[0].includes('generateToken')); expect(tokenCalls.length).toBe(1); }); }); });