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
157 lines
6.0 KiB
TypeScript
157 lines
6.0 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// M3-T2: phone numbers, destinations (exist checks, references, summaries) and the inbound dialplan.
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import type { LightMyRequestResponse } from 'fastify';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { DidCreateSchema } from '@selfphoneng/shared';
|
|
import { settings } from '../src/db/schema.ts';
|
|
import { hasTestDb } from './db.ts';
|
|
import { ADMIN, H, createTestApp } from './app.ts';
|
|
|
|
describe('DID number input', () => {
|
|
it.each([
|
|
['(312) 555-1234', '13125551234'],
|
|
['312.555.1234', '13125551234'],
|
|
['+1 312 555 1234', '13125551234'],
|
|
['13125551234', '13125551234'],
|
|
['+44 20 7946 0958', '011442079460958'],
|
|
])('%s → %s', (input, out) => {
|
|
const r = DidCreateSchema.safeParse({
|
|
number: input,
|
|
trunkId: '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37',
|
|
destination: { type: 'hangup' },
|
|
});
|
|
expect(r.success && r.data.number).toBe(out);
|
|
});
|
|
|
|
it.each(['555-1234x', 'abc', '12'])('rejects %s', (input) => {
|
|
expect(
|
|
DidCreateSchema.safeParse({
|
|
number: input,
|
|
trunkId: '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37',
|
|
destination: { type: 'hangup' },
|
|
}).success,
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe.skipIf(!hasTestDb)('DIDs API', () => {
|
|
let t: Awaited<ReturnType<typeof createTestApp>>;
|
|
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
|
const dialplan = () => readFileSync(join(t.genDir, 'extensions_selfphoneng.conf'), 'utf8');
|
|
const settle = async () => {
|
|
await new Promise((r) => setTimeout(r, 60));
|
|
await t.app.generator.apply({ trigger: 'test' });
|
|
};
|
|
let trunkId: string;
|
|
let extId: string;
|
|
let didId: string;
|
|
|
|
beforeAll(async () => {
|
|
t = await createTestApp();
|
|
await t.clearMustChange(ADMIN.email);
|
|
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
|
req = (method, url, payload) =>
|
|
t.app.inject({
|
|
method: method as 'GET',
|
|
url,
|
|
payload: payload as object,
|
|
headers: { ...H, cookie },
|
|
});
|
|
trunkId = (
|
|
await req('POST', '/api/trunks', {
|
|
name: 'voip.ms',
|
|
serverHost: '127.0.0.1',
|
|
username: '123456_pbx',
|
|
password: 'pw',
|
|
})
|
|
).json().id;
|
|
extId = (await req('POST', '/api/extensions', { number: '101', name: 'Dan Doe' })).json().id;
|
|
});
|
|
afterAll(() => t?.close());
|
|
|
|
it('adds a number routed to a user, normalized, with a readable summary', async () => {
|
|
const r = await req('POST', '/api/dids', {
|
|
number: '(312) 555-1234',
|
|
trunkId,
|
|
label: 'Main line',
|
|
destination: { type: 'user', id: extId },
|
|
});
|
|
expect(r.statusCode).toBe(201);
|
|
didId = r.json().id;
|
|
expect(r.json()).toMatchObject({
|
|
number: '13125551234',
|
|
destinationSummary: 'Ring Dan Doe (101)',
|
|
});
|
|
await settle();
|
|
const conf = dialplan();
|
|
expect(conf).toContain('[sp-did-exists]\nexten => 13125551234,1,NoOp()');
|
|
expect(conf).toContain(
|
|
'exten => 13125551234,1,Set(__SP_DID=13125551234)\n same => n,Set(CDR(SP_DID)=13125551234)\n same => n,Goto(sp-local-numbers,101,1)',
|
|
);
|
|
expect(conf).toContain(`[sp-from-trunk-${trunkId}]`);
|
|
});
|
|
|
|
it('refuses duplicates, unknown trunks and destinations that do not exist', async () => {
|
|
const dup = await req('POST', '/api/dids', {
|
|
number: '3125551234',
|
|
trunkId,
|
|
destination: { type: 'hangup' },
|
|
});
|
|
expect(dup.statusCode).toBe(409);
|
|
expect(dup.json().error.message).toBe('(312) 555-1234 is already added');
|
|
const badTrunk = await req('POST', '/api/dids', {
|
|
number: '3125550001',
|
|
trunkId: '00000000-0000-4000-8000-000000000000',
|
|
destination: { type: 'hangup' },
|
|
});
|
|
expect(badTrunk.statusCode).toBe(400);
|
|
const badDest = await req('POST', '/api/dids', {
|
|
number: '3125550001',
|
|
trunkId,
|
|
destination: { type: 'user', id: '00000000-0000-4000-8000-000000000000' },
|
|
});
|
|
expect(badDest.statusCode).toBe(400);
|
|
});
|
|
|
|
it('an extension a number points at cannot be deleted (409 names the number)', async () => {
|
|
const r = await req('DELETE', `/api/extensions/${extId}`);
|
|
expect(r.statusCode).toBe(409);
|
|
expect(r.json().error.details.usedBy).toEqual(['number (312) 555-1234']);
|
|
// A voicemail destination is a reference to the same extension.
|
|
await req('PATCH', `/api/dids/${didId}`, { destination: { type: 'voicemail', id: extId } });
|
|
expect((await req('DELETE', `/api/extensions/${extId}`)).statusCode).toBe(409);
|
|
});
|
|
|
|
it('the unmatched-number setting counts as a reference too', async () => {
|
|
await req('PATCH', `/api/dids/${didId}`, {
|
|
destination: { type: 'external', number: '13125550000' },
|
|
});
|
|
await t.db.insert(settings).values({
|
|
key: 'routing.unmatchedDidDestination',
|
|
value: { type: 'user', id: extId },
|
|
});
|
|
const r = await req('DELETE', `/api/extensions/${extId}`);
|
|
expect(r.json().error.details.usedBy).toEqual(['the setting for calls to unknown numbers']);
|
|
await settle();
|
|
expect(dialplan()).toContain('exten => _[0-9a-z].,1,Goto(sp-local-numbers,101,1)');
|
|
expect(dialplan()).toContain(' same => n,Goto(sp-ext-forward,13125550000,1)');
|
|
});
|
|
|
|
it('a caller-name prefix lands in the dialplan, sanitized by the schema', async () => {
|
|
const bad = await req('PATCH', `/api/dids/${didId}`, { callerIdNamePrefix: 'X;Y' });
|
|
expect(bad.statusCode).toBe(400);
|
|
await req('PATCH', `/api/dids/${didId}`, { callerIdNamePrefix: 'SALES:' });
|
|
await settle();
|
|
expect(dialplan()).toContain(' same => n,Set(CALLERID(name)=SALES: ${CALLERID(name)})');
|
|
});
|
|
|
|
it('a number that is the emergency caller ID cannot be removed', async () => {
|
|
await t.db.insert(settings).values({ key: 'emergency.defaultCallerIdDidId', value: didId });
|
|
const r = await req('DELETE', `/api/dids/${didId}`);
|
|
expect(r.statusCode).toBe(409);
|
|
expect(r.json().error.message).toContain('the default emergency caller ID');
|
|
});
|
|
});
|