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
161 lines
6.4 KiB
TypeScript
161 lines
6.4 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// M6-T4: /api/time-conditions, /api/holidays, override codes (03 §6.10, §6.13, 04 §5.3, §5.5).
|
|
import { readFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { eq } from 'drizzle-orm';
|
|
import type { LightMyRequestResponse } from 'fastify';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { timeConditions } from '../src/db/schema.ts';
|
|
import { handleToggle } from '../src/services/toggles.ts';
|
|
import { hasTestDb } from './db.ts';
|
|
import { ADMIN, H, createTestApp } from './app.ts';
|
|
|
|
describe.skipIf(!hasTestDb)('office hours and holidays', () => {
|
|
let t: Awaited<ReturnType<typeof createTestApp>>;
|
|
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
|
let dan: string;
|
|
let id: string;
|
|
const dialplan = async () => {
|
|
await t.app.generator.apply({ trigger: 'test' });
|
|
return readFile(join(t.genDir, 'extensions_selfphoneng.conf'), 'utf8');
|
|
};
|
|
const weekdays = { days: ['mon', 'tue', 'wed', 'thu', 'fri'], start: '08:00', end: '17:00' };
|
|
|
|
beforeAll(async () => {
|
|
t = await createTestApp();
|
|
await t.clearMustChange(ADMIN.email);
|
|
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
|
req = (method, url, payload) =>
|
|
t.app.inject({
|
|
method: method as 'GET',
|
|
url,
|
|
payload: payload as object,
|
|
headers: { ...H, cookie },
|
|
});
|
|
dan = (await req('POST', '/api/extensions', { number: '101', name: 'Dan Doe' })).json().id;
|
|
});
|
|
afterAll(() => t?.close());
|
|
|
|
it('creates office hours (company time zone by default) with a live status', async () => {
|
|
const r = await req('POST', '/api/time-conditions', {
|
|
name: 'Business hours',
|
|
openRanges: [weekdays],
|
|
overrideDigit: 1,
|
|
openDestination: { type: 'user', id: dan },
|
|
closedDestination: { type: 'voicemail', id: dan },
|
|
});
|
|
expect(r.statusCode, r.body).toBe(201);
|
|
id = r.json().id;
|
|
expect(r.json()).toMatchObject({
|
|
timezone: 'America/Chicago',
|
|
useHolidays: true,
|
|
overrideState: null,
|
|
holidayDestination: null,
|
|
status: { reason: 'hours' },
|
|
});
|
|
const conf = await dialplan();
|
|
expect(conf).toContain(`[sp-tc-${id}]`);
|
|
expect(conf).toContain('GotoIfTime(08:00-16:59,mon&tue&wed&thu&fri,*,*,America/Chicago?open)');
|
|
expect(conf).toContain(`exten => *311,1,Set(DB(SP/TCOVR/${id})=open)`);
|
|
});
|
|
|
|
it('validates ranges, time zone, digit and destinations', async () => {
|
|
const post = (body: object) =>
|
|
req('POST', '/api/time-conditions', {
|
|
name: 'X',
|
|
openDestination: { type: 'hangup' },
|
|
closedDestination: { type: 'hangup' },
|
|
...body,
|
|
});
|
|
expect(
|
|
(await post({ openRanges: [{ ...weekdays, start: '17:00', end: '08:00' }] })).statusCode,
|
|
).toBe(400);
|
|
expect((await post({ openRanges: [{ ...weekdays, end: '25:00' }] })).statusCode).toBe(400);
|
|
expect((await post({ timezone: 'Mars/Olympus' })).statusCode).toBe(400);
|
|
expect((await post({ overrideDigit: 1 })).statusCode).toBe(409);
|
|
const self = await req('PATCH', `/api/time-conditions/${id}`, {
|
|
closedDestination: { type: 'time_condition', id },
|
|
});
|
|
expect(self.statusCode).toBe(400);
|
|
});
|
|
|
|
it('force closed / automatic: database, AstDB and status follow', async () => {
|
|
const closed = await req('POST', `/api/time-conditions/${id}/override`, { state: 'closed' });
|
|
expect(closed.json()).toMatchObject({
|
|
overrideState: 'closed',
|
|
status: { now: 'closed', reason: 'override' },
|
|
});
|
|
expect(t.ami.actions.at(-1)).toEqual({
|
|
Action: 'DBPut',
|
|
Family: 'SP',
|
|
Key: `TCOVR/${id}`,
|
|
Val: 'closed',
|
|
});
|
|
const auto = await req('POST', `/api/time-conditions/${id}/override`, { state: 'auto' });
|
|
expect(auto.json().overrideState).toBeNull();
|
|
expect(t.ami.actions.at(-1)).toEqual({ Action: 'DBDel', Family: 'SP', Key: `TCOVR/${id}` });
|
|
});
|
|
|
|
it('*311 from a phone (SpToggle TCOVR) updates the database', async () => {
|
|
await handleToggle(
|
|
{ db: t.db, ami: t.ami, live: t.live, log: t.app.log },
|
|
{ Event: 'UserEvent', UserEvent: 'SpToggle', Key: 'TCOVR', Id: id, Value: 'open' },
|
|
);
|
|
const [row] = await t.db.select().from(timeConditions).where(eq(timeConditions.id, id));
|
|
expect(row!.overrideState).toBe('open');
|
|
await handleToggle(
|
|
{ db: t.db, ami: t.ami, live: t.live, log: t.app.log },
|
|
{ Event: 'UserEvent', UserEvent: 'SpToggle', Key: 'TCOVR', Id: id, Value: 'auto' },
|
|
);
|
|
expect((await req('GET', `/api/time-conditions/${id}`)).json().overrideState).toBeNull();
|
|
});
|
|
|
|
it('holidays: add, US federal import (no duplicates), they reach the dialplan', async () => {
|
|
const bad = await req('POST', '/api/holidays', {
|
|
name: 'Backwards',
|
|
startDate: '2026-12-26',
|
|
endDate: '2026-12-24',
|
|
});
|
|
expect(bad.statusCode).toBe(400);
|
|
const xmas = await req('POST', '/api/holidays', {
|
|
name: 'Christmas',
|
|
startDate: '2026-12-25',
|
|
endDate: '2026-12-25',
|
|
recurringYearly: true,
|
|
});
|
|
expect(xmas.statusCode).toBe(201);
|
|
expect((await req('POST', '/api/holidays/import-us-federal', { year: 2026 })).json()).toEqual({
|
|
added: 10, // Christmas (Dec 25) is already listed
|
|
});
|
|
expect((await req('POST', '/api/holidays/import-us-federal', { year: 2026 })).json()).toEqual({
|
|
added: 0,
|
|
});
|
|
expect((await req('GET', '/api/holidays')).json().items).toHaveLength(11);
|
|
const conf = await dialplan();
|
|
expect(conf).toContain('GotoIfTime(*,*,25,dec,America/Chicago?holiday)');
|
|
expect(conf).toContain('GotoIfTime(*,*,26,nov,America/Chicago?holiday)'); // Thanksgiving
|
|
});
|
|
|
|
it('delete is refused while a phone number points at it', async () => {
|
|
const trunkId = (
|
|
await req('POST', '/api/trunks', {
|
|
name: 'voip.ms',
|
|
serverHost: '127.0.0.1',
|
|
username: '123456_pbx',
|
|
password: 'pw',
|
|
})
|
|
).json().id;
|
|
const did = (
|
|
await req('POST', '/api/dids', {
|
|
number: '3125551234',
|
|
trunkId,
|
|
destination: { type: 'time_condition', id },
|
|
})
|
|
).json();
|
|
expect(did.destinationSummary).toBe('Office hours Business hours');
|
|
expect((await req('DELETE', `/api/time-conditions/${id}`)).statusCode).toBe(409);
|
|
await req('PATCH', `/api/dids/${did.id}`, { destination: { type: 'hangup' } });
|
|
expect((await req('DELETE', `/api/time-conditions/${id}`)).statusCode).toBe(200);
|
|
});
|
|
});
|