Files
helix-engage-server/src/worklist/missed-queue.spec.ts
saridsa2 96d0c32000 fix: skip outbound calls in webhook + filter abandon polls by campaign
Webhook controller now skips outbound calls (type=Manual/OutBound).
An unanswered outbound dial is NOT a missed inbound call — it was
being incorrectly created as MISSED with PENDING_CALLBACK status.

MissedQueueService now filters the Ozonetel abandonCalls API response
by campaign name (read from TelephonyConfigService). Prevents
cross-tenant ingestion when multiple sidecars share the same
Ozonetel account.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:09:17 +05:30

179 lines
7.0 KiB
TypeScript

/**
* Missed Queue Service — unit tests
*
* QA coverage: TC-MC-03 (missed calls appear in pending section)
*
* Tests the abandon call polling + ingestion logic:
* - Fetches from Ozonetel getAbandonCalls
* - Deduplicates by UCID
* - Creates Call records with callbackstatus=PENDING_CALLBACK
* - Normalizes phone numbers
* - Converts IST→UTC timestamps
*/
import { Test } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { MissedQueueService, istToUtc, normalizePhone } from './missed-queue.service';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
import { TelephonyConfigService } from '../config/telephony-config.service';
import { ABANDON_CALL_RECORD } from '../__fixtures__/ozonetel-payloads';
describe('MissedQueueService', () => {
let service: MissedQueueService;
let platform: jest.Mocked<PlatformGraphqlService>;
let ozonetel: jest.Mocked<OzonetelAgentService>;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
MissedQueueService,
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string, defaultVal?: any) => {
if (key === 'missedQueue.pollIntervalMs') return 999999; // don't actually poll
if (key === 'platform.apiKey') return 'test-key';
return defaultVal;
}),
},
},
{
provide: PlatformGraphqlService,
useValue: {
query: jest.fn().mockResolvedValue({}),
queryWithAuth: jest.fn().mockImplementation((query: string) => {
if (query.includes('createCall')) {
return Promise.resolve({ createCall: { id: 'call-missed-001' } });
}
if (query.includes('calls')) {
return Promise.resolve({ calls: { edges: [] } }); // no existing calls
}
return Promise.resolve({});
}),
},
},
{
provide: OzonetelAgentService,
useValue: {
getAbandonCalls: jest.fn().mockResolvedValue([ABANDON_CALL_RECORD]),
},
},
{
provide: TelephonyConfigService,
useValue: {
getConfig: () => ({
ozonetel: { campaignName: 'Inbound_918041763400', agentId: '', agentPassword: '', did: '918041763400', sipId: '' },
sip: { domain: 'test', wsPort: '444' },
exotel: { apiKey: '', accountSid: '', subdomain: '' },
}),
},
},
],
}).compile();
service = module.get(MissedQueueService);
platform = module.get(PlatformGraphqlService);
ozonetel = module.get(OzonetelAgentService);
});
// ── Utility functions ────────────────────────────────────────
describe('istToUtc', () => {
it('should subtract 5:30 from a valid IST timestamp', () => {
const result = istToUtc('2026-04-09 14:30:00');
expect(result).toBeDefined();
const d = new Date(result!);
expect(d.toISOString()).toBeTruthy();
});
it('should return null for null input', () => {
expect(istToUtc(null)).toBeNull();
});
it('should return null for invalid date string', () => {
expect(istToUtc('not-a-date')).toBeNull();
});
});
describe('normalizePhone', () => {
it('should strip +91 and format to +91XXXXXXXXXX', () => {
expect(normalizePhone('+919949879837')).toBe('+919949879837');
});
it('should strip 0091 prefix', () => {
expect(normalizePhone('00919949879837')).toBe('+919949879837');
});
it('should strip leading 0', () => {
expect(normalizePhone('09949879837')).toBe('+919949879837');
});
it('should handle raw 10-digit number', () => {
expect(normalizePhone('9949879837')).toBe('+919949879837');
});
it('should strip non-digits', () => {
expect(normalizePhone('+91-994-987-9837')).toBe('+919949879837');
});
});
// ── Ingestion ────────────────────────────────────────────────
describe('ingest', () => {
it('TC-MC-03: should create MISSED call with PENDING_CALLBACK status', async () => {
const result = await service.ingest();
expect(result.created).toBeGreaterThanOrEqual(0);
// Verify createCall was called
const createCalls = platform.queryWithAuth.mock.calls.filter(
c => typeof c[0] === 'string' && c[0].includes('createCall'),
);
if (createCalls.length > 0) {
const data = createCalls[0][1]?.data;
expect(data).toMatchObject(
expect.objectContaining({
callStatus: 'MISSED',
direction: 'INBOUND',
callbackstatus: 'PENDING_CALLBACK',
}),
);
}
});
it('should deduplicate by UCID on second ingest', async () => {
await service.ingest();
const firstCallCount = platform.queryWithAuth.mock.calls.filter(
c => typeof c[0] === 'string' && c[0].includes('createCall'),
).length;
// Same UCID on second ingest
await service.ingest();
const secondCallCount = platform.queryWithAuth.mock.calls.filter(
c => typeof c[0] === 'string' && c[0].includes('createCall'),
).length;
// Should not create duplicate — count stays the same
expect(secondCallCount).toBe(firstCallCount);
});
it('should handle empty abandon list gracefully', async () => {
ozonetel.getAbandonCalls.mockResolvedValueOnce([]);
const result = await service.ingest();
expect(result.created).toBe(0);
expect(result.updated).toBe(0);
});
it('should handle Ozonetel API failure gracefully', async () => {
ozonetel.getAbandonCalls.mockRejectedValueOnce(new Error('API timeout'));
const result = await service.ingest();
expect(result.created).toBe(0);
});
});
});