ivrs: digital receptionist with keypad grid, SoundPicker, golden case
/api/ivrs CRUD: greeting and wrong-key sounds must be prompts, every
option destination must exist (an option may point at its own menu),
keys unique; delete drops the menu's own options before checking
references, inside a transaction so a refused delete keeps them. The
generator renders sp-ivr-{id} per 03 §6.9 (Background greeting,
WaitExten, keys sorted, i/t retry then give up, include sp-local-numbers
for direct dial), adds the number to sp-local-numbers, and destApp sends
ivr destinations there. Golden case ivr (incl. # and self-reference).
Web: Digital receptionist page with a 3x4 keypad grid (pick a key, then
its destination or "unused"), SoundPicker (pick, play, upload new right
in the form), DestinationPicker gains "Digital receptionist" and a shared
target source; useDescribeDestination labels unsaved keys.
e2e m6-t3: menu built in the UI, the greeting plays, keys 1/2 ring the
right phones, a wrong key plays pbx-invalid and repeats the greeting.
Task: M6-T3
This commit is contained in:
1 parent
f87d4d47dd
commit
15624f7b77
35 files changed
+1730
-16
No files matched your search
@@ -0,0 +1,140 @@
|
||||
// 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');
|
||||
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}`);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user