Files
selfphoneng/scripts/e2e/m6-t4.mjs
T
iamdoubz 108547046c timeconditions: office hours, holidays, override codes, weekly grid
/api/time-conditions (company time zone by default, ranges validated,
unique override digit, never pointing at itself) with a live status
("open/closed/holiday now", same logic as the dialplan, shared), and
POST /:id/override, which writes PostgreSQL then AstDB SP/TCOVR.
/api/holidays CRUD plus import-us-federal (observed dates, computed in
code, skips days already listed). services/astdb.ts resyncs AstDB on
every AMI connect (PostgreSQL wins); services/toggles.ts applies
SpToggle TCOVR from *31N/*32N/*33N.

Generator: sp-tc-{id} per 03 §6.10 (override, holidays within the next
year, ranges rendered a minute short because GotoIfTime's end is
inclusive), sp-tc-override with a no-service fallback, admin-only
*3[123]X in sp-featurecodes, destApp time_condition. The date changing
in the company time zone triggers a forced apply so holidays roll over.
Golden cases holidays and ivr-business-hours.

Office hours page: click-and-drag half-hour grid plus an exact range
list, destinations, holiday destination, override key, status chip with
Force open / Force closed / Automatic; Holidays tab with the US federal
import. e2e m6-t4 found two bugs (pointer capture release throwing,
drawer overflow) and checks routing, AstDB, *321/*331 from an admin
phone, refusal for others, and the import reaching the dialplan.

Task: M6-T4
2026-10-18 10:56:28 -05:00

229 lines
9.6 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-T4 in a browser + real calls: office hours built in the UI (open → 195, closed → 196, override
// key 1); Force closed / Force open route calls and set AstDB; *311 from an admin's phone (197)
// forces open and the page follows; the US federal holidays import.
// Runs on the m2-live setup after m1-t4.mjs.
// Usage: node scripts/e2e/m6-t4.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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const cli = (cmd) =>
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? '';
const astLog = () => spawnSync('docker', ['logs', AST], { encoding: 'utf8' }).stdout ?? '';
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 desk = async (number, name, userId) => {
const e = await api('POST', '/api/extensions', { number, name, ...(userId ? { userId } : {}) });
return (await api('GET', `/api/extensions/${e.id}/sip-credentials`)).password;
};
const admin = (await api('GET', '/api/users')).items.find((u) => u.email === email);
const phones = startPhones({
name: 'sp-live-phones-m6t4',
net: NET,
ast: AST,
phones: [
{ ext: '195', password: await desk('195', 'Olive Open'), answer: 'auto' },
{ ext: '196', password: await desk('196', 'Cliff Closed'), answer: 'auto', toneHz: 660 },
// The admin's own desk phone: allowed to use the override codes.
...(admin.extension
? []
: [{ ext: '197', password: await desk('197', 'Admin Desk', admin.id), answer: 'auto' }]),
],
});
const waitUntil = async (fn, what, ms = 20_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 pick = (label, text) =>
p.evaluate(`(() => {
const s = [...document.querySelectorAll('dialog[open] select')].find(s => s.getAttribute('aria-label') === ${JSON.stringify(label)} || s.labels?.[0]?.textContent === ${JSON.stringify(label)});
if (!s) throw new Error('no select ' + ${JSON.stringify(label)});
const o = [...s.options].find(o => o.textContent.includes(${JSON.stringify(text)}));
if (!o) throw new Error('no option ' + ${JSON.stringify(text)});
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set.call(s, o.value);
s.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()`);
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 dropped straight into the office hours context; returns who got rung. */
async function callHours(id) {
const at = astLog().length;
cli(`channel originate Local/s@sp-tc-${id} application Wait 20`);
let who = null;
await waitUntil(() => {
who = /Called PJSIP\/(19[56])\//.exec(astLog().slice(at))?.[1] ?? null;
return who;
}, 'someone rings');
await hangupAll();
return who;
}
const chip = () => p.evaluate(`document.querySelector('article [role=status]')?.textContent`);
const p = await launch({ shotsDir });
try {
await waitUntil(
() =>
['195', '196', '197'].every((n) =>
new RegExp(`${n}/sip:.*Avail`).test(cli('pjsip show contacts')),
),
'phones registered',
180_000,
);
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('Office hours');
await p.click('Add office hours');
await p.waitFor(`!!document.querySelector('dialog[open]')`);
await p.fill('Name', 'Business hours', 'dialog[open]');
await p.waitFor(
`[...document.querySelectorAll('dialog[open] label')].find(l => l.textContent === 'Time zone')?.control.value === 'America/Chicago'`,
'company time zone filled in',
);
// Paint Saturday 10:00–11:00 on the grid (two half-hour cells, a drag).
// One event per step, like a real drag across frames (React re-renders in between).
const pointer = (i, type) =>
p.evaluate(
`document.querySelector('[data-cell="sat-${i}"]').dispatchEvent(new PointerEvent('${type}', { bubbles: true })), true`,
);
await pointer(20, 'pointerdown');
await sleep(100);
await pointer(21, 'pointerover'); // React builds onPointerEnter from pointerover
await sleep(100);
await pointer(21, 'pointerup');
await p.waitFor(
`document.querySelector('dialog[open]').innerText.includes('Sat 10:00–11:00')`,
'grid → ranges',
);
await pick('When open: type', 'Ring a user');
await pick('When open: user', 'Olive Open');
await pick('When closed: type', 'Ring a user');
await pick('When closed: user', 'Cliff Closed');
await pick('Phone override key', '1 (');
await p.shot('m6-t4-01-form');
await p.click('Save', 'dialog[open]');
await p.waitFor(
`!document.querySelector('dialog[open]') && !!document.querySelector('article')`,
'saved',
);
const tc = (await api('GET', '/api/time-conditions')).items[0];
assert.deepEqual(tc.openRanges, [
{ days: ['mon', 'tue', 'wed', 'thu', 'fri'], start: '08:00', end: '17:00' },
{ days: ['sat'], start: '10:00', end: '11:00' },
]);
await waitUntil(() => cli(`dialplan show s@sp-tc-${tc.id}`).includes('GotoIfTime'), 'dialplan');
console.log(`office hours saved: ${await chip()} (Mon–Fri 08–17, Sat 10–11)`);
await p.click('Force closed');
await p.waitFor(
`document.querySelector('article [role=status]')?.textContent === 'Closed now (forced)'`,
);
assert.match(cli('database show SP'), new RegExp(`/SP/TCOVR/${tc.id}\\s*:\\s*closed`));
assert.equal(await callHours(tc.id), '196', 'forced closed → the closed destination');
console.log('Force closed: AstDB set, a call goes to 196');
await p.click('Force open');
await p.waitFor(
`document.querySelector('article [role=status]')?.textContent === 'Open now (forced)'`,
);
assert.equal(await callHours(tc.id), '195', 'forced open → the open destination');
console.log('Force open: a call goes to 195');
await p.click('Automatic');
await p.waitFor(
`!document.querySelector('article [role=status]')?.textContent.includes('forced')`,
);
assert.doesNotMatch(cli('database show SP'), /TCOVR/);
console.log(`Automatic: AstDB key gone, ${await chip()}`);
// *321 from the admin's desk phone forces closed; the page follows after a refresh.
cli('channel originate PJSIP/197 extension *321@sp-from-ext');
await waitUntil(
async () => (await api('GET', `/api/time-conditions/${tc.id}`)).overrideState === 'closed',
'*321 reached the database',
);
await p.goto(`${base}/admin/hours`);
await p.waitFor(
`document.querySelector('article [role=status]')?.textContent === 'Closed now (forced)'`,
);
console.log('*321 from the admin phone: forced closed in Asterisk and in the page');
await hangupAll();
cli('channel originate PJSIP/197 extension *331@sp-from-ext');
await waitUntil(
async () => (await api('GET', `/api/time-conditions/${tc.id}`)).overrideState === null,
'*331 back to automatic',
);
await hangupAll();
// A non-admin phone is refused.
const before = astLog().length;
cli('channel originate PJSIP/195 extension *311@sp-from-ext');
await waitUntil(() => /Playing 'ss-noservice/.test(astLog().slice(before)), 'non-admin refused');
assert.equal((await api('GET', `/api/time-conditions/${tc.id}`)).overrideState, null);
console.log('*311 from a non-admin phone: "no service", nothing changed');
await hangupAll();
await p.click('Holidays');
await p.evaluate(
`[...document.querySelectorAll('button')].find(b => b.textContent.startsWith('Add US federal holidays for 2026'))?.click() ?? (() => { const s = document.getElementById('us-year'); Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set.call(s, '2026'); s.dispatchEvent(new Event('change', { bubbles: true })); })(), true`,
);
await p.waitFor(
`[...document.querySelectorAll('button')].some(b => b.textContent === 'Add US federal holidays for 2026')`,
);
await p.click('Add US federal holidays for 2026');
await p.waitFor(`document.body.innerText.includes('Added 11.')`, 'imported');
await p.waitFor(`document.body.innerText.includes('Thanksgiving Day')`);
await p.shot('m6-t4-02-holidays');
await waitUntil(
() => /26,nov/.test(cli(`dialplan show s@sp-tc-${tc.id}`)),
'holidays in the dialplan',
);
console.log('US federal holidays 2026 imported and in the dialplan');
console.log('M6-T4 (office hours): PASS');
} catch (e) {
await p.shot('m6-t4-fail').catch(() => {});
console.log(`FAIL: ${e.message}`);
console.log(`browser errors: ${JSON.stringify(p.consoleErrors)}`);
process.exitCode = 1;
} finally {
await p.close();
phones.stop();
}