Spec 11 §4: contacts.provider and contacts.contrack (local address only, company token write-only), each person's own token (users.contrack_token_enc, migration 0010) in My settings, books read from Contrack with a 5-minute cache, Add to contacts and accepted shares created in Contrack, the company account on desk phones. Setup wizard step 8 Contacts. deploy/contrack (2.0.2, pinned), Caddy/nginx blocks, decision 0003, admin guide Contacts. Tested against a fake and a real Contrack 2.0.2 (contrack-live.test.ts).
139 lines
6.1 KiB
TypeScript
139 lines
6.1 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||
// M6-T7: GET/PATCH /api/me (DND, forwards, voicemail options) and SpToggle DND/CFU (04 §5.3).
|
||
import { eq } from 'drizzle-orm';
|
||
import type { LightMyRequestResponse } from 'fastify';
|
||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||
import type { LiveMessage } from '@selfphoneng/shared';
|
||
import { auditLog, extensions } 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)('/api/me settings and phone toggles', () => {
|
||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||
let admin: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||
let dan: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||
let danId: string;
|
||
const live: LiveMessage[] = [];
|
||
const toggle = (Key: string, Ext: string, Value: string) =>
|
||
handleToggle(
|
||
{ db: t.db, ami: t.ami, live: t.live, log: t.app.log },
|
||
{ Event: 'UserEvent', UserEvent: 'SpToggle', Key, Ext, Value },
|
||
);
|
||
|
||
beforeAll(async () => {
|
||
t = await createTestApp();
|
||
await t.clearMustChange(ADMIN.email);
|
||
const as = (cookie: string) => (method: string, url: string, payload?: unknown) =>
|
||
t.app.inject({
|
||
method: method as 'GET',
|
||
url,
|
||
payload: payload as object,
|
||
headers: { ...H, cookie },
|
||
});
|
||
admin = as(await t.login(ADMIN.email, ADMIN.password));
|
||
danId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
|
||
await admin('POST', '/api/extensions', { number: '101', name: 'Dan Doe', userId: danId });
|
||
await admin('POST', '/api/extensions', { number: '102', name: 'Sue Smith' });
|
||
const ext = (await admin('GET', '/api/extensions'))
|
||
.json()
|
||
.items.find((e: { number: string }) => e.number === '101');
|
||
await admin('PATCH', `/api/extensions/${ext.id}`, { outboundClass: 'local' });
|
||
// Outbound rules exist once a trunk does (M3 defaults: toll-free, US/Canada national, intl).
|
||
await admin('POST', '/api/trunks', {
|
||
name: 'voip.ms',
|
||
serverHost: '127.0.0.1',
|
||
username: '123456_pbx',
|
||
password: 'pw',
|
||
});
|
||
dan = as(await t.login('dan@example.com', 'dan-password-123'));
|
||
t.live.on('message', (m) => live.push(m));
|
||
});
|
||
afterAll(() => t?.close());
|
||
|
||
it('GET /api/me shows my extension and settings', async () => {
|
||
expect((await dan('GET', '/api/me')).json()).toEqual({
|
||
extension: { number: '101', name: 'Dan Doe' },
|
||
dnd: false,
|
||
cfu: null,
|
||
cfb: null,
|
||
cfna: null,
|
||
voicemailEnabled: true,
|
||
voicemailEmail: true,
|
||
voicemailAttachAudio: true,
|
||
voicemailDeleteAfterEmail: false,
|
||
callReport: 'off',
|
||
callReportTime: '08:00',
|
||
callReportWeekday: 1,
|
||
callReportSummaries: false,
|
||
contrack: null,
|
||
});
|
||
});
|
||
|
||
it('DND and forwards: stored, then written to AstDB', async () => {
|
||
t.ami.actions.length = 0;
|
||
const r = await dan('PATCH', '/api/me', { dnd: true, cfb: '102', cfna: '1-800-555-0100' });
|
||
expect(r.statusCode, r.body).toBe(200);
|
||
expect(r.json()).toMatchObject({ dnd: true, cfb: '102', cfna: '18005550100' });
|
||
expect(t.ami.actions).toEqual([
|
||
{ Action: 'DBPut', Family: 'SP', Key: 'DND/101', Val: '1' },
|
||
{ Action: 'DBPut', Family: 'SP', Key: 'CFB/101', Val: '102' },
|
||
{ Action: 'DBPut', Family: 'SP', Key: 'CFNA/101', Val: '18005550100' },
|
||
]);
|
||
const off = await dan('PATCH', '/api/me', { dnd: false, cfb: null });
|
||
expect(off.json()).toMatchObject({ dnd: false, cfb: null, cfna: '18005550100' });
|
||
expect(t.ami.actions.slice(-2)).toEqual([
|
||
{ Action: 'DBDel', Family: 'SP', Key: 'DND/101' },
|
||
{ Action: 'DBDel', Family: 'SP', Key: 'CFB/101' },
|
||
]);
|
||
});
|
||
|
||
it('forward targets: my calling permission applies; no self, no unknown extension', async () => {
|
||
const fwd = (cfu: string) => dan('PATCH', '/api/me', { cfu });
|
||
const national = await fwd('(312) 555-1234'); // class "local" may not call national numbers
|
||
expect(national.statusCode).toBe(400);
|
||
expect(national.json().error.message).toMatch(/calling permission doesn't allow/);
|
||
expect((await fwd('101')).json().error.message).toMatch(/yourself/);
|
||
expect((await fwd('199')).json().error.message).toMatch(/isn't an extension/);
|
||
expect((await fwd('102')).json().cfu).toBe('102');
|
||
});
|
||
|
||
it('voicemail options and PIN (trivial PINs refused; voicemail.conf regenerated)', async () => {
|
||
expect((await dan('PATCH', '/api/me', { voicemailPin: '1111' })).statusCode).toBe(400);
|
||
const r = await dan('PATCH', '/api/me', { voicemailPin: '4826', voicemailEmail: false });
|
||
expect(r.json().voicemailEmail).toBe(false);
|
||
const [row] = await t.db.select().from(extensions).where(eq(extensions.number, '101'));
|
||
expect(row!.voicemailEmail).toBe(false);
|
||
});
|
||
|
||
it('*78 / *79 from the phone update DND and everyone’s presence', async () => {
|
||
await toggle('DND', '101', '1');
|
||
expect((await dan('GET', '/api/me')).json().dnd).toBe(true);
|
||
expect(
|
||
live.some((m) => m.type === 'extension.updated' && m.data.number === '101' && m.data.dnd),
|
||
).toBe(true);
|
||
await toggle('DND', '101', '0');
|
||
expect((await dan('GET', '/api/me')).json().dnd).toBe(false);
|
||
});
|
||
|
||
it('*72<number>: allowed targets are kept; refused ones are undone, audited, toasted', async () => {
|
||
await toggle('CFU', '101', '102');
|
||
expect((await dan('GET', '/api/me')).json().cfu).toBe('102');
|
||
t.ami.actions.length = 0;
|
||
await toggle('CFU', '101', '13125551234');
|
||
expect((await dan('GET', '/api/me')).json().cfu).toBeNull();
|
||
expect(t.ami.actions).toEqual([{ Action: 'DBDel', Family: 'SP', Key: 'CFU/101' }]);
|
||
expect(live).toContainEqual({
|
||
type: 'toast',
|
||
data: { userId: danId, message: expect.stringMatching(/^Call forwarding not turned on/) },
|
||
});
|
||
const audits = await t.db
|
||
.select()
|
||
.from(auditLog)
|
||
.where(eq(auditLog.action, 'forward_rejected'));
|
||
expect(audits).toHaveLength(1);
|
||
await toggle('CFU', '101', '');
|
||
expect((await dan('GET', '/api/me')).json().cfu).toBeNull();
|
||
});
|
||
});
|