Files
selfphoneng/apps/api/test/jobs.test.ts
T
iamdoubz 0ff0f9292b
ci / check (push) Successful in 1m20s
ci / images (push) Successful in 20s
system: information panel and event log; about: download this build's source
System page laid out after 3CX's admin dashboard: a System information panel (version,
host name, company, public IP, LAN IPs, ports, telephony, restart needed, copy buttons,
how to restart) and an Event log panel (system_events, 90 days: trunk down/up, Asterisk
down/up, restart needed, config failed, 911, public IP changed, backups, disk) with Load
more and a CSV download.

About (/about, no sign-in, linked from the login page, My phone and System): AGPL-3.0
notice, credits, the repository link, and "Code v<version>", which downloads
selfphone.tar.gz: the api image now tars its own build context (no .env, data,
node_modules, build output or .git) before installing anything.

Task: M11-T7
2026-10-20 11:26:34 -05:00

215 lines
7.6 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// M11-T4: background jobs (04 §8): retention, trunk/disk/public IP watches.
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import type { LightMyRequestResponse } from 'fastify';
import { eq } from 'drizzle-orm';
import type { TrunkLive } from '@selfphoneng/shared';
import { createJobs, retentionCleanup, type JobDeps } from '../src/jobs.ts';
import { extensions, sessions, systemEvents, voicemailMessages } from '../src/db/schema.ts';
import { getSetting, setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('background jobs', () => {
const events = (code: string) =>
t.db
.select({ severity: systemEvents.severity, message: systemEvents.message })
.from(systemEvents)
.where(eq(systemEvents.code, code))
.orderBy(systemEvents.id);
let t: Awaited<ReturnType<typeof createTestApp>>;
let req: (m: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let trunkId: string;
let clock = Date.parse('2026-10-19T12:00:00Z');
let reg: TrunkLive['registration'] = 'registered';
const scanned: string[] = [];
const deps = (): JobDeps => ({
config: t.app.config,
db: t.db,
ami: t.ami,
live: {
amiConnected: true,
snapshot: () => ({
extensions: [],
queues: [],
parked: [],
ami: { connected: true },
trunks: [{ trunkId, registration: reg, reachable: true, lastChange: '' }],
}),
},
generator: t.app.generator,
voicemail: { scan: async (ext) => void scanned.push(ext) },
send: t.app.sendMail,
log: t.app.log,
now: () => new Date(clock),
});
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 },
});
await req('POST', '/api/extensions', { number: '101', name: 'Dan' });
const r = await req('POST', '/api/trunks', {
name: 'voip.ms',
serverHost: '127.0.0.1',
username: '123456_pbx',
password: 'Sub-account pw!',
});
trunkId = r.json().id;
});
afterAll(() => t.close());
afterEach(() => vi.unstubAllGlobals());
it('retention removes old voicemail through Asterisk, and expired sessions', async () => {
const [ext] = await t.db.select().from(extensions).where(eq(extensions.number, '101'));
const msg = (msgId: string, days: number) => ({
extensionId: ext!.id,
msgId,
folder: 'INBOX',
durationS: 3,
receivedAt: new Date(Date.now() - days * 86_400_000),
filePath: `default/101/INBOX/${msgId}.wav`,
});
await t.db.insert(voicemailMessages).values([msg('old-1', 40), msg('new-1', 2)]);
const [u] = await t.db.select({ id: sessions.userId }).from(sessions);
await t.db.insert(sessions).values({
id: 'expired-session-hash',
userId: u!.id,
csrfToken: 'x',
expiresAt: new Date(Date.now() - 1000),
} as typeof sessions.$inferInsert);
await setSetting(t.db, 'voicemail.retentionDays', 0); // 0 keeps voicemail
t.ami.actions.length = 0;
await retentionCleanup(deps());
expect(t.ami.actions).toEqual([]);
expect(
await t.db.select().from(sessions).where(eq(sessions.id, 'expired-session-hash')),
).toEqual([]);
await setSetting(t.db, 'voicemail.retentionDays', 30);
await retentionCleanup(deps());
expect(t.ami.actions).toEqual([
{
Action: 'VoicemailRemove',
Context: 'default',
Mailbox: '101',
Folder: 'INBOX',
ID: 'old-1',
},
]);
expect(scanned).toEqual(['101']);
// The daily job waits for Asterisk (at start AMI connects a few seconds after the first run).
await t.db.update(voicemailMessages).set({ deletedAt: null }); // fake scan: still there
const j = createJobs(deps());
t.ami.connected = false;
t.ami.actions.length = 0;
await j.daily();
expect(t.ami.actions).toEqual([]);
t.ami.connected = true;
await j.daily();
await j.daily(); // once a day
expect(t.ami.actions.map((a) => a.ID)).toEqual(['old-1']);
});
it('trunkWatch emails admins once when a trunk is down for 5 minutes', async () => {
const j = createJobs(deps());
t.mails.length = 0;
reg = 'rejected';
await j.trunkWatch();
clock += 4 * 60_000;
await j.trunkWatch();
expect(t.mails).toEqual([]);
clock += 60_000;
await j.trunkWatch();
clock += 60_000;
await j.trunkWatch();
expect(t.mails.map((m) => [m.to, m.subject])).toEqual([
[[ADMIN.email], 'Trunk voip.ms is down'],
]);
// Back up, then down again: a new outage gets its own email.
reg = 'registered';
await j.trunkWatch();
reg = 'unregistered';
await j.trunkWatch();
clock += 5 * 60_000;
await j.trunkWatch();
expect(t.mails).toHaveLength(2);
// IP-auth trunks never register: never "down".
reg = 'unknown';
const k = createJobs(deps());
await k.trunkWatch();
clock += 10 * 60_000;
await k.trunkWatch();
expect(t.mails).toHaveLength(2);
});
it('publicIpWatch saves a changed IP, asks for a restart and emails admins', async () => {
const j = createJobs(deps());
vi.stubGlobal('fetch', async () => new Response('203.0.113.9\n'));
t.mails.length = 0;
await setSetting(t.db, 'network.publicIp', '203.0.113.5');
await j.publicIpWatch(); // off by default
expect(await getSetting(t.db, 'network.publicIp')).toBe('203.0.113.5');
await setSetting(t.db, 'network.publicIpWatch', true);
await j.publicIpWatch();
expect(await getSetting(t.db, 'network.publicIp')).toBe('203.0.113.9');
expect(await getSetting(t.db, 'system.restartRequired')).toBe(true);
expect(t.mails.map((m) => m.subject)).toEqual(['Public IP changed to 203.0.113.9']);
expect(await events('network.publicIpChanged')).toEqual([
{ severity: 'warning', message: 'Public IP changed from 203.0.113.5 to 203.0.113.9' },
]);
await j.publicIpWatch(); // unchanged: nothing
vi.stubGlobal('fetch', async () => new Response('<html>rate limited</html>'));
await j.publicIpWatch(); // garbage answer: keep the old IP
expect(await getSetting(t.db, 'network.publicIp')).toBe('203.0.113.9');
expect(t.mails).toHaveLength(1);
});
it('diskWatch emails admins once a day under 10% free', async () => {
let bavail = 50;
const fake = (async () => ({
bavail,
blocks: 100,
bsize: 1e9,
})) as unknown as JobDeps['statfs'];
const j = createJobs({ ...deps(), statfs: fake });
t.mails.length = 0;
await j.diskWatch();
expect(t.mails).toEqual([]);
bavail = 6;
await j.diskWatch();
await j.diskWatch();
expect(t.mails.map((m) => [m.subject, m.text.split('.')[0]])).toEqual([
['Disk almost full (6% free)', 'Only 6 GB (6%) is free on the phone system'],
]);
clock += 86_400_000; // next day: again
await j.diskWatch();
expect(t.mails).toHaveLength(2);
expect(await events('disk.low')).toEqual([
{ severity: 'warning', message: 'Disk almost full: 6% free' },
{ severity: 'warning', message: 'Disk almost full: 6% free' },
]);
});
it('a mail that fails to send is logged, not thrown', async () => {
t.mail.fail = 'smtp down';
reg = 'rejected';
const j = createJobs(deps());
await j.trunkWatch();
clock += 6 * 60_000;
await expect(j.trunkWatch()).resolves.toBeUndefined();
t.mail.fail = null;
});
});