Files
selfphoneng/apps/api/test/timeconditions.test.ts
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

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);
});
});