import { describe, expect, it } from 'vitest'; import { analyticsRates, foldAnalytics, projectAnalytics, type AnalyticsEvent, type AnalyticsCall } from './caller-analytics-model.js'; const start = Date.UTC(2026, 7, 31, 0); const event = (kind: AnalyticsEvent['kind'], extra: Partial = {}): AnalyticsEvent => ({ eventId: kind, callUid: 'u1', callId: 'c1', attemptId: '1', kind, at: start + 1000, startedAt: start, customerId: 'c', customerGatewayId: 'cg', vendorId: 'v', vendorGatewayId: 'vg', caller: '123', landingCaller: '456', callee: '789', city: 'city', carrier: 'UNKNOWN', code: 0, talkMs: null, source: 'opensips', sequence: 0, ...extra }); function run(events: AnalyticsEvent[]) { return projectAnalytics(events.reduce((c, e) => foldAnalytics(c, e), null)!); } describe('caller analytics business state', () => { it.each([180,183])('counts %i as connected even if final call fails', code => { const [r] = run([event('ATTEMPT'), event('PROGRESS', { code }), event('END', { code: 487, source: 'verified-dialog-final' })]); expect(analyticsRates(r)).toMatchObject({ totalCalls: 1, connectedCalls: 1, answeredCalls: 0, failedCalls: 0, notConnectedCalls: 0, connectionRate: 100, overallAnswerRate: 0, connectedAnswerRate: 0, durationUnknownCalls:0 }); }); it.each([100,181,182])('does not treat %i as connected', code => { const [r] = run([event('ATTEMPT'), event('PROGRESS', { code }), event('END', { code: 486, source: 'verified-dialog-final' })]); expect(analyticsRates(r)).toMatchObject({ connectedCalls: 0, failedCalls: 1, connectedAnswerRate: null }); }); it('counts positive sub-second duration, not just 200', () => { const [zero] = run([event('ATTEMPT'), event('ACCEPTED', { code: 200 })]); expect(zero.answeredCalls).toBe(0); const [positive] = run([event('ATTEMPT'), event('ACCEPTED', { code: 200 }), event('TALK_START', { at:start+1100, source:'dialog-acked' }), event('END', { at: start + 1500, source: 'verified-dialog-final' })]); expect(positive).toMatchObject({ connectedCalls: 1, answeredCalls: 1, talkMs: 400 }); }); it('preserves zero actual duration', () => { const [r] = run([event('ATTEMPT'), event('ACCEPTED', { code: 200 }), event('END', { talkMs: 0, source: 'verified-dialog-final' })]); expect(r.answeredCalls).toBe(0); }); it('is insensitive to duplicate progress and delayed attempt', () => { const e = event('PROGRESS', { code: 183 }); const [r] = run([e, e, event('END', { source: 'verified-dialog-final', code: 487 }), event('ATTEMPT')]); expect(r).toMatchObject({ totalCalls: 1, connectedCalls: 1, answeredCalls: 0, activeCalls: 0 }); }); it('corrects provisional duration and rejects late provisional overwrite', () => { const [r] = run([event('ATTEMPT'), event('ACCEPTED', { code: 200 }), event('DURATION', { at: start + 2000, talkMs: 1000 }), event('RECONCILE', { at: start + 3000, talkMs: 0, source: 'verified-dialog-final' }), event('DURATION', { at: start + 4000, talkMs: 3000 })]); expect(r).toMatchObject({ connectedCalls: 1, answeredCalls: 0, talkMs: 0 }); }); it('keeps original call separate from multiple landing attempts', () => { const rows = run([event('ATTEMPT'), event('END', { code: 486 }), event('ATTEMPT', { attemptId: '2' }), event('PROGRESS', { attemptId: '2', code: 180 })]); expect(rows).toHaveLength(3); expect(rows[0]).toMatchObject({ totalCalls: 1, connectedCalls: 1 }); expect(rows.slice(1).reduce((n,r) => n+r.totalCalls,0)).toBe(2); }); it('distinguishes pending and unknown', () => { expect(run([event('ATTEMPT')])[0]).toMatchObject({ pendingCalls: 1, failedCalls: 0 }); expect(run([event('UNKNOWN')])[0]).toMatchObject({ unknownCalls: 1, failedCalls: 0, pendingCalls: 0 }); }); it('does not merge different customer identities', () => { expect(() => run([event('ATTEMPT'), event('PROGRESS', { customerId: 'other', code: 180 })])).toThrow('Conflicting'); }); it('calculates the three different business rates', () => { expect(analyticsRates({ totalCalls:100, connectedCalls:60, answeredCalls:30, failedCalls:25, pendingCalls:15, unknownCalls:0, activeCalls:15, talkMs:0, durationUnknownCalls:0, provisionalAnsweredCalls:0 })).toMatchObject({ notConnectedCalls:40, connectionRate:60, overallAnswerRate:30, connectedAnswerRate:50 }); }); it('does not erase missing progress evidence with an end event', () => { expect(run([event('END',{code:487,source:'verified-dialog-final'})])[0]).toMatchObject({unknownCalls:1,failedCalls:0}); for (const sequence of [[event('UNKNOWN'),event('END')],[event('END'),event('UNKNOWN')]]) { expect(run([event('ATTEMPT'),...sequence,event('ATTEMPT')])[0]).toMatchObject({unknownCalls:1,failedCalls:0}); } }); it('gives verified final zero priority over a newer provisional observation and delayed END', () => { const provisional=event('DURATION',{at:start+4000,talkMs:3000}); const final=event('RECONCILE',{at:start+3000,talkMs:0,source:'verified-dialog-final'}); for (const sequence of [[provisional,final],[final,provisional]]) { expect(run([event('ATTEMPT'),event('ACCEPTED',{code:200}),...sequence,event('END',{at:start+5000,source:'verified-dialog-final'})])[0]).toMatchObject({connectedCalls:1,answeredCalls:0,talkMs:0}); } }); it('does not infer answer duration from 2xx without ACK evidence',()=>{ const[r]=run([event('ATTEMPT'),event('ACCEPTED',{code:200}),event('END',{at:start+5000,source:'verified-dialog-final'})]); expect(analyticsRates(r)).toMatchObject({connectedCalls:1,answeredCalls:0,talkMs:0,durationUnknownCalls:1,overallAnswerRate:null,connectedAnswerRate:null}); }); it('keeps a version 2 original call pending until explicit close',()=>{ const base={schemaVersion:2}; expect(run([event('START',base),event('ATTEMPT',base),event('END',{...base,code:486,source:'verified-dialog-final'})])[0]).toMatchObject({activeCalls:1,pendingCalls:1,failedCalls:0}); expect(run([event('START',base),event('ATTEMPT',base),event('END',{...base,code:486,source:'verified-dialog-final'}),event('CLOSE',{...base,source:'attempts-exhausted'})])[0]).toMatchObject({activeCalls:0,pendingCalls:0,failedCalls:1}); }); });