Files
selfphoneng/apps/api/test/ringgroups.test.ts
T
iamdoubz f87d4d47dd ringgroups: ring-all and hunt groups, API, page, golden case
/api/ring-groups CRUD with members as ordered memberIds (unique, must
exist), number from the ring-group range, a no-answer destination
(never itself), and delete refused while something points at it. The
generator renders sp-rg-{id} (Dial through sp-rg-member: all at once with
the total ring time, or one at a time with the per-member time, music
instead of ringing on request), adds the number to sp-local-numbers, and
destApp sends ring_group destinations there. Golden case ringgroups.

The DestinationPicker is now generic over targets picked by id and gains
"Voicemail of a user" and "Ring group" (M6-T6 completes it). Caller-name
prefixes (DIDs and ring groups) get a space before the caller's name.
Ring groups page with an ordered member list.

e2e m6-t2 found the new-group form failing on an unset no-answer
destination (fixed: defaults to hang up). It checks ring-all (both ring,
191 answers) and hunt (190 alone for 5 s, then 191) with real calls. The
generator debounce test now waits for the apply instead of a fixed 150 ms.

Task: M6-T2
2026-10-18 10:31:05 -05:00

125 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: AGPL-3.0-or-later
// M6-T2: /api/ring-groups (03 §6.7, 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 { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('/api/ring-groups', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
const ext: Record<string, string> = {};
let groupId: string;
const dialplan = async () => {
await t.app.generator.apply({ trigger: 'test' });
return readFile(join(t.genDir, 'extensions_selfphoneng.conf'), 'utf8');
};
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 },
});
for (const [n, name] of [
['101', 'Dan Doe'],
['102', 'Sue Smith'],
] as const)
ext[n] = (await req('POST', '/api/extensions', { number: n, name })).json().id;
});
afterAll(() => t?.close());
it('creates a group with ordered members; the dialplan rings them', async () => {
const r = await req('POST', '/api/ring-groups', {
number: '500',
name: 'Sales',
memberIds: [ext['102'], ext['101']],
noAnswerDestination: { type: 'voicemail', id: ext['101'] },
});
expect(r.statusCode, r.body).toBe(201);
groupId = r.json().id;
expect(r.json()).toMatchObject({
strategy: 'ringall',
ringSeconds: 25,
memberIds: [ext['102'], ext['101']],
noAnswerSummary: 'Voicemail of Dan Doe (101)',
});
const conf = await dialplan();
expect(conf).toContain(`exten => 500,1,Goto(sp-rg-${groupId},s,1)`);
expect(conf).toContain('Dial(Local/102@sp-rg-member&Local/101@sp-rg-member,25,t)');
});
it('checks the number, members and destination', async () => {
const bad = (body: object) =>
req('POST', '/api/ring-groups', {
number: '501',
name: 'X',
noAnswerDestination: { type: 'hangup' },
...body,
});
expect((await bad({ number: '101' })).statusCode).toBe(400); // outside 500–549
expect((await bad({ number: '500' })).statusCode).toBe(409);
expect((await bad({ memberIds: [ext['101'], ext['101']] })).statusCode).toBe(400);
expect((await bad({ memberIds: ['00000000-0000-4000-8000-000000000999'] })).statusCode).toBe(
400,
);
expect(
(
await bad({
noAnswerDestination: { type: 'ivr', id: '00000000-0000-4000-8000-000000000999' },
})
).statusCode,
).toBe(400);
expect(
(
await req('PATCH', `/api/ring-groups/${groupId}`, {
noAnswerDestination: { type: 'ring_group', id: groupId },
})
).statusCode,
).toBe(400);
});
it('hunt: one member at a time; reorder via PATCH', async () => {
const r = await req('PATCH', `/api/ring-groups/${groupId}`, {
strategy: 'hunt',
ringSeconds: 15,
memberIds: [ext['101'], ext['102']],
});
expect(r.statusCode, r.body).toBe(200);
const conf = await dialplan();
expect(conf.indexOf('Dial(Local/101@sp-rg-member,15,t)')).toBeLessThan(
conf.indexOf('Dial(Local/102@sp-rg-member,15,t)'),
);
});
it('a phone number can point at it; delete is refused while it does', async () => {
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: 'ring_group', id: groupId },
});
expect(did.json().destinationSummary).toBe('Ring group Sales');
expect(await dialplan()).toContain(`Goto(sp-rg-${groupId},s,1)`);
const busy = await req('DELETE', `/api/ring-groups/${groupId}`);
expect(busy.statusCode).toBe(409);
await req('PATCH', `/api/dids/${did.json().id}`, { destination: { type: 'hangup' } });
expect((await req('DELETE', `/api/ring-groups/${groupId}`)).statusCode).toBe(200);
expect(await dialplan()).not.toContain(`sp-rg-${groupId}`);
});
});