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

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