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
140 lines
5.6 KiB
TypeScript
140 lines
5.6 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// M7-T2: /api/calls (filters, paging, detail, CSV) and /api/me/calls, on the recorded CDR fixtures.
|
|
import { readdirSync, readFileSync } from 'node:fs';
|
|
import type { LightMyRequestResponse } from 'fastify';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import type { AmiMessage } from '../src/ami/events.ts';
|
|
import { cdr, dids, extensions, trunks } from '../src/db/schema.ts';
|
|
import { encrypt } from '../src/crypto/secrets.ts';
|
|
import { aggregateCall, toCdrRow } from '../src/services/cdr.ts';
|
|
import { hasTestDb } from './db.ts';
|
|
import { ADMIN, H, createTestApp } from './app.ts';
|
|
|
|
const FIXTURES = new URL('./fixtures/cdr/', import.meta.url);
|
|
|
|
describe.skipIf(!hasTestDb)('call history API', () => {
|
|
let t: Awaited<ReturnType<typeof createTestApp>>;
|
|
let admin: (url: string) => Promise<LightMyRequestResponse>;
|
|
let carol: (url: string) => Promise<LightMyRequestResponse>;
|
|
const ext: Record<string, string> = {};
|
|
|
|
beforeAll(async () => {
|
|
t = await createTestApp();
|
|
await t.clearMustChange(ADMIN.email);
|
|
const carolUser = await t.addUser('carol@example.com', 'carol-password-123');
|
|
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'),
|
|
userId: n === '103' ? carolUser.id : null,
|
|
})
|
|
.returning();
|
|
ext[n] = e!.id;
|
|
}
|
|
const [trunk] = await t.db
|
|
.insert(trunks)
|
|
.values({
|
|
name: 'voip.ms',
|
|
serverHost: 'x.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' },
|
|
label: n === '1234' ? 'Main line' : null,
|
|
});
|
|
// The M7 scenarios; queue and park calls (M8) are checked in cdr.test.ts.
|
|
for (const f of readdirSync(FIXTURES).filter((f) => !/^(queue|park)-/.test(f))) {
|
|
const events: AmiMessage[] = JSON.parse(readFileSync(new URL(f, FIXTURES), 'utf8'));
|
|
for (const e of events) await t.db.insert(cdr).values(toCdrRow(e));
|
|
await aggregateCall(t.db, String(events[0]!.LinkedID));
|
|
}
|
|
const as = (cookie: string) => (url: string) =>
|
|
t.app.inject({ method: 'GET', url, headers: { ...H, cookie } });
|
|
admin = as(await t.login(ADMIN.email, ADMIN.password));
|
|
carol = as(await t.login('carol@example.com', 'carol-password-123'));
|
|
});
|
|
afterAll(() => t?.close());
|
|
|
|
const list = async (q = '') => (await admin(`/api/calls${q}`)).json();
|
|
|
|
it('lists every call newest first, with the DID and who answered', async () => {
|
|
const r = await list();
|
|
expect(r.items).toHaveLength(8);
|
|
const starts = r.items.map((c: { startedAt: string }) => c.startedAt);
|
|
expect([...starts].sort().reverse()).toEqual(starts);
|
|
const inbound = r.items.find(
|
|
(c: { did: { label: string } | null }) => c.did?.label === 'Main line',
|
|
);
|
|
expect(inbound).toMatchObject({
|
|
direction: 'inbound',
|
|
did: { number: '13125551234' },
|
|
answeredBy: { number: '101', name: 'Alice' },
|
|
});
|
|
});
|
|
|
|
it('filters by direction, status, number, extension and company-time-zone days', async () => {
|
|
expect((await list('?direction=inbound')).items).toHaveLength(4);
|
|
expect((await list('?status=voicemail')).items).toHaveLength(2);
|
|
expect((await list('?q=(312) 555-9999')).items).toHaveLength(4);
|
|
expect((await list(`?extensionId=${ext['102']}`)).items).toHaveLength(3); // incl. IVR → its mailbox
|
|
expect((await list('?from=2026-10-18&to=2026-10-18')).items).toHaveLength(8);
|
|
expect((await list('?from=2026-10-19')).items).toHaveLength(0);
|
|
expect((await admin('/api/calls?status=lost')).statusCode).toBe(400);
|
|
});
|
|
|
|
it('pages with a cursor without repeats', async () => {
|
|
const seen: string[] = [];
|
|
let url = '/api/calls?limit=3';
|
|
for (;;) {
|
|
const r = (await admin(url)).json();
|
|
seen.push(...r.items.map((c: { id: string }) => c.id));
|
|
if (!r.nextCursor) break;
|
|
url = `/api/calls?limit=3&cursor=${r.nextCursor}`;
|
|
}
|
|
expect(seen).toHaveLength(8);
|
|
expect(new Set(seen).size).toBe(8);
|
|
});
|
|
|
|
it('a call expands to its CDR legs', async () => {
|
|
const rg = (await list('?q=5551235')).items[0];
|
|
const d = (await admin(`/api/calls/${rg.id}`)).json();
|
|
expect(d.legs).toHaveLength(6);
|
|
expect(d.legs.some((l: { dstchannel: string }) => l.dstchannel?.startsWith('PJSIP/103-'))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('exports CSV with the same filters', async () => {
|
|
const r = await admin('/api/calls/export.csv?direction=internal');
|
|
expect(r.headers['content-type']).toMatch(/^text\/csv/);
|
|
const lines = r.body.trim().split('\r\n');
|
|
expect(lines[0]).toBe(
|
|
'started,direction,status,from,from_name,to,phone_number,answered_by,talk_seconds',
|
|
);
|
|
expect(lines).toHaveLength(4);
|
|
expect(lines.some((l) => /,internal,answered,103,Carol,101,,101 Alice,5$/.test(l))).toBe(true);
|
|
});
|
|
|
|
it('/api/me/calls: only my calls, with my side of each; admins-only list is closed to me', async () => {
|
|
const r = (await carol('/api/me/calls')).json();
|
|
expect(r.items).toHaveLength(5);
|
|
expect(r.items.filter((c: { myRole: string }) => c.myRole === 'out')).toHaveLength(3);
|
|
expect(r.items.every((c: { missedByMe: boolean }) => !c.missedByMe)).toBe(true);
|
|
expect((await carol('/api/calls')).statusCode).toBe(403);
|
|
});
|
|
});
|