Outbound routes CRUD, reorder and POST /test (mirrors the dialplan:
911 and internal numbers first, then the first matching enabled route,
class check, usable trunks, caller ID priority route > user > default).
The first trunk creates the default rules: toll-free first (because
1NXXNXXXXXX also matches 1800...), US/Canada, international disabled.
Generator: Asterisk orders patterns in one context by specificity, not
by file order, so each route gets its own sp-or-{id} context and
sp-outbound-routes includes them in position order (spec 03 §6.12
updated). sp-route-{id}: class check -> ss-noservice, caller ID
priority, per-trunk GROUP channel limit, failover on CHANUNAVAIL or
CONGESTION, all-circuits-busy-now when every trunk is full or off.
UI: Outbound rules page (↑/↓ reorder, pattern rows with help, ordered
trunk list, caller ID override, test box) and a Calling tab on users
(calling permission, outbound caller ID).
trunk-test.ts: 101 dials 3125550000 -> the fake provider receives
13125550000 with the DID in Remote-Party-ID (From user is the
sub-account), two-way audio; a caller without a class hears
ss-noservice. scripts/e2e/m3-t3.mjs covers the UI. With real voip.ms
still to do: whether per-call caller ID is honored (06 §4).
Task: M3-T3
171 lines
6.7 KiB
TypeScript
171 lines
6.7 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// M3-T3: outbound routes, their order, the test box, and caller ID priority.
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import type { LightMyRequestResponse } from 'fastify';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { patternRegex } from '@selfphoneng/shared';
|
|
import { settings } from '../src/db/schema.ts';
|
|
import { hasTestDb } from './db.ts';
|
|
import { ADMIN, H, createTestApp } from './app.ts';
|
|
|
|
describe('patternRegex (Asterisk pattern rules)', () => {
|
|
it.each([
|
|
['NXXNXXXXXX', '3125550000', true],
|
|
['NXXNXXXXXX', '1125550000', false], // N is 2-9
|
|
['1NXXNXXXXXX', '13125550000', true],
|
|
['1800NXXXXXX', '18005551234', true],
|
|
['011.', '011442079460958', true],
|
|
['011.', '011', false], // "." needs at least one more character
|
|
['011!', '011', true],
|
|
['9[2-9]XX', '9234', true],
|
|
['9[2-9]XX', '9134', false],
|
|
['ZXX', '011', false],
|
|
])('%s vs %s → %s', (p, n, ok) => expect(patternRegex(p).test(n)).toBe(ok));
|
|
});
|
|
|
|
describe.skipIf(!hasTestDb)('outbound routes 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 ext: { id: string };
|
|
let local: { id: string };
|
|
let mainDid: string;
|
|
let salesDid: string;
|
|
let routes: { id: string; name: string }[];
|
|
const test = async (extensionId: string, number: string) =>
|
|
(await req('POST', '/api/outbound-routes/test', { extensionId, number })).json();
|
|
|
|
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;
|
|
ext = (await req('POST', '/api/extensions', { number: '101', name: 'Dan' })).json();
|
|
local = (
|
|
await req('POST', '/api/extensions', { number: '102', name: 'Lo', outboundClass: 'local' })
|
|
).json();
|
|
mainDid = (
|
|
await req('POST', '/api/dids', {
|
|
number: '3125551234',
|
|
trunkId,
|
|
destination: { type: 'hangup' },
|
|
})
|
|
).json().id;
|
|
salesDid = (
|
|
await req('POST', '/api/dids', {
|
|
number: '3125559876',
|
|
trunkId,
|
|
destination: { type: 'hangup' },
|
|
})
|
|
).json().id;
|
|
await t.db.insert(settings).values({ key: 'routing.defaultCallerIdDidId', value: mainDid });
|
|
routes = (await req('GET', '/api/outbound-routes')).json().items;
|
|
});
|
|
afterAll(() => t?.close());
|
|
|
|
it('a 10-digit number goes out as 11 digits with the default caller ID', async () => {
|
|
expect(await test(ext.id, '(312) 555-0000')).toEqual({
|
|
ok: true,
|
|
routeId: routes[1]!.id,
|
|
routeName: 'Local and national (US/Canada)',
|
|
pattern: 'NXXNXXXXXX',
|
|
dialed: '13125550000',
|
|
trunks: [{ id: trunkId, name: 'voip.ms' }],
|
|
callerId: '(312) 555-1234',
|
|
});
|
|
});
|
|
|
|
it('class local may call toll-free but not national; international is off', async () => {
|
|
expect((await test(local.id, '18005551234')).ok).toBe(true);
|
|
const denied = await test(local.id, '13125550000');
|
|
expect(denied).toMatchObject({ ok: false, routeName: 'Local and national (US/Canada)' });
|
|
expect(denied.reason).toMatch(/needs calling permission "national"/);
|
|
expect(await test(ext.id, '011442079460958')).toEqual({
|
|
ok: false,
|
|
reason: 'No enabled route matches this number. The call fails.',
|
|
});
|
|
expect((await test(ext.id, '911')).reason).toMatch(/Emergency/);
|
|
expect((await test(ext.id, '102')).reason).toMatch(/internal number/);
|
|
});
|
|
|
|
it('caller ID: the user setting beats the default, a route override beats both', async () => {
|
|
await req('PATCH', `/api/extensions/${ext.id}`, { outboundCallerIdDidId: salesDid });
|
|
expect((await test(ext.id, '3125550000')).callerId).toBe('(312) 555-9876');
|
|
await req('PATCH', `/api/outbound-routes/${routes[1]!.id}`, { callerIdDidId: mainDid });
|
|
expect((await test(ext.id, '3125550000')).callerId).toBe('(312) 555-1234');
|
|
await req('PATCH', `/api/outbound-routes/${routes[1]!.id}`, { callerIdDidId: null });
|
|
});
|
|
|
|
it('turning international on, then reordering, changes what matches first', async () => {
|
|
const intl = routes[2]!;
|
|
await req('PATCH', `/api/outbound-routes/${intl.id}`, { enabled: true });
|
|
expect((await test(ext.id, '011442079460958')).ok).toBe(false); // class national < international
|
|
const catchAll = (
|
|
await req('POST', '/api/outbound-routes', {
|
|
name: 'Everything',
|
|
patterns: [{ match: 'X.', strip: 0, prepend: '' }],
|
|
requiredClass: 'local',
|
|
trunkIds: [trunkId],
|
|
})
|
|
).json();
|
|
expect(catchAll.position).toBe(3);
|
|
expect((await test(local.id, '13125550000')).routeName).toBe('Local and national (US/Canada)');
|
|
const ids = [catchAll.id, ...routes.map((r) => r.id)];
|
|
expect((await req('POST', '/api/outbound-routes/reorder', { ids })).statusCode).toBe(200);
|
|
expect((await test(local.id, '13125550000')).routeName).toBe('Everything');
|
|
|
|
await settle();
|
|
const conf = dialplan();
|
|
const order = [...conf.matchAll(/^include => sp-or-(\S+)/gm)].map((m) => m[1]);
|
|
expect(order).toEqual(ids); // the include order is the position order
|
|
expect(conf).toContain(`[sp-route-${catchAll.id}]`);
|
|
expect(
|
|
(await req('POST', '/api/outbound-routes/reorder', { ids: ids.slice(1) })).statusCode,
|
|
).toBe(400);
|
|
});
|
|
|
|
it('refuses unknown or repeated trunks and bad patterns', async () => {
|
|
const base = {
|
|
name: 'x',
|
|
requiredClass: 'local',
|
|
patterns: [{ match: 'X.', strip: 0, prepend: '' }],
|
|
};
|
|
const unknown = await req('POST', '/api/outbound-routes', {
|
|
...base,
|
|
trunkIds: ['00000000-0000-4000-8000-000000000000'],
|
|
});
|
|
expect(unknown.statusCode).toBe(400);
|
|
const twice = await req('POST', '/api/outbound-routes', {
|
|
...base,
|
|
trunkIds: [trunkId, trunkId],
|
|
});
|
|
expect(twice.statusCode).toBe(400);
|
|
const bad = await req('POST', '/api/outbound-routes', {
|
|
...base,
|
|
trunkIds: [trunkId],
|
|
patterns: [{ match: '9,Dial(x)', strip: 0, prepend: '' }],
|
|
});
|
|
expect(bad.statusCode).toBe(400);
|
|
});
|
|
});
|