moh: music-on-hold classes, uploads, pickers for users and queues
/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
This commit is contained in:
1 parent
e95271e694
commit
b8ff6e3386
34 files changed
+840
-21
No files matched your search
@@ -65,6 +65,7 @@ export async function createTestApp(extend?: (app: ReturnType<typeof buildApp>)
|
||||
LOG_LEVEL: 'fatal',
|
||||
RECORDINGS_DIR: mkdtempSync(join(tmpdir(), 'sp-rec-')),
|
||||
SOUNDS_DIR: mkdtempSync(join(tmpdir(), 'sp-sounds-')),
|
||||
MOH_DIR: mkdtempSync(join(tmpdir(), 'sp-moh-')),
|
||||
});
|
||||
setSecretKey(config.APP_SECRET);
|
||||
const pub = mkdtempSync(join(tmpdir(), 'sp-public-'));
|
||||
|
||||
@@ -68,6 +68,7 @@ function snapshot(): Snapshot {
|
||||
queues: [],
|
||||
conferences: [],
|
||||
pagingGroups: [],
|
||||
mohClasses: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
|
||||
@@ -157,6 +157,7 @@ function snapshot(): Snapshot {
|
||||
})),
|
||||
conferences: [],
|
||||
pagingGroups: [],
|
||||
mohClasses: [],
|
||||
ivrs: [
|
||||
{
|
||||
id: CDR_IDS.ivr,
|
||||
|
||||
@@ -4,7 +4,15 @@
|
||||
// Needs Docker and a built image (ASTERISK_IMAGE, default "selfphoneng-asterisk").
|
||||
// Usage: pnpm test:asterisk [case ...]
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { cpSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -40,6 +48,11 @@ async function check(name) {
|
||||
const tmp = mkdtempSync(join(tmpdir(), `sp-golden-${name}-`));
|
||||
cpSync(join(GOLDEN, name, 'expected'), tmp, { recursive: true });
|
||||
const problems = [];
|
||||
// The api makes each MOH class's folder when the class is created (routes/moh.ts); do the same.
|
||||
const moh = mkdtempSync(join(tmpdir(), `sp-golden-moh-`));
|
||||
const mohConf = readFileSync(join(tmp, 'musiconhold_selfphoneng.conf'), 'utf8');
|
||||
for (const [, d] of mohConf.matchAll(/^directory = \/var\/lib\/asterisk\/moh\/(.+)$/gm))
|
||||
mkdirSync(join(moh, d));
|
||||
const run = docker(
|
||||
'run',
|
||||
'-d',
|
||||
@@ -50,6 +63,8 @@ async function check(name) {
|
||||
'AMI_SECRET=golden-test-secret',
|
||||
'-v',
|
||||
`${tmp}:/etc/asterisk/selfphoneng`,
|
||||
'-v',
|
||||
`${moh}:/var/lib/asterisk/moh`,
|
||||
IMAGE,
|
||||
);
|
||||
if (!run.ok) throw new Error(`docker run failed: ${run.out}`);
|
||||
@@ -90,6 +105,7 @@ async function check(name) {
|
||||
} finally {
|
||||
docker('rm', '-f', NAME);
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
rmSync(moh, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ function snapshot(): Snapshot {
|
||||
queues: [],
|
||||
conferences: [],
|
||||
pagingGroups: [],
|
||||
mohClasses: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
|
||||
@@ -63,6 +63,7 @@ function snapshot(): Snapshot {
|
||||
queues: [],
|
||||
conferences: [],
|
||||
pagingGroups: [],
|
||||
mohClasses: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M8-T5: /api/moh-classes (03 §8, 04 §6): classes, their files, and who may use them.
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
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 { hasFfmpeg, silenceWav } from './audio.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
describe.skipIf(!hasTestDb || !hasFfmpeg)('/api/moh-classes', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let req: (
|
||||
method: string,
|
||||
url: string,
|
||||
payload?: unknown,
|
||||
headers?: Record<string, string>,
|
||||
) => Promise<LightMyRequestResponse>;
|
||||
let classId: string;
|
||||
let fileId: string;
|
||||
let extId: string;
|
||||
const mohDir = () => t.app.config.MOH_DIR;
|
||||
const conf = async () => {
|
||||
await t.app.generator.apply({ trigger: 'test' });
|
||||
return readFile(join(t.genDir, 'musiconhold_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, headers) =>
|
||||
t.app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as object,
|
||||
headers: { ...H, cookie, ...headers },
|
||||
});
|
||||
extId = (await req('POST', '/api/extensions', { number: '101', name: 'Dan Doe' })).json().id;
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
it('creates a class: its folder; no section until it has music', async () => {
|
||||
const r = await req('POST', '/api/moh-classes', { name: 'sales-jazz' });
|
||||
expect(r.statusCode, r.body).toBe(201);
|
||||
classId = r.json().id;
|
||||
expect(existsSync(join(mohDir(), 'sales-jazz'))).toBe(true);
|
||||
expect(await conf()).not.toContain('sales-jazz'); // empty: Asterisk falls back to default
|
||||
expect((await req('POST', '/api/moh-classes', { name: 'sales-jazz' })).statusCode).toBe(409);
|
||||
for (const name of ['default', 'Sales', '../x', 'a b'])
|
||||
expect((await req('POST', '/api/moh-classes', { name })).statusCode, name).toBe(400);
|
||||
});
|
||||
|
||||
it('adds music: an 8 kHz wav in the folder, playable, listed', async () => {
|
||||
const r = await req(
|
||||
'POST',
|
||||
`/api/moh-classes/${classId}/files?name=Smooth&filename=smooth.wav`,
|
||||
silenceWav(),
|
||||
{ 'content-type': 'audio/wav' },
|
||||
);
|
||||
expect(r.statusCode, r.body).toBe(201);
|
||||
fileId = r.json().files[0].id;
|
||||
expect(r.json().files[0]).toMatchObject({ name: 'Smooth', originalFilename: 'smooth.wav' });
|
||||
const wav = join(mohDir(), 'sales-jazz', `${fileId}.wav`);
|
||||
expect(readFileSync(wav).readUInt32LE(24)).toBe(8000);
|
||||
expect(await conf()).toContain(
|
||||
'[sales-jazz]\nmode = files\ndirectory = /var/lib/asterisk/moh/sales-jazz\nsort = random',
|
||||
);
|
||||
const audio = await req('GET', `/api/moh-classes/${classId}/files/${fileId}/audio`);
|
||||
expect(audio.statusCode).toBe(200);
|
||||
// not a prompt: the sounds list and pickers don't show it
|
||||
expect((await req('GET', '/api/sounds')).json().items).toEqual([]);
|
||||
const bad = await req('POST', `/api/moh-classes/${classId}/files?name=X`, Buffer.from('nope'), {
|
||||
'content-type': 'audio/wav',
|
||||
});
|
||||
expect(bad.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('users and queues may pick only classes that exist; delete is refused while used', async () => {
|
||||
const pick = (mohClass: string) => req('PATCH', `/api/extensions/${extId}`, { mohClass });
|
||||
expect((await pick('nope')).statusCode).toBe(400);
|
||||
expect((await pick('sales-jazz')).statusCode).toBe(200);
|
||||
const queue = await req('POST', '/api/queues', {
|
||||
number: '550',
|
||||
name: 'Support',
|
||||
mohClass: 'nope',
|
||||
timeoutDestination: { type: 'hangup' },
|
||||
});
|
||||
expect(queue.statusCode).toBe(400);
|
||||
const del = await req('DELETE', `/api/moh-classes/${classId}`);
|
||||
expect(del.statusCode).toBe(409);
|
||||
expect(del.json().error.details.usedBy).toEqual(['extension 101']);
|
||||
expect((await pick('default')).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('removing a file and the class removes them from disk and config', async () => {
|
||||
expect((await req('DELETE', `/api/moh-classes/${classId}/files/${fileId}`)).statusCode).toBe(
|
||||
200,
|
||||
);
|
||||
expect(existsSync(join(mohDir(), 'sales-jazz', `${fileId}.wav`))).toBe(false);
|
||||
expect(await conf()).not.toContain('sales-jazz');
|
||||
expect((await req('DELETE', `/api/moh-classes/${classId}`)).statusCode).toBe(200);
|
||||
expect(existsSync(join(mohDir(), 'sales-jazz'))).toBe(false);
|
||||
expect(await conf()).not.toContain('sales-jazz');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user