Asterisk sends NewCallerid after a Local;1 channel's Hangup, which re-created the channel and left a ghost ringing call on the dashboard. Only Newchannel creates a channel now. The e2e waits that matched text also shown on the previous page (now that the dashboard lists calls) wait for the route. Task: M7-T4
244 lines
8.8 KiB
JavaScript
244 lines
8.8 KiB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// M6 milestone check (09 M6), as real calls into a trunk's entry context:
|
|
// DID → office hours (open) → menu "1 sales (ring group, everyone at once), 2 support (102)";
|
|
// closed → voicemail of 101; *311 from an admin's phone forces open; a blocked caller is
|
|
// rejected. The blocked number is added in the UI (M6-T8).
|
|
// Phones (baresip, auto-answer): 220 (an admin's), 221, 222, 223. Runs on m2-live after m1-t4.
|
|
// Usage: node scripts/e2e/m6-check.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
|
|
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { originate } from './ami.mjs';
|
|
import { launch } from './cdp.mjs';
|
|
import { startPhones } from './phones.mjs';
|
|
|
|
const NET = 'sp-live';
|
|
const AST = 'sp-live-ast';
|
|
const DID = '13125557777';
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const cli = (cmd) =>
|
|
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? '';
|
|
// eslint-disable-next-line no-control-regex -- strips Asterisk's log colors
|
|
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
const astLog = () =>
|
|
(spawnSync('docker', ['logs', AST], { encoding: 'utf8' }).stdout ?? '').replace(ANSI, '');
|
|
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, raw) => {
|
|
const r = await fetch(`${base}${url}`, {
|
|
method,
|
|
headers: raw
|
|
? { 'content-type': 'audio/wav', 'x-requested-with': 'selfphoneng', cookie }
|
|
: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng', cookie },
|
|
body: raw ?? (body && JSON.stringify(body)),
|
|
});
|
|
const json = await r.json();
|
|
assert.ok(r.ok, `${method} ${url}: ${r.status} ${JSON.stringify(json)}`);
|
|
return json;
|
|
};
|
|
const next = async (kind) => (await api('GET', `/api/numbering/next?kind=${kind}`)).number;
|
|
|
|
// People and phones.
|
|
const boss = await api('POST', '/api/users', {
|
|
email: 'boss@example.com',
|
|
displayName: 'Bo Boss',
|
|
role: 'admin',
|
|
});
|
|
const ext = {};
|
|
const pass = {};
|
|
for (const [n, name, extra] of [
|
|
['220', 'Bo Boss', { userId: boss.user.id }],
|
|
['221', 'Sal Sales'],
|
|
['222', 'Sam Sales'],
|
|
['223', 'Sue Support'],
|
|
]) {
|
|
ext[n] = (await api('POST', '/api/extensions', { number: n, name, ...extra })).id;
|
|
pass[n] = (await api('GET', `/api/extensions/${ext[n]}/sip-credentials`)).password;
|
|
}
|
|
const phones = startPhones({
|
|
name: 'sp-live-phones-m6check',
|
|
net: NET,
|
|
ast: AST,
|
|
phones: ['220', '221', '222', '223'].map((n, i) => ({
|
|
ext: n,
|
|
password: pass[n],
|
|
answer: 'auto',
|
|
toneHz: 440 + 110 * i,
|
|
})),
|
|
});
|
|
|
|
// The call flow.
|
|
const wav = (() => {
|
|
const n = 8000 * 3;
|
|
const b = Buffer.alloc(44 + n * 2);
|
|
b.write('RIFF', 0);
|
|
b.writeUInt32LE(36 + n * 2, 4);
|
|
b.write('WAVEfmt ', 8);
|
|
b.writeUInt32LE(16, 16);
|
|
b.writeUInt16LE(1, 20);
|
|
b.writeUInt16LE(1, 22);
|
|
b.writeUInt32LE(8000, 24);
|
|
b.writeUInt32LE(16000, 28);
|
|
b.writeUInt16LE(2, 32);
|
|
b.writeUInt16LE(16, 34);
|
|
b.write('data', 36);
|
|
b.writeUInt32LE(n * 2, 40);
|
|
for (let i = 0; i < n; i++)
|
|
b.writeInt16LE(Math.round(Math.sin((2 * Math.PI * 500 * i) / 8000) * 8000), 44 + i * 2);
|
|
return b;
|
|
})();
|
|
const greeting = await api('POST', '/api/sounds?name=Welcome%20menu', undefined, wav);
|
|
const sales = await api('POST', '/api/ring-groups', {
|
|
number: await next('ringGroup'),
|
|
name: 'Check sales',
|
|
memberIds: [ext['221'], ext['222']],
|
|
noAnswerDestination: { type: 'voicemail', id: ext['220'] },
|
|
});
|
|
const menu = await api('POST', '/api/ivrs', {
|
|
number: await next('ivr'),
|
|
name: 'Check menu',
|
|
greetingSoundId: greeting.id,
|
|
options: [
|
|
{ digit: '1', destination: { type: 'ring_group', id: sales.id } },
|
|
{ digit: '2', destination: { type: 'user', id: ext['223'] } },
|
|
],
|
|
timeoutDestination: { type: 'voicemail', id: ext['220'] },
|
|
invalidDestination: { type: 'voicemail', id: ext['220'] },
|
|
});
|
|
const used = new Set((await api('GET', '/api/time-conditions')).items.map((t) => t.overrideDigit));
|
|
const digit = [1, 2, 3, 4, 5, 6, 7, 8, 9].find((d) => !used.has(d));
|
|
const hours = await api('POST', '/api/time-conditions', {
|
|
name: 'Check hours',
|
|
openRanges: [], // never open by the clock: overrides decide, so the check doesn't depend on now
|
|
overrideDigit: digit,
|
|
openDestination: { type: 'ivr', id: menu.id },
|
|
closedDestination: { type: 'voicemail', id: ext['220'] },
|
|
});
|
|
const trunk = await api('POST', '/api/trunks', {
|
|
name: 'M6 check',
|
|
serverHost: '127.0.0.1',
|
|
username: 'm6check',
|
|
password: 'unused-pw',
|
|
});
|
|
await api('POST', '/api/dids', {
|
|
number: DID,
|
|
trunkId: trunk.id,
|
|
destination: { type: 'time_condition', id: hours.id },
|
|
});
|
|
|
|
const waitUntil = async (fn, what, ms = 25_000) => {
|
|
const end = Date.now() + ms;
|
|
while (Date.now() < end) {
|
|
if (await fn()) return;
|
|
await sleep(200);
|
|
}
|
|
throw new Error(`timeout waiting for: ${what}`);
|
|
};
|
|
const hangupAll = async () => {
|
|
cli('channel request hangup all');
|
|
await waitUntil(() => !/(PJSIP|Local)\//.test(cli('core show channels concise')), 'calls ended');
|
|
await sleep(500);
|
|
};
|
|
/** A caller from outside: into the trunk's entry, presses `keys` once the greeting plays. */
|
|
async function inbound(from, keys = '') {
|
|
const at = astLog().length;
|
|
await originate({
|
|
Channel: `Local/${DID}@sp-from-trunk-${trunk.id}`,
|
|
CallerID: `"Outside" <${from}>`,
|
|
Application: keys ? 'SendDTMF' : 'Wait',
|
|
Data: keys ? `${'w'.repeat(10)}${keys}${'w'.repeat(40)}` : '25',
|
|
});
|
|
return () => astLog().slice(at);
|
|
}
|
|
const dialCode = async (code) => {
|
|
cli(`channel originate PJSIP/220 extension ${code}@sp-from-ext`);
|
|
await sleep(3000);
|
|
await hangupAll();
|
|
};
|
|
|
|
const p = await launch({ shotsDir });
|
|
try {
|
|
await waitUntil(
|
|
() =>
|
|
['220', '221', '222', '223'].every((n) =>
|
|
new RegExp(`${n}/sip:.*Avail`).test(cli('pjsip show contacts')),
|
|
),
|
|
'phones registered',
|
|
180_000,
|
|
);
|
|
await waitUntil(
|
|
() => cli(`dialplan show ${DID}@sp-inbound`).includes('sp-tc-'),
|
|
'dialplan ready',
|
|
);
|
|
|
|
// Closed (no hours, no override): straight to 220's voicemail.
|
|
let log = await inbound('3125550111');
|
|
await waitUntil(() => /VoiceMail\("[^"]+", "220@default,u"\)/.test(log()), 'closed → voicemail');
|
|
await hangupAll();
|
|
console.log('closed: the DID went to voicemail of 220');
|
|
|
|
// *31N from the admin's phone forces open → menu → 1 = sales (221 and 222 ring together).
|
|
await dialCode(`*31${digit}`);
|
|
await waitUntil(
|
|
async () => (await api('GET', `/api/time-conditions/${hours.id}`)).overrideState === 'open',
|
|
'forced open',
|
|
);
|
|
log = await inbound('3125550111', '1');
|
|
await waitUntil(
|
|
() => /Called PJSIP\/221\//.test(log()) && /Called PJSIP\/222\//.test(log()),
|
|
'sales rings',
|
|
);
|
|
assert.match(log(), new RegExp(`Playing '${greeting.asteriskName}\\.`), 'menu greeting played');
|
|
await hangupAll();
|
|
log = await inbound('3125550111', '2');
|
|
await waitUntil(() => /Called PJSIP\/223\//.test(log()), 'support rings');
|
|
await hangupAll();
|
|
console.log(`*31${digit} forced open: menu greeting, 1 → 221 + 222 together, 2 → 223`);
|
|
|
|
// Block the caller in the UI: the next call is rejected before anything rings.
|
|
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'`);
|
|
await p.click('Blocked numbers');
|
|
await p.waitFor(`document.body.innerText.includes('Number to block')`);
|
|
await p.fill('Number to block', '(312) 555-0111');
|
|
await p.fill('Note (optional)', 'Test spammer');
|
|
await p.click('Block');
|
|
await p.waitFor(`document.body.innerText.includes('(312) 555-0111')`, 'listed');
|
|
await p.shot('m6-check-01-blocked');
|
|
assert.match(cli('database show SP'), /\/SP\/BLOCK\/13125550111\s*:\s*1/);
|
|
log = await inbound('3125550111', '1');
|
|
await waitUntil(() => /Hangup\("[^"]+", "21"\)/.test(log()), 'blocked caller rejected');
|
|
assert.doesNotMatch(log(), /Called PJSIP\//);
|
|
await hangupAll();
|
|
log = await inbound('3125550222', '2');
|
|
await waitUntil(() => /Called PJSIP\/223\//.test(log()), 'other callers still get through');
|
|
await hangupAll();
|
|
console.log('blocked in the UI: (312) 555-0111 rejected (cause 21); others still get through');
|
|
|
|
await dialCode(`*33${digit}`);
|
|
await waitUntil(
|
|
async () => (await api('GET', `/api/time-conditions/${hours.id}`)).overrideState === null,
|
|
'automatic',
|
|
);
|
|
console.log('M6 milestone check: PASS');
|
|
} catch (e) {
|
|
await p.shot('m6-check-fail').catch(() => {});
|
|
console.log(`FAIL: ${e.message}`);
|
|
process.exitCode = 1;
|
|
} finally {
|
|
await p.close();
|
|
phones.stop();
|
|
}
|