Files
selfphoneng/apps/api/test/cdr.test.ts
T
iamdoubz 71440ac85d cdr: store Cdr events, aggregate calls, fixtures from real calls
services/cdr.ts stores every AMI Cdr event in cdr and, 5 s after a
call's last event, builds its calls row (direction, status, from, to,
DID, answered by, talk time) and call_extensions (caller, rang,
answered) per 04 §5.4; linkedids without a calls row are caught up on
AMI connect. Aggregation is idempotent.

test/asterisk/cdr-record.ts records 8 scenarios with our config, the
fake voip.ms (now shared in provider.ts) and baresip phones; each has a
fixture and a test with the expected calls row. Queue abandon and park
come with M8.

Found while recording: Asterisk 24's Cdr event has no LinkedID unless
cdr_manager maps it, its times carry no zone (now usegmtime), and the
mappings read CDR variables, so the dialplan sets CDR(SP_DID) and
CDR(SP_DIRECTION) next to the channel variables. The Asterisk image
needs a rebuild for the cdr_manager.conf change.

Task: M7-T1
2026-10-18 14:37:14 -05:00

193 lines
5.5 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: [],
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,
},
};
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'])
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);
});
});