mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Inbound transferred calls arrive with AgentName like 'RamaiahAdmin -> GlobalHealthX'. The webhook was persisting the raw chain string and leaving agentId null; the CDR enrichment cron then silently skipped 100% of rows because the bulk CDR keys on caller-leg UCID while the webhook stores monitorUCID — the join never matched. - missed-call-webhook: split chain on ' -> ', take final handler, resolve via AgentLookupService (ozonetelAgentId + display name) - cdr-enrichment: index CDR rows by both UCID and monitorUCID so the cron actually patches historical rows - enrichment also parses chain in CDR AgentName as a second fallback - spec: add CallerResolutionService + AgentLookupService mocks
213 lines
9.3 KiB
TypeScript
213 lines
9.3 KiB
TypeScript
/**
|
|
* Missed Call Webhook — unit tests
|
|
*
|
|
* QA coverage: TC-MC-01, TC-MC-02, TC-MC-03
|
|
*
|
|
* Tests verify that Ozonetel webhook payloads are correctly parsed and
|
|
* transformed into platform Call records via GraphQL mutations. The
|
|
* platform GraphQL client is mocked — no real HTTP or database calls.
|
|
*/
|
|
import { Test } from '@nestjs/testing';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
|
import { AgentLookupService } from '../platform/agent-lookup.service';
|
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
|
import {
|
|
WEBHOOK_INBOUND_ANSWERED,
|
|
WEBHOOK_INBOUND_MISSED,
|
|
WEBHOOK_OUTBOUND_ANSWERED,
|
|
WEBHOOK_OUTBOUND_NO_ANSWER,
|
|
} from '../__fixtures__/ozonetel-payloads';
|
|
|
|
describe('MissedCallWebhookController', () => {
|
|
let controller: MissedCallWebhookController;
|
|
let platformGql: jest.Mocked<PlatformGraphqlService>;
|
|
|
|
beforeEach(async () => {
|
|
const mockPlatformGql = {
|
|
query: jest.fn().mockResolvedValue({}),
|
|
queryWithAuth: jest.fn().mockImplementation((query: string) => {
|
|
// createCall → return an id
|
|
if (query.includes('createCall')) return Promise.resolve({ createCall: { id: 'test-call-id' } });
|
|
// leads query → return empty (no matching lead)
|
|
if (query.includes('leads')) return Promise.resolve({ leads: { edges: [] } });
|
|
// createLeadActivity → return id
|
|
if (query.includes('createLeadActivity')) return Promise.resolve({ createLeadActivity: { id: 'test-activity-id' } });
|
|
// updateCall → return id
|
|
if (query.includes('updateCall')) return Promise.resolve({ updateCall: { id: 'test-call-id' } });
|
|
// updateLead → return id
|
|
if (query.includes('updateLead')) return Promise.resolve({ updateLead: { id: 'test-lead-id' } });
|
|
return Promise.resolve({});
|
|
}),
|
|
};
|
|
|
|
const mockConfig = {
|
|
get: jest.fn((key: string) => {
|
|
if (key === 'platform.apiKey') return 'test-api-key';
|
|
if (key === 'platform.graphqlUrl') return 'http://localhost:4000/graphql';
|
|
return undefined;
|
|
}),
|
|
};
|
|
|
|
const mockCaller = {
|
|
resolve: jest.fn().mockResolvedValue({
|
|
leadId: '',
|
|
firstName: '',
|
|
lastName: '',
|
|
patientId: '',
|
|
isNew: true,
|
|
}),
|
|
};
|
|
|
|
const mockAgentLookup = {
|
|
resolveByOzonetelId: jest.fn().mockResolvedValue(null),
|
|
resolveByDisplayName: jest.fn().mockResolvedValue(null),
|
|
};
|
|
|
|
const module = await Test.createTestingModule({
|
|
controllers: [MissedCallWebhookController],
|
|
providers: [
|
|
{ provide: PlatformGraphqlService, useValue: mockPlatformGql },
|
|
{ provide: ConfigService, useValue: mockConfig },
|
|
{ provide: CallerResolutionService, useValue: mockCaller },
|
|
{ provide: AgentLookupService, useValue: mockAgentLookup },
|
|
],
|
|
}).compile();
|
|
|
|
controller = module.get(MissedCallWebhookController);
|
|
platformGql = module.get(PlatformGraphqlService);
|
|
});
|
|
|
|
// ── TC-MC-01: Inbound missed call logged ─────────────────────
|
|
|
|
it('TC-MC-01: should create a MISSED INBOUND call record from webhook', async () => {
|
|
const result = await controller.handleCallWebhook(WEBHOOK_INBOUND_MISSED);
|
|
|
|
expect(result).toEqual(expect.objectContaining({ received: true, processed: true }));
|
|
expect(platformGql.queryWithAuth).toHaveBeenCalled();
|
|
|
|
const mutationCall = platformGql.queryWithAuth.mock.calls.find(
|
|
(c) => typeof c[0] === 'string' && c[0].includes('createCall'),
|
|
);
|
|
expect(mutationCall).toBeDefined();
|
|
|
|
// Verify the mutation variables contain correct mapped values
|
|
const variables = mutationCall![1];
|
|
expect(variables).toMatchObject({
|
|
data: expect.objectContaining({
|
|
callStatus: 'MISSED',
|
|
direction: 'INBOUND',
|
|
}),
|
|
});
|
|
});
|
|
|
|
// ── TC-MC-02: Outbound unanswered call logged ────────────────
|
|
|
|
it('TC-MC-02: outbound unanswered call — skipped if no CallerID (by design)', async () => {
|
|
// Ozonetel outbound webhooks have empty CallerID — the controller
|
|
// skips processing when CallerID is blank. This is correct behavior:
|
|
// outbound calls are tracked via the CDR polling, not the webhook.
|
|
const result = await controller.handleCallWebhook(WEBHOOK_OUTBOUND_NO_ANSWER);
|
|
expect(result).toEqual({ received: true, processed: false });
|
|
});
|
|
|
|
// ── Inbound answered call logged correctly ───────────────────
|
|
|
|
it('should create a COMPLETED INBOUND call record', async () => {
|
|
const result = await controller.handleCallWebhook(WEBHOOK_INBOUND_ANSWERED);
|
|
|
|
expect(result).toEqual(expect.objectContaining({ received: true, processed: true }));
|
|
|
|
const mutationCall = platformGql.queryWithAuth.mock.calls.find(
|
|
(c) => typeof c[0] === 'string' && c[0].includes('createCall'),
|
|
);
|
|
expect(mutationCall).toBeDefined();
|
|
|
|
const variables = mutationCall![1];
|
|
expect(variables).toMatchObject({
|
|
data: expect.objectContaining({
|
|
callStatus: 'COMPLETED',
|
|
direction: 'INBOUND',
|
|
agentName: 'global',
|
|
}),
|
|
});
|
|
});
|
|
|
|
// ── Outbound answered call logged correctly ──────────────────
|
|
|
|
it('should skip outbound answered webhook with empty CallerID', async () => {
|
|
// Same as TC-MC-02: outbound calls have no CallerID in the webhook
|
|
const result = await controller.handleCallWebhook(WEBHOOK_OUTBOUND_ANSWERED);
|
|
expect(result).toEqual({ received: true, processed: false });
|
|
});
|
|
|
|
// ── Duration parsing ─────────────────────────────────────────
|
|
|
|
it('should parse Ozonetel HH:MM:SS duration to seconds', async () => {
|
|
await controller.handleCallWebhook(WEBHOOK_INBOUND_ANSWERED);
|
|
|
|
const mutationCall = platformGql.queryWithAuth.mock.calls.find(
|
|
(c) => typeof c[0] === 'string' && c[0].includes('createCall'),
|
|
);
|
|
const data = mutationCall![1]?.data;
|
|
// "00:04:00" → 240 seconds
|
|
expect(data?.durationSec).toBe(240);
|
|
});
|
|
|
|
// ── CallerID stripping (+91 prefix) ──────────────────────────
|
|
|
|
it('should strip +91 prefix from CallerID', async () => {
|
|
const payload = { ...WEBHOOK_INBOUND_ANSWERED, CallerID: '+919949879837' };
|
|
await controller.handleCallWebhook(payload);
|
|
|
|
const mutationCall = platformGql.queryWithAuth.mock.calls.find(
|
|
(c) => typeof c[0] === 'string' && c[0].includes('createCall'),
|
|
);
|
|
const data = mutationCall![1]?.data;
|
|
// Controller adds +91 prefix when storing
|
|
expect(data?.callerNumber?.primaryPhoneNumber).toBe('+919949879837');
|
|
});
|
|
|
|
// ── Empty CallerID skipped ───────────────────────────────────
|
|
|
|
it('should skip processing if no CallerID in webhook', async () => {
|
|
const payload = { ...WEBHOOK_INBOUND_MISSED, CallerID: '' };
|
|
const result = await controller.handleCallWebhook(payload);
|
|
|
|
expect(result).toEqual({ received: true, processed: false });
|
|
});
|
|
|
|
// ── IST → UTC timestamp conversion ───────────────────────────
|
|
|
|
it('should convert IST timestamps to UTC (subtract 5:30)', async () => {
|
|
await controller.handleCallWebhook(WEBHOOK_INBOUND_ANSWERED);
|
|
|
|
const mutationCall = platformGql.queryWithAuth.mock.calls.find(
|
|
(c) => typeof c[0] === 'string' && c[0].includes('createCall'),
|
|
);
|
|
const data = mutationCall![1]?.data;
|
|
|
|
// The istToUtc function subtracts 5:30 from the parsed date.
|
|
// Input: "2026-04-09 14:30:00" (treated as local by Date constructor,
|
|
// then shifted by -330 min). Verify the output is an ISO string
|
|
// that's 5:30 earlier than what Date would parse natively.
|
|
expect(data?.startedAt).toBeDefined();
|
|
expect(typeof data?.startedAt).toBe('string');
|
|
// Just verify it's a valid ISO timestamp (the exact hour depends
|
|
// on how the test machine's TZ interacts with the naive parse)
|
|
expect(new Date(data.startedAt).toISOString()).toBe(data.startedAt);
|
|
});
|
|
|
|
// ── Handles JSON-string-wrapped body (Ozonetel quirk) ────────
|
|
|
|
it('should handle payload wrapped in a "data" JSON string', async () => {
|
|
const wrappedPayload = {
|
|
data: JSON.stringify(WEBHOOK_INBOUND_MISSED),
|
|
};
|
|
const result = await controller.handleCallWebhook(wrappedPayload);
|
|
|
|
expect(result).toEqual(expect.objectContaining({ received: true, processed: true }));
|
|
});
|
|
});
|