Files
selfphoneng/scripts/e2e/m6-t7.mjs
T
iamdoubz 57e0e3dac2 featurecodes: DND, forwarding, pickup, echo, say-ext; /me settings
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
2026-10-18 11:19:21 -05:00

225 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
}