Files
selfphoneng/apps/api/test/queues.test.ts
T
iamdoubz aa6681da03 numbers: any free internal number of 1 to 6 digits; ranges only suggest
Owner wanted the first user on 1. Numbering ranges now choose the Next free suggestion
(Settings → Numbering), the setup wizard takes a number per person, and a one-digit number
can't also be a menu key of a digital receptionist with direct dialing. Dialplan patterns that
take an internal number match one digit (_X!); golden case short-numbers proves it in Asterisk.
2026-10-21 13:17:25 -05:00

280 lines
11 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// M8-T1: /api/queues (03 §6.8, 04 §7.3), agent pause from the UI and *45, stats from events.
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 { eq } from 'drizzle-orm';
import { queueCalls, queueMemberState } from '../src/db/schema.ts';
import { syncAstDb } from '../src/services/astdb.ts';
import { recordQueueEvent } from '../src/services/queuestats.ts';
import { handleToggle } from '../src/services/toggles.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('/api/queues', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let admin: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let dan: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
const ext: Record<string, string> = {};
let queueId: string;
const conf = async (f: string) => {
await t.app.generator.apply({ trigger: 'test' });
return readFile(join(t.genDir, f), 'utf8');
};
const state = async () =>
(await t.db.select().from(queueMemberState)).map((s) => ({
ext: Object.keys(ext).find((n) => ext[n] === s.extensionId),
paused: s.paused,
}));
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const as = (cookie: string) => (method: string, url: string, payload?: unknown) =>
t.app.inject({
method: method as 'GET',
url,
payload: payload as object,
headers: { ...H, cookie },
});
admin = as(await t.login(ADMIN.email, ADMIN.password));
const danUser = await t.addUser('dan@example.com', 'dan-password-123');
dan = as(await t.login('dan@example.com', 'dan-password-123'));
for (const [n, name] of [
['101', 'Dan Doe'],
['102', 'Sue Smith'],
['103', 'Ann Lee'],
] as const)
ext[n] = (
await admin('POST', '/api/extensions', {
number: n,
name,
...(n === '101' ? { userId: danUser.id } : {}),
})
).json().id;
});
afterAll(() => t?.close());
it('creates a queue with agents; config and dialplan follow', async () => {
const r = await admin('POST', '/api/queues', {
number: '550',
name: 'Support',
members: [
{ extensionId: ext['102'], penalty: 1 },
{ extensionId: ext['101'], penalty: 0 },
],
timeoutDestination: { type: 'voicemail', id: ext['101'] },
});
expect(r.statusCode, r.body).toBe(201);
queueId = r.json().id;
expect(r.json()).toMatchObject({
strategy: 'rrmemory',
maxWaitSeconds: 300,
timeoutSummary: 'Voicemail of Dan Doe (101)',
members: [
{ extensionId: ext['101'], penalty: 0, paused: false },
{ extensionId: ext['102'], penalty: 1, paused: false },
],
});
const q = await conf('queues_selfphoneng.conf');
expect(q).toContain('[550]');
expect(q).toContain(
'member => Local/101@sp-queue-member/n,0,Dan Doe,hint:101@sp-local-numbers',
);
expect(q).toContain(
'member => Local/102@sp-queue-member/n,1,Sue Smith,hint:102@sp-local-numbers',
);
const d = await conf('extensions_selfphoneng.conf');
expect(d).toContain(`exten => 550,1,Goto(sp-queue-${queueId},s,1)`);
expect(d).toContain(' same => n,Queue(550,t,,,300)');
expect(t.ami.commands).toContain('queue reload all');
});
it('checks the number, agents and destination', async () => {
const bad = (body: object) =>
admin('POST', '/api/queues', {
number: '551',
name: 'X',
timeoutDestination: { type: 'hangup' },
...body,
});
expect((await bad({ number: '1234567' })).statusCode).toBe(400); // 1 to 6 digits
expect((await bad({ number: '550' })).statusCode).toBe(409);
const twice = { extensionId: ext['101'], penalty: 0 };
expect((await bad({ members: [twice, twice] })).statusCode).toBe(400);
expect(
(
await bad({
members: [{ extensionId: '00000000-0000-4000-8000-000000000999', penalty: 0 }],
})
).statusCode,
).toBe(400);
expect(
(
await bad({
timeoutDestination: { type: 'queue', id: '00000000-0000-4000-8000-000000000999' },
})
).statusCode,
).toBe(400);
expect(
(
await admin('PATCH', `/api/queues/${queueId}`, {
timeoutDestination: { type: 'queue', id: queueId },
})
).statusCode,
).toBe(400);
});
it('admin pause: PostgreSQL, AstDB and app_queue; unpause undoes it', async () => {
t.ami.actions.length = 0;
const r = await admin('POST', `/api/queues/${queueId}/members/${ext['102']}/pause`);
expect(r.statusCode, r.body).toBe(200);
expect(await state()).toEqual([{ ext: '102', paused: true }]);
expect(t.ami.actions).toEqual([
{ Action: 'DBPut', Family: 'SP', Key: 'QPAUSE/550/102', Val: '1' },
{
Action: 'QueuePause',
Queue: '550',
Interface: 'Local/102@sp-queue-member/n',
Paused: 'true',
},
]);
expect((await admin('GET', `/api/queues/${queueId}`)).json().members[1].paused).toBe(true);
// not an agent of the queue
expect(
(await admin('POST', `/api/queues/${queueId}/members/${ext['103']}/pause`)).statusCode,
).toBe(404);
t.ami.actions.length = 0;
await admin('POST', `/api/queues/${queueId}/members/${ext['102']}/unpause`);
expect(await state()).toEqual([{ ext: '102', paused: false }]);
expect(t.ami.actions.map((a) => a.Action)).toEqual(['DBDel', 'QueuePause']);
});
it('agents log themselves in and out from Home, only for their own queues', async () => {
expect((await dan('POST', `/api/me/queues/${queueId}/pause`)).statusCode).toBe(200);
expect(await state()).toContainEqual({ ext: '101', paused: true });
expect(
(await dan('POST', `/api/queues/${queueId}/members/${ext['101']}/unpause`)).statusCode,
).toBe(403);
const other = await admin('POST', '/api/queues', {
number: '551',
name: 'Billing',
members: [{ extensionId: ext['103'], penalty: 0 }],
timeoutDestination: { type: 'hangup' },
});
expect((await dan('POST', `/api/me/queues/${other.json().id}/pause`)).statusCode).toBe(404);
expect((await dan('POST', `/api/me/queues/${queueId}/unpause`)).statusCode).toBe(200);
await admin('DELETE', `/api/queues/${other.json().id}`);
});
it('*45 from a phone (SpToggle QPAUSE) updates PostgreSQL', async () => {
const toggle = (Value: string) =>
handleToggle(
{ db: t.db, ami: t.ami, live: t.live, log: t.app.log },
{
Event: 'UserEvent',
UserEvent: 'SpToggle',
Key: 'QPAUSE',
Queue: '550',
Ext: '102',
Value,
},
);
await toggle('1');
expect(await state()).toContainEqual({ ext: '102', paused: true });
await toggle('0');
expect(await state()).toContainEqual({ ext: '102', paused: false });
});
it('a queue reload and an AstDB resync pause the paused agents again', async () => {
await admin('POST', `/api/queues/${queueId}/members/${ext['102']}/pause`);
t.ami.actions.length = 0;
await t.app.generator.apply({ trigger: 'test', force: true });
expect(t.ami.actions).toContainEqual({
Action: 'QueuePause',
Queue: '550',
Interface: 'Local/102@sp-queue-member/n',
Paused: 'true',
});
t.ami.actions.length = 0;
await syncAstDb(t.db, t.ami);
expect(t.ami.actions).toContainEqual({
Action: 'DBPut',
Family: 'SP',
Key: 'QPAUSE/550/102',
Val: '1',
});
});
it('removing an agent forgets their pause', async () => {
const r = await admin('PATCH', `/api/queues/${queueId}`, {
members: [{ extensionId: ext['101'], penalty: 0 }],
});
expect(r.statusCode, r.body).toBe(200);
expect((await state()).map((s) => s.ext)).not.toContain('102');
expect(await conf('queues_selfphoneng.conf')).not.toContain('Local/102@');
});
it('stats from app_queue events (shapes as on a real Asterisk 24)', async () => {
const ev = (Event: string, Uniqueid: string, extra: Record<string, string> = {}) =>
recordQueueEvent(t.db, { Event, Queue: '550', Uniqueid, Linkedid: Uniqueid, ...extra });
const exit = (Uniqueid: string, Status: string) =>
ev('UserEvent', Uniqueid, { UserEvent: 'SpQueueExit', Status });
await ev('QueueCallerJoin', '1.1');
await ev('AgentConnect', '1.1', { HoldTime: '3', Interface: 'Local/101@sp-queue-member/n' });
await ev('QueueCallerJoin', '1.2');
await ev('AgentConnect', '1.2', { HoldTime: '45', Interface: 'Local/101@sp-queue-member/n' });
await ev('QueueCallerJoin', '1.3');
await ev('QueueCallerAbandon', '1.3', { HoldTime: '4' }); // hung up
await ev('QueueCallerJoin', '1.4');
await ev('QueueCallerAbandon', '1.4', { HoldTime: '300' }); // a timeout looks the same…
await exit('1.4', 'TIMEOUT'); // …until the dialplan says so
await exit('1.5', 'JOINEMPTY'); // turned away without joining
await ev('QueueCallerJoin', '1.6'); // still waiting
await ev('QueueCallerJoin', '1.7', { Queue: '999' }); // not ours
expect((await admin('GET', `/api/queues/${queueId}/stats`)).json()).toEqual({
offered: 6,
answered: 2,
abandoned: 1,
timedOut: 2,
avgWaitSeconds: 24,
// sla 20 s: 3 s yes; 45 s, the timeout and the turned away no; the 4 s hang-up and the
// caller still waiting don't count
withinSlaPercent: 25,
});
const [taken] = await t.db.select().from(queueCalls).where(eq(queueCalls.uniqueid, '1.1'));
expect(taken).toMatchObject({
outcome: 'answered',
waitSeconds: 3,
agentExtensionId: ext['101'],
});
expect(
(await admin('GET', `/api/queues/${queueId}/stats?from=2026-01-01&to=2026-01-02`)).json(),
).toMatchObject({ offered: 0, avgWaitSeconds: null, withinSlaPercent: null });
});
it('a phone number can point at it; delete is refused while it does', async () => {
const trunkId = (
await admin('POST', '/api/trunks', {
name: 'voip.ms',
serverHost: '127.0.0.1',
username: '123456_pbx',
password: 'pw',
})
).json().id;
const did = await admin('POST', '/api/dids', {
number: '3125551234',
trunkId,
destination: { type: 'queue', id: queueId },
});
expect(did.json().destinationSummary).toBe('Queue Support (550)');
expect(await conf('extensions_selfphoneng.conf')).toContain(`Goto(sp-queue-${queueId},s,1)`);
expect((await admin('DELETE', `/api/queues/${queueId}`)).statusCode).toBe(409);
await admin('PATCH', `/api/dids/${did.json().id}`, { destination: { type: 'hangup' } });
expect((await admin('DELETE', `/api/queues/${queueId}`)).statusCode).toBe(200);
expect(await conf('queues_selfphoneng.conf')).not.toContain('[550]');
});
});