72 lines
6.1 KiB
TypeScript
72 lines
6.1 KiB
TypeScript
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> = {}): 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<AnalyticsCall | null>((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});
|
|
});
|
|
});
|