/api/moh-classes: create (slug, not "default"), delete (409 while an extension or queue uses it), add/play/remove files. Files are 8 kHz wav in MOH_DIR/<class>/ and sound_files rows of kind moh. Extensions and queues may only pick default or an existing class. Two things found with real calls, written back into 03 and 04: - An empty class plays silence on Asterisk 24. Only classes with music are rendered, so a channel asking for an empty one gets default. - The generator reloads only changed files, and a file upload changes no config (except a class's first/last). The route sends moh reload after every file change. Web: a Music on hold section on Sounds & music, and a select of sets in the user drawer's Phone tab and the queue form. load-golden makes the class folders like the api does. Golden case queue gains two classes. e2e m8-t5: set made in the UI, upload listed by Asterisk, a queue caller hears it, a user's moh_suggest, delete refused while used, an emptied set falls back to default. Task: M8-T5
183 lines
7.3 KiB
JavaScript
183 lines
7.3 KiB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// M8-T5 with real calls: a music-on-hold set "sales-jazz" made on Sounds & music, a tone file
|
|
// uploaded into it. Asterisk lists the class with that file; a queue using it plays it to a
|
|
// waiting caller; a user using it gets moh_suggest (what the people they hold hear). An empty
|
|
// set falls back to the default music. Deleting the set is refused while the queue uses it.
|
|
// Runs on m2-live with a moh dir, after m1-t4.mjs.
|
|
// Usage: node scripts/e2e/m8-t5.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
|
|
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { launch } from './cdp.mjs';
|
|
|
|
const AST = 'sp-live-ast';
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const cli = (cmd) =>
|
|
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? '';
|
|
const astLog = () =>
|
|
// eslint-disable-next-line no-control-regex -- Asterisk colors its log
|
|
spawnSync('docker', ['logs', AST], { encoding: 'utf8' }).stdout.replace(/\x1b\[[0-9;]*m/g, '');
|
|
const [base, email, pw, shotsDir] = process.argv.slice(2);
|
|
|
|
const login = await fetch(`${base}/api/auth/login`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' },
|
|
body: JSON.stringify({ email, password: pw }),
|
|
});
|
|
const cookie = login.headers
|
|
.getSetCookie()
|
|
.map((c) => c.split(';')[0])
|
|
.join('; ');
|
|
const api = async (method, url, body, ok = true) => {
|
|
const r = await fetch(`${base}${url}`, {
|
|
method,
|
|
headers: {
|
|
...(body ? { 'content-type': 'application/json' } : {}),
|
|
'x-requested-with': 'selfphoneng',
|
|
cookie,
|
|
},
|
|
body: body && JSON.stringify(body),
|
|
});
|
|
const json = await r.json();
|
|
if (ok) assert.ok(r.ok, `${method} ${url}: ${r.status} ${JSON.stringify(json)}`);
|
|
return { status: r.status, json };
|
|
};
|
|
const waitUntil = async (fn, what, ms = 30_000) => {
|
|
const end = Date.now() + ms;
|
|
while (Date.now() < end) {
|
|
const v = await fn();
|
|
if (v) return v;
|
|
await sleep(300);
|
|
}
|
|
throw new Error(`timeout waiting for: ${what}`);
|
|
};
|
|
/** A caller waiting in queue `q` (no agents) for 20 s. */
|
|
const caller = (q) => cli(`channel originate Local/${q}@sp-from-ext application Wait 20`);
|
|
|
|
const p = await launch({ shotsDir });
|
|
try {
|
|
await p.goto(`${base}/login`);
|
|
await p.waitFor(`!!document.querySelector('input[type=email]')`);
|
|
await p.fill('Email', email);
|
|
await p.fill('Password', pw);
|
|
await p.click('Sign in');
|
|
await p.waitFor(`location.pathname === '/admin'`);
|
|
|
|
// The set and its music, in the UI.
|
|
await p.click('Sounds & music');
|
|
await p.waitFor(`document.body.innerText.includes('Music on hold')`);
|
|
await p.fill('New set', 'Sales Jazz');
|
|
await p.click('Add set');
|
|
await p.waitFor(
|
|
`document.body.innerText.includes('Lowercase letters, digits and dashes')`,
|
|
'bad name refused',
|
|
);
|
|
await p.fill('New set', 'sales-jazz');
|
|
await p.click('Add set');
|
|
await p.waitFor(
|
|
`!!document.querySelector('[aria-label="Music on hold sales-jazz"]')`,
|
|
'set created',
|
|
);
|
|
// A 3 s 523 Hz tone picked in the set's file input.
|
|
await p.evaluate(`(() => {
|
|
const rate = 8000, n = rate * 3, buf = new ArrayBuffer(44 + n * 2), v = new DataView(buf);
|
|
const s = (o, t) => [...t].forEach((c, i) => v.setUint8(o + i, c.charCodeAt(0)));
|
|
s(0, 'RIFF'); v.setUint32(4, 36 + n * 2, true); s(8, 'WAVEfmt '); v.setUint32(16, 16, true);
|
|
v.setUint16(20, 1, true); v.setUint16(22, 1, true); v.setUint32(24, rate, true);
|
|
v.setUint32(28, rate * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
|
|
s(36, 'data'); v.setUint32(40, n * 2, true);
|
|
for (let i = 0; i < n; i++) v.setInt16(44 + i * 2, Math.sin((2 * Math.PI * 523 * i) / rate) * 8000, true);
|
|
const dt = new DataTransfer();
|
|
dt.items.add(new File([buf], 'smooth_jazz.wav', { type: 'audio/wav' }));
|
|
const input = document.querySelector('[aria-label="Music on hold sales-jazz"] input[type=file]');
|
|
input.files = dt.files;
|
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
return true;
|
|
})()`);
|
|
await p.click('Upload', '[aria-label="Music on hold sales-jazz"]');
|
|
await p.waitFor(
|
|
`!!document.querySelector('audio[aria-label="Play smooth jazz"]')`,
|
|
'uploaded',
|
|
15_000,
|
|
);
|
|
assert.match(await p.text(), /smooth jazz 0:03/);
|
|
await p.shot('m8-t5-01-moh');
|
|
console.log('set sales-jazz made in the UI (a bad name refused), "smooth jazz" 0:03 uploaded');
|
|
|
|
// Asterisk has the class with that file.
|
|
await waitUntil(
|
|
() =>
|
|
/Class: sales-jazz\n\tFile: \/var\/lib\/asterisk\/moh\/sales-jazz\//.test(
|
|
cli('moh show files'),
|
|
),
|
|
'moh show files lists the upload',
|
|
);
|
|
console.log('Asterisk: class sales-jazz plays the uploaded file');
|
|
|
|
// A queue with it plays it to a waiting caller.
|
|
const sets = (await api('GET', '/api/moh-classes')).json.items;
|
|
const jazz = sets.find((c) => c.name === 'sales-jazz');
|
|
const queue = (
|
|
await api('POST', '/api/queues', {
|
|
number: '560',
|
|
name: 'Jazz line',
|
|
mohClass: 'sales-jazz',
|
|
joinWhenNoAgents: true,
|
|
timeoutDestination: { type: 'hangup' },
|
|
})
|
|
).json;
|
|
await waitUntil(() => /musicclass|560 has/.test(cli('queue show 560')), 'queue 560 loaded');
|
|
let at = astLog().length;
|
|
caller('560');
|
|
await waitUntil(
|
|
() => /Started music on hold, class 'sales-jazz'/.test(astLog().slice(at)),
|
|
'caller hears sales-jazz',
|
|
);
|
|
cli('channel request hangup all');
|
|
console.log('queue 560: the waiting caller hears sales-jazz');
|
|
|
|
// A user with it: what the people they put on hold hear.
|
|
const ext = (await api('POST', '/api/extensions', { number: '330', name: 'Hal Hold' })).json;
|
|
assert.equal(
|
|
(await api('PATCH', `/api/extensions/${ext.id}`, { mohClass: 'nope' }, false)).status,
|
|
400,
|
|
);
|
|
await api('PATCH', `/api/extensions/${ext.id}`, { mohClass: 'sales-jazz' });
|
|
await waitUntil(
|
|
() => /moh_suggest\s*:\s*sales-jazz/.test(cli('pjsip show endpoint 330')),
|
|
'endpoint 330 suggests sales-jazz',
|
|
);
|
|
console.log('user 330: moh_suggest sales-jazz (an unknown set was refused)');
|
|
|
|
// In use: can't delete. Emptied: the caller hears the default music.
|
|
const del = await api('DELETE', `/api/moh-classes/${jazz.id}`, undefined, false);
|
|
assert.equal(del.status, 409);
|
|
assert.match(del.json.error.message, /extension 330.*queue Jazz line|queue Jazz line/);
|
|
await api('DELETE', `/api/moh-classes/${jazz.id}/files/${jazz.files[0].id}`);
|
|
await waitUntil(
|
|
() =>
|
|
!/Class: sales-jazz\n\tFile: \/var\/lib\/asterisk\/moh\/sales-jazz\//.test(
|
|
cli('moh show files'),
|
|
),
|
|
'emptied',
|
|
);
|
|
at = astLog().length;
|
|
caller('560');
|
|
await waitUntil(
|
|
() => /Started music on hold, class 'default'/.test(astLog().slice(at)),
|
|
'empty set: default music',
|
|
);
|
|
cli('channel request hangup all');
|
|
console.log('delete refused while used; emptied set: the caller hears the default music');
|
|
await api('DELETE', `/api/queues/${queue.id}`);
|
|
console.log('M8-T5 (music on hold): PASS');
|
|
} catch (e) {
|
|
await p.shot('m8-t5-fail').catch(() => {});
|
|
console.log(`FAIL: ${e.message}`);
|
|
console.log(`moh: ${cli('moh show classes')}`);
|
|
console.log(`browser errors: ${JSON.stringify(p.consoleErrors)}`);
|
|
process.exitCode = 1;
|
|
} finally {
|
|
cli('channel request hangup all');
|
|
await p.close();
|
|
}
|