mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
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>
214 lines
8.5 KiB
TypeScript
214 lines
8.5 KiB
TypeScript
/**
|
|
* Caller Resolution Service — unit tests
|
|
*
|
|
* QA coverage: TC-IB-05 (lead creation from enquiry),
|
|
* TC-IB-06 (new patient registration), TC-IB-07/08 (AI context)
|
|
*
|
|
* Tests the phone→lead+patient resolution logic:
|
|
* - Existing patient + existing lead → returns both, links if needed
|
|
* - Existing lead, no patient → creates patient, links
|
|
* - Existing patient, no lead → creates lead, links
|
|
* - New caller (neither exists) → creates both
|
|
* - Phone normalization (strips +91, non-digits)
|
|
* - Cache hit/miss behavior
|
|
*/
|
|
import { Test } from '@nestjs/testing';
|
|
import { CallerResolutionService, ResolvedCaller } from './caller-resolution.service';
|
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
|
import { SessionService } from '../auth/session.service';
|
|
|
|
describe('CallerResolutionService', () => {
|
|
let service: CallerResolutionService;
|
|
let platform: jest.Mocked<PlatformGraphqlService>;
|
|
let cache: jest.Mocked<SessionService>;
|
|
|
|
const AUTH = 'Bearer test-token';
|
|
|
|
const existingLead = {
|
|
id: 'lead-001',
|
|
contactName: { firstName: 'Priya', lastName: 'Sharma' },
|
|
contactPhone: { primaryPhoneNumber: '+919949879837' },
|
|
patientId: 'patient-001',
|
|
};
|
|
|
|
const existingPatient = {
|
|
id: 'patient-001',
|
|
fullName: { firstName: 'Priya', lastName: 'Sharma' },
|
|
phones: { primaryPhoneNumber: '+919949879837' },
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const module = await Test.createTestingModule({
|
|
providers: [
|
|
CallerResolutionService,
|
|
{
|
|
provide: PlatformGraphqlService,
|
|
useValue: {
|
|
queryWithAuth: jest.fn(),
|
|
},
|
|
},
|
|
{
|
|
provide: SessionService,
|
|
useValue: {
|
|
getCache: jest.fn().mockResolvedValue(null), // no cache by default
|
|
setCache: jest.fn().mockResolvedValue(undefined),
|
|
},
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get(CallerResolutionService);
|
|
platform = module.get(PlatformGraphqlService);
|
|
cache = module.get(SessionService);
|
|
});
|
|
|
|
// ── TC-IB-05: Existing lead + existing patient ───────────────
|
|
|
|
it('TC-IB-05: should return existing lead+patient when both found by phone', async () => {
|
|
platform.queryWithAuth
|
|
// leads query
|
|
.mockResolvedValueOnce({ leads: { edges: [{ node: existingLead }] } })
|
|
// patients query
|
|
.mockResolvedValueOnce({ patients: { edges: [{ node: existingPatient }] } });
|
|
|
|
const result = await service.resolve('9949879837', AUTH);
|
|
|
|
expect(result.leadId).toBe('lead-001');
|
|
expect(result.patientId).toBe('patient-001');
|
|
expect(result.isNew).toBe(false);
|
|
expect(result.firstName).toBe('Priya');
|
|
});
|
|
|
|
// ── TC-IB-06: New caller → creates both lead + patient ──────
|
|
|
|
it('TC-IB-06: should create both lead+patient for unknown caller', async () => {
|
|
platform.queryWithAuth
|
|
// leads query → empty
|
|
.mockResolvedValueOnce({ leads: { edges: [] } })
|
|
// patients query → empty
|
|
.mockResolvedValueOnce({ patients: { edges: [] } })
|
|
// createPatient
|
|
.mockResolvedValueOnce({ createPatient: { id: 'new-patient-001' } })
|
|
// createLead
|
|
.mockResolvedValueOnce({ createLead: { id: 'new-lead-001' } });
|
|
|
|
const result = await service.resolve('6309248884', AUTH);
|
|
|
|
expect(result.leadId).toBe('new-lead-001');
|
|
expect(result.patientId).toBe('new-patient-001');
|
|
expect(result.isNew).toBe(true);
|
|
});
|
|
|
|
// ── Lead exists, no patient → creates patient ────────────────
|
|
|
|
it('should create patient when lead exists without patient match', async () => {
|
|
const leadNoPatient = { ...existingLead, patientId: null };
|
|
|
|
platform.queryWithAuth
|
|
.mockResolvedValueOnce({ leads: { edges: [{ node: leadNoPatient }] } })
|
|
.mockResolvedValueOnce({ patients: { edges: [] } })
|
|
// createPatient
|
|
.mockResolvedValueOnce({ createPatient: { id: 'new-patient-002' } })
|
|
// linkLeadToPatient (updateLead)
|
|
.mockResolvedValueOnce({ updateLead: { id: 'lead-001' } });
|
|
|
|
const result = await service.resolve('9949879837', AUTH);
|
|
|
|
expect(result.patientId).toBe('new-patient-002');
|
|
expect(result.leadId).toBe('lead-001');
|
|
expect(result.isNew).toBe(false);
|
|
});
|
|
|
|
// ── Patient exists, no lead → creates lead ───────────────────
|
|
|
|
it('should create lead when patient exists without lead match', async () => {
|
|
platform.queryWithAuth
|
|
.mockResolvedValueOnce({ leads: { edges: [] } })
|
|
.mockResolvedValueOnce({ patients: { edges: [{ node: existingPatient }] } })
|
|
// createLead
|
|
.mockResolvedValueOnce({ createLead: { id: 'new-lead-002' } });
|
|
|
|
const result = await service.resolve('9949879837', AUTH);
|
|
|
|
expect(result.leadId).toBe('new-lead-002');
|
|
expect(result.patientId).toBe('patient-001');
|
|
expect(result.isNew).toBe(false);
|
|
});
|
|
|
|
// ── Phone normalization ──────────────────────────────────────
|
|
|
|
it('should normalize phone: strip +91 prefix and non-digits', async () => {
|
|
platform.queryWithAuth
|
|
.mockResolvedValueOnce({ leads: { edges: [] } })
|
|
.mockResolvedValueOnce({ patients: { edges: [] } })
|
|
.mockResolvedValueOnce({ createPatient: { id: 'p' } })
|
|
.mockResolvedValueOnce({ createLead: { id: 'l' } });
|
|
|
|
const result = await service.resolve('+91-994-987-9837', AUTH);
|
|
|
|
expect(result.phone).toBe('9949879837');
|
|
});
|
|
|
|
it('should reject invalid short phone numbers', async () => {
|
|
await expect(service.resolve('12345', AUTH)).rejects.toThrow('Invalid phone');
|
|
});
|
|
|
|
// ── Cache hit ────────────────────────────────────────────────
|
|
|
|
it('should return cached result without hitting platform', async () => {
|
|
const cached: ResolvedCaller = {
|
|
leadId: 'cached-lead',
|
|
patientId: 'cached-patient',
|
|
firstName: 'Cache',
|
|
lastName: 'Hit',
|
|
phone: '9949879837',
|
|
isNew: false,
|
|
};
|
|
cache.getCache.mockResolvedValueOnce(JSON.stringify(cached));
|
|
|
|
const result = await service.resolve('9949879837', AUTH);
|
|
|
|
expect(result).toEqual(cached);
|
|
expect(platform.queryWithAuth).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// ── Cache write ──────────────────────────────────────────────
|
|
|
|
it('should cache result after successful resolve', async () => {
|
|
platform.queryWithAuth
|
|
.mockResolvedValueOnce({ leads: { edges: [{ node: existingLead }] } })
|
|
.mockResolvedValueOnce({ patients: { edges: [{ node: existingPatient }] } });
|
|
|
|
await service.resolve('9949879837', AUTH);
|
|
|
|
expect(cache.setCache).toHaveBeenCalledWith(
|
|
'caller:9949879837',
|
|
expect.any(String),
|
|
3600,
|
|
);
|
|
});
|
|
|
|
// ── Links unlinked lead to patient ───────────────────────────
|
|
|
|
it('should link lead to patient when both exist but are unlinked', async () => {
|
|
const unlinkedLead = { ...existingLead, patientId: null };
|
|
|
|
platform.queryWithAuth
|
|
.mockResolvedValueOnce({ leads: { edges: [{ node: unlinkedLead }] } })
|
|
.mockResolvedValueOnce({ patients: { edges: [{ node: existingPatient }] } })
|
|
// updateLead to link
|
|
.mockResolvedValueOnce({ updateLead: { id: 'lead-001' } });
|
|
|
|
const result = await service.resolve('9949879837', AUTH);
|
|
|
|
expect(result.leadId).toBe('lead-001');
|
|
expect(result.patientId).toBe('patient-001');
|
|
|
|
// Verify the link mutation was called
|
|
const linkCall = platform.queryWithAuth.mock.calls.find(
|
|
c => typeof c[0] === 'string' && c[0].includes('updateLead'),
|
|
);
|
|
expect(linkCall).toBeDefined();
|
|
});
|
|
});
|