/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
161 lines
6.3 KiB
TypeScript
161 lines
6.3 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, 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, 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);
|
|
});
|
|
});
|