queues: queues, agents, *45 login, live tiles, stats
Queues per 03 §6.8: queues_selfphoneng.conf (members as Local agents
following their hint), sp-queue-{id} (answer, prefix, record, Queue with
max wait, timeout destination), the number in sp-local-numbers and
destApp queue. Golden case queue; the Asterisk load test now checks
`queue show` lists each queue's members.
*45<queue> toggles login and checks PQMSTATUS/UPQMSTATUS, so a code for
a queue the caller isn't an agent of plays ss-noservice and stores
nothing (spec snippet fixed). Pauses live in queue_member_state, AstDB
SP/QPAUSE (resynced on connect) and app_queue; QueuePause is re-sent
after every `queue reload all` and rollback, which unpause everyone.
/api/queues CRUD with members [{extensionId, penalty}], admin
pause/unpause, GET /:id/stats; /api/me/queues/:id/pause|unpause for
Home. Stats come from queue_calls (new table), filled from app_queue's
AMI events plus UserEvent SpQueueExit in the fail branch: a real call
showed that when no agent rings, the Queue part of a call vanishes into
the next leg's CDR, and Asterisk 24 sends QueueCallerAbandon on
timeouts too.
Live: QueueTracker (QueueStatus on connect and after applies, queue
events); admins see every queue, users the queues they're agents of.
Web: Queues page (list, form with every column, detail page with
waiting callers, agents and pause buttons, today's stats), queue tiles
on the dashboard, queue login switches on Home, Queue in the
DestinationPicker.
CDR fixtures for queue answered/abandoned/timeout calls. e2e m8-t1 with
real phones: 3 callers wait with 2 agents, one paused by *45550 and one
in the UI, unpause connects the longest waiting caller, one times out
to the overflow phone, stats count it all. The whole e2e chain passes.
Task: M8-T1
This commit is contained in:
1 parent
6ddd8d3ea9
commit
2d9f8ed99e
75 files changed
+7623
-56
No files matched your search
@@ -65,6 +65,7 @@ function snapshot(): Snapshot {
|
||||
trunks: [],
|
||||
routes: [],
|
||||
ringGroups: [],
|
||||
queues: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// 101 Alice auto-answer 102 Bob never answers, voicemail on, rings 5 s
|
||||
// 103 Carol auto-answer 104 Dave no phone, no voicemail
|
||||
// Each scenario's Cdr events go to apps/api/test/fixtures/cdr/<name>.json.
|
||||
// Queue abandon and park arrive with M8 (queues, parking).
|
||||
// Queue 550 has Alice (answers); queue 551 has Bob (never answers) and sends callers who wait
|
||||
// 8 s to his voicemail. Parking arrives with M8-T2.
|
||||
// Usage: node --conditions=source apps/api/test/asterisk/cdr-record.ts
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
@@ -34,6 +35,7 @@ export const CDR_IDS = {
|
||||
did: (n: string) => `11111111-1111-4111-8111-11111111${n}`, // 1234 / 1235 / 1236
|
||||
ringGroup: '22222222-2222-4222-8222-222222222500',
|
||||
ivr: '33333333-3333-4333-8333-333333333600',
|
||||
queue: (n: string) => `44444444-4444-4444-8444-444444444${n}`,
|
||||
};
|
||||
const PASS = (n: string) => `CdrRecord${n}xxxxxxxxxxxxx`;
|
||||
|
||||
@@ -93,6 +95,8 @@ function snapshot(): Snapshot {
|
||||
did('1234', { type: 'user', id: CDR_IDS.ext('101') }),
|
||||
did('1235', { type: 'ring_group', id: CDR_IDS.ringGroup }),
|
||||
did('1236', { type: 'ivr', id: CDR_IDS.ivr }),
|
||||
did('1237', { type: 'queue', id: CDR_IDS.queue('550') }),
|
||||
did('1238', { type: 'queue', id: CDR_IDS.queue('551') }),
|
||||
],
|
||||
trunks: [
|
||||
{
|
||||
@@ -125,6 +129,32 @@ function snapshot(): Snapshot {
|
||||
noAnswerDestination: { type: 'hangup' },
|
||||
},
|
||||
],
|
||||
queues: (['550', '551'] as const).map((n) => ({
|
||||
id: CDR_IDS.queue(n),
|
||||
number: n,
|
||||
name: n === '550' ? 'Support' : 'Billing',
|
||||
strategy: 'rrmemory' as const,
|
||||
memberRingSeconds: 15,
|
||||
retrySeconds: 1,
|
||||
wrapupSeconds: 0,
|
||||
maxWaitSeconds: n === '550' ? 60 : 8,
|
||||
maxCallers: 0,
|
||||
mohClass: 'default',
|
||||
announcePosition: false,
|
||||
announceEverySeconds: 60,
|
||||
joinWhenNoAgents: false,
|
||||
record: false,
|
||||
callerIdNamePrefix: null,
|
||||
members: [
|
||||
n === '550'
|
||||
? { number: '101', name: 'Alice', penalty: 0 }
|
||||
: { number: '102', name: 'Bob', penalty: 0 },
|
||||
],
|
||||
timeoutDestination:
|
||||
n === '550'
|
||||
? ({ type: 'hangup' } as const)
|
||||
: ({ type: 'voicemail', id: CDR_IDS.ext('102') } as const),
|
||||
})),
|
||||
ivrs: [
|
||||
{
|
||||
id: CDR_IDS.ivr,
|
||||
@@ -394,6 +424,26 @@ async function main() {
|
||||
await sleep(4000);
|
||||
});
|
||||
|
||||
await scenario('queue-answered', async () => {
|
||||
fromMobile('3125551237');
|
||||
await waitFor('101 answers', () => !!pbxChannel('PJSIP/101-'), 20);
|
||||
await sleep(4000);
|
||||
});
|
||||
await scenario('queue-abandoned', async () => {
|
||||
fromMobile('3125551238');
|
||||
await waitFor('102 rings', () => !!pbxChannel('PJSIP/102-'), 20);
|
||||
await sleep(3000); // the caller gives up before the 8 s limit
|
||||
});
|
||||
await scenario('queue-timeout-voicemail', async () => {
|
||||
fromMobile('3125551238');
|
||||
await waitFor(
|
||||
'voicemail',
|
||||
() => /VoiceMail/.test(cli(PBX, 'core show channels concise')),
|
||||
20,
|
||||
);
|
||||
await sleep(6000);
|
||||
});
|
||||
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
for (const [name, evs] of Object.entries(recorded)) {
|
||||
if (!evs.length) throw new Error(`${name}: no Cdr events`);
|
||||
|
||||
@@ -28,7 +28,12 @@ function expectations(dir) {
|
||||
const contexts = [...ext.matchAll(/^\[([^\]]+)\]$/gm)].map((m) => m[1]);
|
||||
const vm = readFileSync(join(dir, 'voicemail_selfphoneng.conf'), 'utf8');
|
||||
const mailboxes = [...vm.matchAll(/^(\d+) =>/gm)].map((m) => m[1]);
|
||||
return { endpoints, contexts, mailboxes };
|
||||
// queue number → how many agents it lists
|
||||
const queues = readFileSync(join(dir, 'queues_selfphoneng.conf'), 'utf8')
|
||||
.split(/^\[/m)
|
||||
.slice(1)
|
||||
.map((sec) => [sec.slice(0, sec.indexOf(']')), (sec.match(/^member =>/gm) ?? []).length]);
|
||||
return { endpoints, contexts, mailboxes, queues };
|
||||
}
|
||||
|
||||
async function check(name) {
|
||||
@@ -57,7 +62,7 @@ async function check(name) {
|
||||
if (!booted) problems.push('Asterisk did not finish booting');
|
||||
await sleep(500);
|
||||
|
||||
const { endpoints, contexts, mailboxes } = expectations(tmp);
|
||||
const { endpoints, contexts, mailboxes, queues } = expectations(tmp);
|
||||
const shown = cli('pjsip show endpoints').out;
|
||||
for (const e of endpoints)
|
||||
if (!new RegExp(`Endpoint:\\s+${e}[\\s/]`).test(shown))
|
||||
@@ -70,6 +75,12 @@ async function check(name) {
|
||||
for (const m of mailboxes)
|
||||
if (!new RegExp(`^default\\s+${m}\\s`, 'm').test(users))
|
||||
problems.push(`mailbox ${m} not loaded`);
|
||||
for (const [q, members] of queues) {
|
||||
const shownQ = cli(`queue show ${q}`).out;
|
||||
const listed = (shownQ.match(/Local\/\d+@sp-queue-member/g) ?? []).length;
|
||||
if (!shownQ.startsWith(`${q} has`) || listed !== members)
|
||||
problems.push(`queue ${q}: expected ${members} members, got: ${shownQ.trim()}`);
|
||||
}
|
||||
// Asterisk colors its log: strip the escape codes before looking for ERROR[.
|
||||
// eslint-disable-next-line no-control-regex -- matching ANSI escapes is the point
|
||||
const log = docker('logs', NAME).out.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
|
||||
@@ -104,6 +104,7 @@ function snapshot(): Snapshot {
|
||||
],
|
||||
trunks: [trunk(GOOD, 'voip.ms', SUB.pass), trunk(BAD, 'wrong password', 'NotThePassword1')],
|
||||
ringGroups: [],
|
||||
queues: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
|
||||
@@ -60,6 +60,7 @@ function snapshot(): Snapshot {
|
||||
trunks: [],
|
||||
routes: [],
|
||||
ringGroups: [],
|
||||
queues: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
|
||||
@@ -56,7 +56,8 @@ describe.skipIf(!hasTestDb)('call history API', () => {
|
||||
destination: { type: 'hangup' },
|
||||
label: n === '1234' ? 'Main line' : null,
|
||||
});
|
||||
for (const f of readdirSync(FIXTURES)) {
|
||||
// The M7 scenarios; queue calls (M8) are checked in cdr.test.ts and queues.test.ts.
|
||||
for (const f of readdirSync(FIXTURES).filter((f) => !f.startsWith('queue-'))) {
|
||||
const events: AmiMessage[] = JSON.parse(readFileSync(new URL(f, FIXTURES), 'utf8'));
|
||||
for (const e of events) await t.db.insert(cdr).values(toCdrRow(e));
|
||||
await aggregateCall(t.db, String(events[0]!.LinkedID));
|
||||
|
||||
@@ -110,6 +110,39 @@ const SCENARIOS: Record<string, Expected> = {
|
||||
rang: ['101', '103'],
|
||||
talkSeconds: 4,
|
||||
},
|
||||
'queue-answered': {
|
||||
direction: 'inbound',
|
||||
status: 'answered',
|
||||
fromNumber: '13125559999',
|
||||
fromName: 'Mobile',
|
||||
toNumber: '13125551237',
|
||||
did: '13125551237',
|
||||
answeredBy: '101',
|
||||
rang: ['101'],
|
||||
talkSeconds: 4,
|
||||
},
|
||||
'queue-abandoned': {
|
||||
direction: 'inbound',
|
||||
status: 'abandoned',
|
||||
fromNumber: '13125559999',
|
||||
fromName: 'Mobile',
|
||||
toNumber: '13125551238',
|
||||
did: '13125551238',
|
||||
answeredBy: null,
|
||||
rang: ['102'],
|
||||
talkSeconds: 0,
|
||||
},
|
||||
'queue-timeout-voicemail': {
|
||||
direction: 'inbound',
|
||||
status: 'voicemail',
|
||||
fromNumber: '13125559999',
|
||||
fromName: 'Mobile',
|
||||
toNumber: '13125551238',
|
||||
did: '13125551238',
|
||||
answeredBy: null,
|
||||
rang: ['102'], // rang as an agent, then took the call in his mailbox
|
||||
talkSeconds: 0,
|
||||
},
|
||||
};
|
||||
|
||||
describe.skipIf(!hasTestDb)('call aggregation from recorded CDRs', () => {
|
||||
@@ -139,7 +172,7 @@ describe.skipIf(!hasTestDb)('call aggregation from recorded CDRs', () => {
|
||||
passwordEnc: encrypt('p'),
|
||||
})
|
||||
.returning();
|
||||
for (const n of ['1234', '1235', '1236'])
|
||||
for (const n of ['1234', '1235', '1236', '1237', '1238'])
|
||||
await t.db.insert(dids).values({
|
||||
number: `1312555${n}`,
|
||||
trunkId: trunk!.id,
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
[
|
||||
{
|
||||
"Event": "Cdr",
|
||||
"Privilege": "cdr,all",
|
||||
"AccountCode": "",
|
||||
"Source": "13125559999",
|
||||
"Destination": "102",
|
||||
"DestinationContext": "sp-queue-member",
|
||||
"CallerID": "\"Mobile\" <13125559999>",
|
||||
"Channel": "Local/102@sp-queue-member-00000003;2",
|
||||
"DestinationChannel": "PJSIP/102-00000013",
|
||||
"LastApplication": "Dial",
|
||||
"LastData": "PJSIP/102/sip:102-0x5d74549820b0@172.21.0.4:5072,600,tx",
|
||||
"StartTime": "2026-10-19 00:33:12",
|
||||
"AnswerTime": "",
|
||||
"EndTime": "2026-10-19 00:33:16",
|
||||
"Duration": "3",
|
||||
"BillableSeconds": "0",
|
||||
"Disposition": "NO ANSWER",
|
||||
"AMAFlags": "DOCUMENTATION",
|
||||
"UniqueID": "1792369992.43",
|
||||
"UserField": "",
|
||||
"LinkedID": "1792369992.41",
|
||||
"sp_did": "",
|
||||
"sp_direction": "",
|
||||
"sp_recording": ""
|
||||
},
|
||||
{
|
||||
"Event": "Cdr",
|
||||
"Privilege": "cdr,all",
|
||||
"AccountCode": "",
|
||||
"Source": "13125559999",
|
||||
"Destination": "s",
|
||||
"DestinationContext": "sp-queue-44444444-4444-4444-8444-444444444551",
|
||||
"CallerID": "\"Mobile\" <13125559999>",
|
||||
"Channel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000012",
|
||||
"DestinationChannel": "Local/102@sp-queue-member-00000003;1",
|
||||
"LastApplication": "Queue",
|
||||
"LastData": "551,t,,,8",
|
||||
"StartTime": "2026-10-19 00:33:12",
|
||||
"AnswerTime": "2026-10-19 00:33:12",
|
||||
"EndTime": "2026-10-19 00:33:16",
|
||||
"Duration": "3",
|
||||
"BillableSeconds": "3",
|
||||
"Disposition": "NO ANSWER",
|
||||
"AMAFlags": "DOCUMENTATION",
|
||||
"UniqueID": "1792369992.41",
|
||||
"UserField": "",
|
||||
"LinkedID": "1792369992.41",
|
||||
"sp_did": "13125551238",
|
||||
"sp_direction": "inbound",
|
||||
"sp_recording": ""
|
||||
}
|
||||
]
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
[
|
||||
{
|
||||
"Event": "Cdr",
|
||||
"Privilege": "cdr,all",
|
||||
"AccountCode": "",
|
||||
"Source": "13125559999",
|
||||
"Destination": "101",
|
||||
"DestinationContext": "sp-queue-member",
|
||||
"CallerID": "\"Mobile\" <13125559999>",
|
||||
"Channel": "Local/101@sp-queue-member-00000002;2",
|
||||
"DestinationChannel": "PJSIP/101-00000011",
|
||||
"LastApplication": "Dial",
|
||||
"LastData": "PJSIP/101/sip:101-0x5bca24ea80b0@172.21.0.4:5070,600,tx",
|
||||
"StartTime": "2026-10-19 00:33:04",
|
||||
"AnswerTime": "2026-10-19 00:33:04",
|
||||
"EndTime": "2026-10-19 00:33:08",
|
||||
"Duration": "4",
|
||||
"BillableSeconds": "4",
|
||||
"Disposition": "ANSWERED",
|
||||
"AMAFlags": "DOCUMENTATION",
|
||||
"UniqueID": "1792369984.37",
|
||||
"UserField": "",
|
||||
"LinkedID": "1792369984.35",
|
||||
"sp_did": "",
|
||||
"sp_direction": "",
|
||||
"sp_recording": ""
|
||||
},
|
||||
{
|
||||
"Event": "Cdr",
|
||||
"Privilege": "cdr,all",
|
||||
"AccountCode": "",
|
||||
"Source": "13125559999",
|
||||
"Destination": "s",
|
||||
"DestinationContext": "sp-queue-44444444-4444-4444-8444-444444444550",
|
||||
"CallerID": "\"Mobile\" <13125559999>",
|
||||
"Channel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000010",
|
||||
"DestinationChannel": "Local/101@sp-queue-member-00000002;1",
|
||||
"LastApplication": "Queue",
|
||||
"LastData": "550,t,,,60",
|
||||
"StartTime": "2026-10-19 00:33:04",
|
||||
"AnswerTime": "2026-10-19 00:33:04",
|
||||
"EndTime": "2026-10-19 00:33:08",
|
||||
"Duration": "4",
|
||||
"BillableSeconds": "4",
|
||||
"Disposition": "ANSWERED",
|
||||
"AMAFlags": "DOCUMENTATION",
|
||||
"UniqueID": "1792369984.35",
|
||||
"UserField": "",
|
||||
"LinkedID": "1792369984.35",
|
||||
"sp_did": "13125551237",
|
||||
"sp_direction": "inbound",
|
||||
"sp_recording": ""
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,80 @@
|
||||
[
|
||||
{
|
||||
"Event": "Cdr",
|
||||
"Privilege": "cdr,all",
|
||||
"AccountCode": "",
|
||||
"Source": "13125559999",
|
||||
"Destination": "102",
|
||||
"DestinationContext": "sp-queue-member",
|
||||
"CallerID": "\"Mobile\" <13125559999>",
|
||||
"Channel": "Local/102@sp-queue-member-00000004;2",
|
||||
"DestinationChannel": "PJSIP/102-00000015",
|
||||
"LastApplication": "Dial",
|
||||
"LastData": "PJSIP/102/sip:102-0x5d74549820b0@172.21.0.4:5072,600,tx",
|
||||
"StartTime": "2026-10-19 00:33:20",
|
||||
"AnswerTime": "",
|
||||
"EndTime": "2026-10-19 00:33:28",
|
||||
"Duration": "8",
|
||||
"BillableSeconds": "0",
|
||||
"Disposition": "NO ANSWER",
|
||||
"AMAFlags": "DOCUMENTATION",
|
||||
"UniqueID": "1792370000.49",
|
||||
"UserField": "",
|
||||
"LinkedID": "1792369999.47",
|
||||
"sp_did": "",
|
||||
"sp_direction": "",
|
||||
"sp_recording": ""
|
||||
},
|
||||
{
|
||||
"Event": "Cdr",
|
||||
"Privilege": "cdr,all",
|
||||
"AccountCode": "",
|
||||
"Source": "13125559999",
|
||||
"Destination": "s",
|
||||
"DestinationContext": "sp-queue-44444444-4444-4444-8444-444444444551",
|
||||
"CallerID": "\"Mobile\" <13125559999>",
|
||||
"Channel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000014",
|
||||
"DestinationChannel": "Local/102@sp-queue-member-00000004;1",
|
||||
"LastApplication": "Queue",
|
||||
"LastData": "551,t,,,8",
|
||||
"StartTime": "2026-10-19 00:33:19",
|
||||
"AnswerTime": "2026-10-19 00:33:19",
|
||||
"EndTime": "2026-10-19 00:33:28",
|
||||
"Duration": "8",
|
||||
"BillableSeconds": "8",
|
||||
"Disposition": "NO ANSWER",
|
||||
"AMAFlags": "DOCUMENTATION",
|
||||
"UniqueID": "1792369999.47",
|
||||
"UserField": "",
|
||||
"LinkedID": "1792369999.47",
|
||||
"sp_did": "13125551238",
|
||||
"sp_direction": "inbound",
|
||||
"sp_recording": ""
|
||||
},
|
||||
{
|
||||
"Event": "Cdr",
|
||||
"Privilege": "cdr,all",
|
||||
"AccountCode": "",
|
||||
"Source": "13125559999",
|
||||
"Destination": "102",
|
||||
"DestinationContext": "sp-vm-direct",
|
||||
"CallerID": "\"Mobile\" <13125559999>",
|
||||
"Channel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000014",
|
||||
"DestinationChannel": "",
|
||||
"LastApplication": "VoiceMail",
|
||||
"LastData": "102@default,u",
|
||||
"StartTime": "2026-10-19 00:33:28",
|
||||
"AnswerTime": "2026-10-19 00:33:28",
|
||||
"EndTime": "2026-10-19 00:33:34",
|
||||
"Duration": "6",
|
||||
"BillableSeconds": "6",
|
||||
"Disposition": "ANSWERED",
|
||||
"AMAFlags": "DOCUMENTATION",
|
||||
"UniqueID": "1792369999.47",
|
||||
"UserField": "",
|
||||
"LinkedID": "1792369999.47",
|
||||
"sp_did": "13125551238",
|
||||
"sp_direction": "inbound",
|
||||
"sp_recording": ""
|
||||
}
|
||||
]
|
||||
@@ -136,6 +136,7 @@ describe.skipIf(!hasTestDb)('generator apply', () => {
|
||||
const order: string[] = [];
|
||||
const slowAmi = {
|
||||
isConnected: () => true,
|
||||
action: async () => ({}),
|
||||
command: async (c: string) => {
|
||||
order.push(`start ${c}`);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
@@ -219,4 +219,36 @@ describe.skipIf(!hasTestDb)('/api/live WebSocket', () => {
|
||||
expect(other.msgs).toHaveLength(1); // just the snapshot
|
||||
for (const c of [admin, desk, other]) c.ws.terminate();
|
||||
});
|
||||
|
||||
it("queues reach admins and that queue's agents", async () => {
|
||||
const agent = await t.addUser('agent@example.com', 'agent-password-123');
|
||||
await t.db.insert(extensions).values({
|
||||
number: '160',
|
||||
name: 'Agent',
|
||||
userId: agent.id,
|
||||
sipPasswordEnc: encrypt('x'),
|
||||
voicemailPinEnc: encrypt('1234'),
|
||||
});
|
||||
const admin = await open(await t.login(ADMIN.email, ADMIN.password));
|
||||
const me = await open(await t.login('agent@example.com', 'agent-password-123'));
|
||||
await until(() => admin.msgs.length === 1 && me.msgs.length === 1);
|
||||
const q = (number: string, ext: string) => ({
|
||||
type: 'queue.updated' as const,
|
||||
data: {
|
||||
id: `q-${number}`,
|
||||
number,
|
||||
name: number,
|
||||
waiting: [],
|
||||
members: [{ ext, paused: false, state: 'idle' as const, callsTaken: 0 }],
|
||||
},
|
||||
});
|
||||
t.live.emit('message', q('550', '160'));
|
||||
t.live.emit('message', q('551', '170'));
|
||||
await until(() => admin.msgs.length === 3 && me.msgs.length === 3);
|
||||
expect(me.msgs[1]).toMatchObject({ type: 'queue.updated', data: { number: '550' } });
|
||||
// not an agent there: it only learns the queue left its view
|
||||
expect(me.msgs[2]).toEqual({ type: 'queue.removed', data: { number: '551' } });
|
||||
expect(admin.msgs[2]).toMatchObject({ type: 'queue.updated', data: { number: '551' } });
|
||||
for (const c of [admin, me]) c.ws.terminate();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
// 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: '500' })).statusCode).toBe(400); // outside 550–599
|
||||
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,
|
||||
withinSlaPercent: 50, // 3 s yes, 45 s no (sla 20 s)
|
||||
});
|
||||
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]');
|
||||
});
|
||||
});
|
||||
@@ -104,11 +104,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
|
||||
beforeAll(async () => ({ db, drop } = await createTestDb()));
|
||||
afterAll(() => drop?.());
|
||||
|
||||
it('creates all 39 tables', async () => {
|
||||
it('creates all 40 tables', async () => {
|
||||
const r = await db.execute(
|
||||
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
|
||||
);
|
||||
expect(r.rows[0]).toEqual({ n: 39 });
|
||||
expect(r.rows[0]).toEqual({ n: 40 });
|
||||
});
|
||||
|
||||
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
|
||||
|
||||
Reference in new issue
Block a user