Feature codes per 03 §6.13: *78/*79 DND, *72<number>/*73 forward always, **<ext> directed pickup, *8 group pickup (also as a dialplan line, since pickupexten only catches a phone's own INVITE), *43 echo test, *65 say my extension; queue login and intercom stay for M8. SpToggle DND/CFU update user_runtime_state; a *72 target the user's calling permission doesn't allow is removed from AstDB, audited (forward_rejected) and sent to the user as a toast (new private live message). Forward targets are checked the same way everywhere (services/forwards.ts: an existing internal number, or an outside number some outbound rule allows them). GET/PATCH /api/me: DND, cfu/cfb/cfna, voicemail email options and PIN; PostgreSQL first, then AstDB; AstDB resync on connect now includes DND and forwards. Web: /me/settings (forwarding, DND, voicemail PIN and email options), a status card on Home, toasts. e2e m6-t7 with real phones covers every code, the refused forward toast, and the settings page reaching AstDB; it found the settings forms losing "Saved." on remount. Task: M6-T7
225 lines
8.9 KiB
JavaScript
225 lines
8.9 KiB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||
// M6-T7 with real phones: feature codes *78/*79 (DND), *72/*73 (forward always), *43 (echo),
|
||
// *65 (say my extension), **N (directed pickup), *8 (group pickup); a refused *72 shows a toast;
|
||
// forwarding set on /me/settings reaches AstDB and routes calls.
|
||
// User fwd@example.com has desk phone 198; 199 auto-answers; 200 rings without answering.
|
||
// Runs on the m2-live setup after m1-t4.mjs.
|
||
// Usage: node scripts/e2e/m6-t7.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
|
||
import assert from 'node:assert/strict';
|
||
import { spawnSync } from 'node:child_process';
|
||
import { launch } from './cdp.mjs';
|
||
import { startPhones } from './phones.mjs';
|
||
|
||
const NET = 'sp-live';
|
||
const AST = 'sp-live-ast';
|
||
const USER = { email: 'fwd@example.com', password: 'fwd-new-password-123' };
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
const cli = (cmd) =>
|
||
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? '';
|
||
// Asterisk colors its log (e.g. the arguments in "Executing" lines): strip the escape codes.
|
||
// eslint-disable-next-line no-control-regex -- matching ANSI escapes is the point
|
||
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) => {
|
||
const r = await fetch(`${base}${url}`, {
|
||
method,
|
||
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng', cookie },
|
||
body: body && JSON.stringify(body),
|
||
});
|
||
const json = await r.json();
|
||
assert.ok(r.ok, `${method} ${url}: ${r.status} ${JSON.stringify(json)}`);
|
||
return json;
|
||
};
|
||
const created = await api('POST', '/api/users', { email: USER.email, displayName: 'Fay Forward' });
|
||
const desk = async (number, name, extra = {}) => {
|
||
const e = await api('POST', '/api/extensions', { number, name, ...extra });
|
||
return (await api('GET', `/api/extensions/${e.id}/sip-credentials`)).password;
|
||
};
|
||
const phones = startPhones({
|
||
name: 'sp-live-phones-m6t7',
|
||
net: NET,
|
||
ast: AST,
|
||
phones: [
|
||
{
|
||
ext: '198',
|
||
password: await desk('198', 'Fay Forward', {
|
||
userId: created.user.id,
|
||
outboundClass: 'internal',
|
||
pickupGroup: 'front',
|
||
}),
|
||
answer: 'auto',
|
||
},
|
||
{ ext: '199', password: await desk('199', 'Gus Target'), answer: 'auto', toneHz: 660 },
|
||
{
|
||
ext: '200',
|
||
password: await desk('200', 'Hal Ringing', { pickupGroup: 'front' }),
|
||
answer: 'manual',
|
||
},
|
||
],
|
||
});
|
||
|
||
const waitUntil = async (fn, what, ms = 20_000) => {
|
||
const end = Date.now() + ms;
|
||
while (Date.now() < end) {
|
||
const v = await fn();
|
||
if (v) return v;
|
||
await sleep(200);
|
||
}
|
||
throw new Error(`timeout waiting for: ${what}`);
|
||
};
|
||
const channels = () =>
|
||
cli('core show channels concise')
|
||
.split('\n')
|
||
.filter((l) => /^(PJSIP|Local)\//.test(l))
|
||
.map((l) => {
|
||
const f = l.split('!');
|
||
return { name: f[0], bridge: f[12] };
|
||
});
|
||
const hangupAll = async () => {
|
||
cli('channel request hangup all');
|
||
await waitUntil(() => channels().length === 0, 'calls ended');
|
||
await sleep(500);
|
||
};
|
||
/** Phone 198 dials a code (it auto-answers the originate, then runs sp-from-ext). */
|
||
async function dial(code, done) {
|
||
const at = astLog().length;
|
||
cli(`channel originate PJSIP/198 extension ${code}@sp-from-ext`);
|
||
await waitUntil(() => done(astLog().slice(at)), `${code} ran`);
|
||
await hangupAll();
|
||
}
|
||
const me = () => fetch(`${base}/api/me`, { headers: { cookie: userCookie } }).then((r) => r.json());
|
||
const astdb = () => cli('database show SP');
|
||
let userCookie = '';
|
||
|
||
const p = await launch({ shotsDir });
|
||
try {
|
||
await waitUntil(
|
||
() =>
|
||
['198', '199', '200'].every((n) =>
|
||
new RegExp(`${n}/sip:.*Avail`).test(cli('pjsip show contacts')),
|
||
),
|
||
'phones registered',
|
||
180_000,
|
||
);
|
||
// The user signs in (first login: change password) and opens Settings.
|
||
await p.goto(`${base}/login`);
|
||
await p.waitFor(`!!document.querySelector('input[type=email]')`);
|
||
await p.fill('Email', USER.email);
|
||
await p.fill('Password', created.temporaryPassword);
|
||
await p.click('Sign in');
|
||
await p.waitFor(`location.pathname === '/change-password'`);
|
||
await p.fill('Current password', created.temporaryPassword);
|
||
await p.fill('New password', USER.password);
|
||
await p.fill('Repeat new password', USER.password);
|
||
await p.click('Change password');
|
||
await p.waitFor(`location.pathname === '/me'`);
|
||
userCookie = (
|
||
await fetch(`${base}/api/auth/login`, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' },
|
||
body: JSON.stringify(USER),
|
||
})
|
||
).headers
|
||
.getSetCookie()
|
||
.map((c) => c.split(';')[0])
|
||
.join('; ');
|
||
await p.waitFor(`document.body.innerText.includes('Not forwarding.')`, 'status card');
|
||
|
||
// *78: DND on from the phone → database, AstDB, the page's switch; calls get busy → voicemail.
|
||
await dial('*78', (l) => /Playing 'do-not-disturb/.test(l));
|
||
await waitUntil(async () => (await me()).dnd, 'DND in the database');
|
||
assert.match(astdb(), /\/SP\/DND\/198\s*:\s*1/);
|
||
await p.waitFor(`document.querySelector('[role=switch]')?.checked === true`, 'switch on', 10_000);
|
||
let at = astLog().length;
|
||
cli('channel originate Local/198@sp-from-ext application Wait 10');
|
||
await waitUntil(
|
||
() => /VoiceMail\("[^"]+", "198@default,b"\)/.test(astLog().slice(at)),
|
||
'busy voicemail',
|
||
);
|
||
await hangupAll();
|
||
await dial('*79', (l) => /Playing 'do-not-disturb/.test(l));
|
||
await waitUntil(async () => !(await me()).dnd, 'DND off');
|
||
console.log('*78/*79: DND in database, AstDB and the page; a call meanwhile went to voicemail');
|
||
|
||
// *72199: forward everything to 199; a call to 198 rings 199.
|
||
await dial('*72199', (l) => /Playing 'call-fwd-unconditional/.test(l));
|
||
await waitUntil(async () => (await me()).cfu === '199', 'CFU stored');
|
||
at = astLog().length;
|
||
cli('channel originate Local/198@sp-from-ext application Wait 10');
|
||
await waitUntil(() => /Called PJSIP\/199\//.test(astLog().slice(at)), 'forwarded to 199');
|
||
assert.doesNotMatch(astLog().slice(at), /Called PJSIP\/198\//);
|
||
await hangupAll();
|
||
await dial('*73', (l) => /Playing 'call-fwd-unconditional/.test(l));
|
||
await waitUntil(async () => (await me()).cfu === null, 'CFU off');
|
||
console.log('*72199 / *73: a call to 198 went to 199 while forwarded');
|
||
|
||
// *72 to an outside number: 198 may only call internally → refused, undone, toast.
|
||
await dial('*7213125551234', (l) => /Playing 'call-fwd-unconditional/.test(l));
|
||
await p.waitFor(
|
||
`document.body.innerText.includes('Call forwarding not turned on')`,
|
||
'toast',
|
||
10_000,
|
||
);
|
||
assert.equal((await me()).cfu, null);
|
||
assert.doesNotMatch(astdb(), /CFU\/198/);
|
||
await p.shot('m6-t7-01-toast');
|
||
console.log('*72 to an outside number: refused, removed from AstDB, toast shown');
|
||
|
||
// Settings page: "when you don't answer, forward to 199".
|
||
await p.click('Settings');
|
||
await p.fill('When you don’t answer, forward to', '199');
|
||
await p.click('Save forwarding');
|
||
await p.waitFor(`document.body.innerText.includes('Saved.')`);
|
||
assert.match(astdb(), /\/SP\/CFNA\/198\s*:\s*199/);
|
||
await p.fill('Forward all calls to', '(312) 555-1234');
|
||
await p.click('Save forwarding');
|
||
await p.waitFor(
|
||
`/doesn't allow|can't go out/.test(document.body.innerText)`,
|
||
'refused in the form',
|
||
);
|
||
await p.shot('m6-t7-02-settings');
|
||
console.log('settings page: no-answer forward saved to AstDB; an outside number refused');
|
||
|
||
await dial('*43', (l) => /Playing 'demo-echotest/.test(l)); // Echo() follows the long prompt
|
||
await dial('*65', (l) => /SayDigits\("[^"]+", "198"\)/.test(l));
|
||
console.log('*43 echo test and *65 say my extension');
|
||
|
||
// Pickup: 200 rings (never answers); 198 takes it with **200, then with *8 (same group).
|
||
for (const code of ['**200', '*8']) {
|
||
cli('channel originate Local/200@sp-from-ext application Wait 30');
|
||
await waitUntil(() => channels().some((c) => c.name.startsWith('PJSIP/200-')), '200 rings');
|
||
at = astLog().length;
|
||
cli(`channel originate PJSIP/198 extension ${code}@sp-from-ext`);
|
||
await waitUntil(
|
||
() =>
|
||
/Pickup|picked up/i.test(astLog().slice(at)) &&
|
||
!channels().some((c) => c.name.startsWith('PJSIP/200-')),
|
||
`${code} picks up`,
|
||
);
|
||
console.log(`${code}: 198 picked up the call ringing at 200`);
|
||
await hangupAll();
|
||
}
|
||
|
||
console.log('M6-T7 (feature codes, forwarding): PASS');
|
||
} catch (e) {
|
||
await p.shot('m6-t7-fail').catch(() => {});
|
||
console.log(`FAIL: ${e.message}`);
|
||
console.log(`browser errors: ${JSON.stringify(p.consoleErrors)}`);
|
||
process.exitCode = 1;
|
||
} finally {
|
||
await p.close();
|
||
phones.stop();
|
||
}
|