Files
selfphoneng/apps/api/test/recordings.test.ts
T
iamdoubz 4b6b95090a recordings: record calls, link them to history, Recordings page
sp-sub-record now also sets CDR(SP_REC); phone numbers can record every
call (record_inbound), with the notice from Settings → Recording played
first (SP_REC_ANNOUNCE). Aggregation links the MixMonitor file as a
recordings row (size, ffprobe length). /api/recordings lists, plays
(Range; admins and the people on the call) and deletes; recordings past
recordings.retentionDays are deleted daily.

Web: Recordings page (play, download, delete), a player in Call history
and My calls, a Recording settings tab with the consent notice (08 §5),
and "Record every call" on phone numbers.

e2e m7-t3: the notice plays before a recorded inbound call and the
recording plays from Call history (the M7 acceptance). The AMI
Originate helper moved to scripts/e2e/ami.mjs.

Task: M7-T3
2026-10-18 14:57:52 -05:00

112 lines
5.0 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// M7-T3: a recorded call (sp_recording) is linked to its calls row and playable by admins and the
// people on the call; delete and retention remove file and row.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { AmiMessage } from '../src/ami/events.ts';
import { calls, cdr, extensions, recordings } from '../src/db/schema.ts';
import { encrypt } from '../src/crypto/secrets.ts';
import { cleanupRecordings } from '../src/routes/recordings.ts';
import { aggregateCall, toCdrRow } from '../src/services/cdr.ts';
import { silenceWav } from './audio.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const FILE = '2026/10/18/1792351748.9.wav';
describe.skipIf(!hasTestDb)('call recordings', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let as: Record<
'admin' | 'alice' | 'bob',
(m: string, url: string) => Promise<LightMyRequestResponse>
>;
let recId: string;
const dir = () => t.app.config.RECORDINGS_DIR;
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const alice = await t.addUser('alice@example.com', 'alice-password-123');
await t.addUser('bob@example.com', 'bob-password-123');
await t.db.insert(extensions).values({
number: '101',
name: 'Alice',
userId: alice.id,
sipPasswordEnc: encrypt('x'),
voicemailPinEnc: encrypt('4826'),
});
// The inbound-user scenario, as if 101 recorded it.
const events: AmiMessage[] = JSON.parse(
readFileSync(new URL('./fixtures/cdr/inbound-user.json', import.meta.url), 'utf8'),
);
for (const e of events) await t.db.insert(cdr).values(toCdrRow({ ...e, sp_recording: FILE }));
mkdirSync(join(dir(), '2026/10/18'), { recursive: true });
writeFileSync(join(dir(), FILE), silenceWav(2));
await aggregateCall(t.db, String(events[0]!.LinkedID), { recordingsDir: dir() });
const login = (email: string, pw: string) => t.login(email, pw);
const mk = (cookie: string) => (method: string, url: string) =>
t.app.inject({ method: method as 'GET', url, headers: { ...H, cookie } });
as = {
admin: mk(await login(ADMIN.email, ADMIN.password)),
alice: mk(await login('alice@example.com', 'alice-password-123')),
bob: mk(await login('bob@example.com', 'bob-password-123')),
};
});
afterAll(() => t?.close());
it('the recording is linked to its call, with size and length', async () => {
const [c] = await t.db.select().from(calls);
expect(c!.recordingId).not.toBeNull();
recId = c!.recordingId!;
const [r] = await t.db.select().from(recordings).where(eq(recordings.id, recId));
expect(r).toMatchObject({ filePath: FILE, sizeBytes: 44 + 32_000 });
// ffprobe may be missing on a dev machine; then the length stays unknown.
if (r!.durationMs !== null) expect(r!.durationMs).toBeGreaterThan(1900);
const list = (await as.admin('GET', '/api/recordings')).json();
expect(list.items).toEqual([
expect.objectContaining({
id: recId,
call: expect.objectContaining({ direction: 'inbound' }),
}),
]);
const history = (await as.admin('GET', '/api/calls')).json();
expect(history.items[0].recordingId).toBe(recId);
});
it('plays for admins and the people on the call, with Range; others get 403', async () => {
const full = await as.admin('GET', `/api/recordings/${recId}/audio`);
expect(full.statusCode).toBe(200);
expect(full.rawPayload.subarray(0, 4).toString()).toBe('RIFF');
const mine = await t.app.inject({
method: 'GET',
url: `/api/recordings/${recId}/audio`,
headers: { ...H, range: 'bytes=0-9', cookie: '' },
});
expect(mine.statusCode).toBe(401);
expect((await as.alice('GET', `/api/recordings/${recId}/audio`)).statusCode).toBe(200);
expect((await as.bob('GET', `/api/recordings/${recId}/audio`)).statusCode).toBe(403);
expect((await as.alice('GET', '/api/recordings')).statusCode).toBe(403);
});
it('retention deletes recordings past their days; delete removes file and link', async () => {
expect(await cleanupRecordings(t.db, dir(), 3650)).toBe(0);
expect((await as.admin('DELETE', `/api/recordings/${recId}`)).statusCode).toBe(200);
expect(existsSync(join(dir(), FILE))).toBe(false);
const [c] = await t.db.select().from(calls);
expect(c!.recordingId).toBeNull();
writeFileSync(join(dir(), FILE), silenceWav(1));
await t.db.insert(recordings).values({
linkedid: 'old',
filePath: FILE,
startedAt: new Date('2020-01-01T00:00:00Z'),
});
expect(await cleanupRecordings(t.db, dir(), 90)).toBe(1);
expect(existsSync(join(dir(), FILE))).toBe(false);
expect(await cleanupRecordings(t.db, dir(), 0)).toBe(0); // 0 = keep forever
});
});