Files
selfphoneng/apps/api/test/ivrs.test.ts
T
iamdoubz d746772b97 destinations: full picker everywhere, numbers in summaries, Ring everyone
The DestinationPicker now offers every type that exists (user, voicemail,
ring group, digital receptionist, office hours, announcement, outside
number, hang up) on every page that routes calls; queues and
conferences join in M8. API summaries show the number of dialable
targets ("Ring group Sales (500)", 05 §3). Wizard step 8 gains "Ring
everyone": it creates or refreshes a ring group "Everyone" of all
extensions (unanswered → the first user's voicemail); an IVR isn't
created there because it needs a recorded greeting. m3-t5 now routes
the main number through "Ring everyone" and still passes.

Task: M6-T6
2026-10-18 11:04:42 -05:00

141 lines
5.3 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// M6-T3: /api/ivrs (03 §6.9, 04 §7.3).
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { soundFiles } from '../src/db/schema.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('/api/ivrs', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let dan: string;
let greeting: string;
let ivrId: string;
const dialplan = async () => {
await t.app.generator.apply({ trigger: 'test' });
return readFile(join(t.genDir, 'extensions_selfphoneng.conf'), 'utf8');
};
const base = () => ({
number: '600',
name: 'Main menu',
greetingSoundId: greeting,
timeoutDestination: { type: 'voicemail', id: dan },
invalidDestination: { type: 'hangup' },
});
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 },
});
dan = (await req('POST', '/api/extensions', { number: '101', name: 'Dan Doe' })).json().id;
const id = '44444444-4444-4444-8444-444444444444';
await t.db.insert(soundFiles).values({
id,
name: 'Welcome',
kind: 'prompt',
source: 'upload',
asteriskName: `custom/${id}`,
durationMs: 3000,
});
greeting = id;
});
afterAll(() => t?.close());
it('creates a menu; the dialplan plays the greeting and maps the keys', async () => {
const r = await req('POST', '/api/ivrs', {
...base(),
options: [
{ digit: '1', destination: { type: 'user', id: dan } },
{ digit: '#', destination: { type: 'voicemail', id: dan } },
],
});
expect(r.statusCode, r.body).toBe(201);
ivrId = r.json().id;
expect(r.json()).toMatchObject({
timeoutSeconds: 5,
maxRetries: 3,
allowDirectDial: true,
options: [
{ digit: '#', summary: 'Voicemail of Dan Doe (101)' },
{ digit: '1', summary: 'Ring Dan Doe (101)' },
],
});
const conf = await dialplan();
expect(conf).toContain(`exten => 600,1,Goto(sp-ivr-${ivrId},s,1)`);
expect(conf).toContain(`Background(custom/${greeting})`);
expect(conf).toContain('exten => 1,1,Goto(sp-local-numbers,101,1)');
expect(conf).toContain('exten => #,1,Goto(sp-vm-direct,101,1)');
});
it('validates keys, sounds, numbers and destinations', async () => {
const post = (body: object) => req('POST', '/api/ivrs', { ...base(), number: '601', ...body });
const missing = '00000000-0000-4000-8000-000000000999';
expect((await post({ number: '600' })).statusCode).toBe(409);
expect((await post({ number: '500' })).statusCode).toBe(400); // ring-group range
expect((await post({ greetingSoundId: missing })).statusCode).toBe(400);
const twice = await post({
options: [
{ digit: '1', destination: { type: 'hangup' } },
{ digit: '1', destination: { type: 'hangup' } },
],
});
expect(twice.statusCode).toBe(400);
expect(
(await post({ options: [{ digit: 'A', destination: { type: 'hangup' } }] })).statusCode,
).toBe(400);
expect(
(await post({ options: [{ digit: '2', destination: { type: 'ring_group', id: missing } }] }))
.statusCode,
).toBe(400);
});
it('"press 9 to hear this again" points at itself, and the menu can still be deleted', async () => {
const r = await req('PATCH', `/api/ivrs/${ivrId}`, {
options: [{ digit: '9', destination: { type: 'ivr', id: ivrId } }],
allowDirectDial: false,
});
expect(r.statusCode, r.body).toBe(200);
const conf = await dialplan();
expect(conf).toContain(`exten => 9,1,Goto(sp-ivr-${ivrId},s,1)`);
expect(conf).not.toContain('include => sp-local-numbers\nexten => s,1,Answer()');
});
it('the greeting sound and the menu are protected while in use', async () => {
const sound = await req('DELETE', `/api/sounds/${greeting}`);
expect(sound.statusCode).toBe(409);
expect(sound.json().error.details.usedBy).toEqual(['digital receptionist Main menu']);
const trunkId = (
await req('POST', '/api/trunks', {
name: 'voip.ms',
serverHost: '127.0.0.1',
username: '123456_pbx',
password: 'pw',
})
).json().id;
const did = (
await req('POST', '/api/dids', {
number: '3125551234',
trunkId,
destination: { type: 'ivr', id: ivrId },
})
).json();
expect(did.destinationSummary).toBe('Digital receptionist Main menu (600)');
expect((await req('DELETE', `/api/ivrs/${ivrId}`)).statusCode).toBe(409);
// The refused delete must not have dropped its options.
expect((await req('GET', `/api/ivrs/${ivrId}`)).json().options).toHaveLength(1);
await req('PATCH', `/api/dids/${did.id}`, { destination: { type: 'hangup' } });
expect((await req('DELETE', `/api/ivrs/${ivrId}`)).statusCode).toBe(200);
expect(await dialplan()).not.toContain(`sp-ivr-${ivrId}`);
});
});