mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
test: unit tests for Ozonetel integration, caller resolution, team, missed calls
48 tests across 5 new spec files, all passing in <1s: - ozonetel-agent.service.spec: agent auth (login/logout/retry), manual dial, set disposition, change state, token caching (10 tests) - missed-call-webhook.spec: webhook payload parsing, IST→UTC conversion, duration parsing, CallerID handling, JSON-wrapped body (9 tests) - missed-queue.spec: abandon call polling, PENDING_CALLBACK status, UCID dedup, phone normalization, istToUtc utility (8 tests) - caller-resolution.spec: phone→lead+patient resolution (4 paths: both exist, lead only, patient only, neither), caching, phone normalization, link-if-unlinked (9 tests) - team.spec: 5-step member creation flow, SIP seat linking, validation, temp password Redis cache, email normalization, workspace context caching (8 tests) Fixtures: ozonetel-payloads.ts with accurate Ozonetel API shapes from official docs — webhook payloads, CDR records, abandon calls, disposition responses, auth responses. QA coverage: TC-MC-01/02/03, TC-IB-05/06/07, backs TC-IB/OB-01→06 via the Ozonetel service layer. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
269
src/ozonetel/ozonetel-agent.service.spec.ts
Normal file
269
src/ozonetel/ozonetel-agent.service.spec.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* 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<typeof axios>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user