Files
selfphoneng/apps/api/test/cdr.test.ts
T
iamdoubz 863a9d121c
ci / images (push) Canceled after 0s
ci / check (push) Canceled after 3s
parking: parked calls live, on the dashboard and Home, pick up from Home
res_parking was configured in M0 (700, slots 701-720, slot hints); this
makes parked calls visible. ParkingTracker loads ParkedCalls and follows
ParkedCall/UnParkedCall/ParkedCallTimeOut/ParkedCallGiveUp; everyone
gets parked.updated/parked.removed. Who parked comes from
ParkerDialString.

A blind transfer to 700 parks without running Park() in the dialplan,
so the live call's "parked" state now comes from those events by
ParkeeLinkedid; the picking-up phone runs ParkedCall() while talking,
which no longer counts as parked.

Web: "Parked calls" on the Dashboard and on Home, where "Pick up 701"
calls the slot like Call back (softphone, or the desk phone first).

CDR fixture park-pickup (recorded: 101 parks an inbound call, 103 picks
it up); the call counts as answered by 101 with 103 in rang. e2e m8-t2
with real phones: park by blind transfer, hint InUse, both lists, pick
up from Home, a second park on 702 (findslot = next) picked up by
dialing it from another phone. Specs 01/04/05 note what was verified.

Task: M8-T2
2026-10-19 04:23:21 -05:00

237 lines
6.6 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// M7-T1: each recorded scenario (test/fixtures/cdr, from real calls) produces the expected calls row.
import { readFileSync } from 'node:fs';
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { AmiMessage } from '../src/ami/events.ts';
import { callExtensions, calls, cdr, dids, extensions, trunks } from '../src/db/schema.ts';
import { aggregateCall, toCdrRow } from '../src/services/cdr.ts';
import { encrypt } from '../src/crypto/secrets.ts';
import { hasTestDb } from './db.ts';
import { createTestApp } from './app.ts';
const fixture = (name: string): AmiMessage[] =>
JSON.parse(readFileSync(new URL(`./fixtures/cdr/${name}.json`, import.meta.url), 'utf8'));
type Expected = {
direction: string;
status: string;
fromNumber: string;
fromName: string | null;
toNumber: string;
did?: string;
answeredBy: string | null;
rang: string[];
talkSeconds: number;
};
const SCENARIOS: Record<string, Expected> = {
'internal-answered': {
direction: 'internal',
status: 'answered',
fromNumber: '103',
fromName: 'Carol',
toNumber: '101',
answeredBy: '101',
rang: ['101'],
talkSeconds: 5,
},
'internal-voicemail': {
direction: 'internal',
status: 'voicemail',
fromNumber: '103',
fromName: 'Carol',
toNumber: '102',
answeredBy: null,
rang: ['102'],
talkSeconds: 0,
},
'internal-unavailable': {
direction: 'internal',
status: 'missed',
fromNumber: '103',
fromName: 'Carol',
toNumber: '104',
answeredBy: null,
rang: [],
talkSeconds: 0,
},
'inbound-user': {
direction: 'inbound',
status: 'answered',
fromNumber: '13125559999',
fromName: 'Mobile',
toNumber: '13125551234',
did: '13125551234',
answeredBy: '101',
rang: ['101'],
talkSeconds: 5,
},
'inbound-ringgroup': {
direction: 'inbound',
status: 'answered',
fromNumber: '13125559999',
fromName: 'Mobile',
toNumber: '13125551235',
did: '13125551235',
answeredBy: '103',
rang: ['102', '103'],
talkSeconds: 4,
},
'inbound-ivr-voicemail': {
direction: 'inbound',
status: 'voicemail',
fromNumber: '13125559999',
fromName: 'Mobile',
toNumber: '13125551236',
did: '13125551236',
answeredBy: null,
rang: ['102'], // its mailbox took the call
talkSeconds: 0,
},
'outbound-answered': {
direction: 'outbound',
status: 'answered',
fromNumber: '101',
fromName: 'Alice',
toNumber: '13125550000',
answeredBy: null,
rang: [],
talkSeconds: 5,
},
transfer: {
direction: 'inbound',
status: 'answered',
fromNumber: '13125559999',
fromName: 'Mobile',
toNumber: '13125551234',
did: '13125551234',
answeredBy: '101',
rang: ['101', '103'],
talkSeconds: 4,
},
'queue-answered': {
direction: 'inbound',
status: 'answered',
fromNumber: '13125559999',
fromName: 'Mobile',
toNumber: '13125551237',
did: '13125551237',
answeredBy: '101',
rang: ['101'],
talkSeconds: 4,
},
'queue-abandoned': {
direction: 'inbound',
status: 'abandoned',
fromNumber: '13125559999',
fromName: 'Mobile',
toNumber: '13125551238',
did: '13125551238',
answeredBy: null,
rang: ['102'],
talkSeconds: 0,
},
'queue-timeout-voicemail': {
direction: 'inbound',
status: 'voicemail',
fromNumber: '13125559999',
fromName: 'Mobile',
toNumber: '13125551238',
did: '13125551238',
answeredBy: null,
rang: ['102'], // rang as an agent, then took the call in his mailbox
talkSeconds: 0,
},
'park-pickup': {
direction: 'inbound',
status: 'answered',
fromNumber: '13125559999',
fromName: 'Mobile',
toNumber: '13125551234',
did: '13125551234',
answeredBy: '101', // parked it on 701; Carol (103) picked it up there
rang: ['101', '103'],
talkSeconds: 4,
},
};
describe.skipIf(!hasTestDb)('call aggregation from recorded CDRs', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
const extId: Record<string, string> = {};
beforeAll(async () => {
t = await createTestApp();
for (const [n, name] of [
['101', 'Alice'],
['102', 'Bob'],
['103', 'Carol'],
['104', 'Dave'],
] as const) {
const [e] = await t.db
.insert(extensions)
.values({ number: n, name, sipPasswordEnc: encrypt('x'), voicemailPinEnc: encrypt('4826') })
.returning();
extId[n] = e!.id;
}
const [trunk] = await t.db
.insert(trunks)
.values({
name: 'voip.ms',
serverHost: 'chicago1.voip.ms',
username: 'u',
passwordEnc: encrypt('p'),
})
.returning();
for (const n of ['1234', '1235', '1236', '1237', '1238'])
await t.db.insert(dids).values({
number: `1312555${n}`,
trunkId: trunk!.id,
destination: { type: 'hangup' },
});
});
afterAll(() => t?.close());
it.each(Object.entries(SCENARIOS))('%s', async (name, want) => {
const events = fixture(name);
for (const e of events) await t.db.insert(cdr).values(toCdrRow(e));
const linkedid = String(events[0]!.LinkedID);
await aggregateCall(t.db, linkedid);
await aggregateCall(t.db, linkedid); // idempotent
const rows = await t.db.select().from(calls).where(eq(calls.linkedid, linkedid));
expect(rows).toHaveLength(1);
const c = rows[0]!;
expect({
direction: c.direction,
status: c.status,
fromNumber: c.fromNumber,
fromName: c.fromName,
toNumber: c.toNumber,
talkSeconds: c.talkSeconds,
}).toEqual({
direction: want.direction,
status: want.status,
fromNumber: want.fromNumber,
fromName: want.fromName,
toNumber: want.toNumber,
talkSeconds: want.talkSeconds,
});
expect(c.answeredByExtensionId).toBe(want.answeredBy ? extId[want.answeredBy] : null);
expect(c.didId !== null).toBe(Boolean(want.did));
expect(c.startedAt.getTime()).toBeLessThanOrEqual(c.endedAt.getTime());
expect(c.answeredAt !== null).toBe(want.status === 'answered');
const involved = await t.db
.select({ ext: callExtensions.extensionId, role: callExtensions.role })
.from(callExtensions)
.where(eq(callExtensions.callId, c.id));
expect(
involved
.filter((i) => i.role === 'rang')
.map((i) => i.ext)
.sort(),
).toEqual(want.rang.map((n) => extId[n]).sort());
const caller = /^\d{3}$/.test(want.fromNumber) ? [extId[want.fromNumber]] : [];
expect(involved.filter((i) => i.role === 'caller').map((i) => i.ext)).toEqual(caller);
});
});