openvidu/openvidu-dashboard/dashboard-ce/backend/test/integration/egress-api.test.ts

287 lines
10 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest';
import {
type EgressDocument,
type EgressListItem,
type EgressStatus,
type EgressSummary,
type EgressType,
type HourlyRollupDocument,
type ListResponse
} from '@openvidu-dashboard/typings';
import { openApiDocument } from '../../src/api/openapi.js';
import type { RouteRegistry } from '../../src/api/route-registry.js';
import { TOKENS } from '../../src/di/tokens.js';
import { bootClient, type Client, issue, json } from '../support/api-client.js';
import { activeRoom, egress, minutesAgo, reseed } from '../support/rooms-seed.js';
const mongoUri = inject('mongoUri');
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const ids = (page: { items: EgressListItem[] }) => page.items.map((item) => item.egressId);
/** An egress of `roomSid` started `minutes` ago: complete after 30 minutes unless another status is given. */
function recording(
id: string,
roomSid: string | undefined,
startedAt: Date,
input: Partial<EgressDocument> = {}
): EgressDocument {
const status: EgressStatus = input.status ?? 'complete';
const ended = !['starting', 'active', 'ending'].includes(status);
const {
roomSid: _roomSid,
roomName: _roomName,
roomNameLower: _roomNameLower,
endedAt: _endedAt,
durationMs: _durationMs,
closedBy: _closedBy,
error: _error,
errorCode: _errorCode,
...base
} = egress({ _id: id, roomSid: 'RM_unused', startedAt });
const roomName = input.roomName ?? roomSid?.toLowerCase();
return {
...base,
...(roomSid === undefined || roomName === undefined
? {}
: { roomSid, roomName, roomNameLower: roomName.toLowerCase() }),
status,
updatedAt: startedAt,
...(ended
? {
endedAt: new Date(startedAt.getTime() + 30 * MINUTE),
durationMs: 30 * MINUTE,
closedBy: 'event',
expiresAt: new Date(Date.now() + 90 * 24 * HOUR)
}
: { expiresAt: null }),
...input
};
}
function hourly(bucket: Date, figures: NonNullable<HourlyRollupDocument['egress']>): HourlyRollupDocument {
return { _id: `global:${bucket.toISOString().replace('.000Z', 'Z')}`, scope: 'global', bucket, egress: figures };
}
describe.skipIf(mongoUri === '')('egress API', () => {
let ce: Client;
beforeAll(async () => {
ce = await bootClient(mongoUri);
});
afterAll(async () => {
await ce.dashboard.stop();
});
describe('list', () => {
it('lists the egresses started in the last 24 hours with total and walks the cursor', async () => {
const recent = Array.from({ length: 60 }, (_, i) =>
recording(`EG_r${String(i).padStart(2, '0')}`, 'RM_a', minutesAgo((i + 1) * 20))
);
const old = Array.from({ length: 5 }, (_, i) =>
recording(`EG_o${String(i)}`, 'RM_a', minutesAgo(3 * 24 * 60 + i))
);
await reseed(ce.dashboard.db, { egresses: [...old, ...recent] });
const inDay = recent
.filter((doc) => doc.startedAt.getTime() > Date.now() - 24 * HOUR)
.map((doc) => doc._id);
const first = await json<ListResponse<EgressListItem>>(ce, '/egresses');
expect(ids(first)).toEqual(inDay.slice(0, 25));
expect(first).toMatchObject({ prevCursor: null, total: inDay.length });
const all = recent.map((doc) => doc._id);
const walk = async (cursor: string | null) =>
json<ListResponse<EgressListItem>>(
ce,
`/egresses?from=now-2d&limit=25${cursor ? `&cursor=${cursor}` : ''}`
);
const one = await walk(null);
const two = await walk(one.nextCursor);
const three = await walk(two.nextCursor);
const back = await walk(three.prevCursor);
expect([ids(one), ids(two), ids(three), ids(back)]).toEqual([
all.slice(0, 25),
all.slice(25, 50),
all.slice(50, 60),
all.slice(25, 50)
]);
expect((await issue(ce, '/egresses?sort=durationMs')).first?.path).toEqual(['sort']);
});
it('filters by a status set, types and a room, and rejects unknown values', async () => {
const at = (i: number) => minutesAgo(10 + i);
await reseed(ce.dashboard.db, {
egresses: [
recording('EG_act', 'RM_abc', at(1), { status: 'active' }),
recording('EG_sta', 'RM_x', at(2), { status: 'starting' }),
recording('EG_end', 'RM_x', at(3), { status: 'ending' }),
recording('EG_ok', 'RM_abc', at(4)),
recording('EG_f1', 'RM_x', at(5), { status: 'failed', error: 'boom', type: 'participant' }),
recording('EG_f2', 'RM_x', at(6), { status: 'failed', error: 'boom', type: 'web' }),
recording('EG_f3', 'RM_x', at(7), { status: 'failed', error: 'boom', type: 'room_composite' })
]
});
expect(ids(await json(ce, '/egresses?status=starting,active,ending'))).toEqual([
'EG_act',
'EG_sta',
'EG_end'
]);
expect(ids(await json(ce, '/egresses?status=failed&type=room_composite,participant'))).toEqual([
'EG_f1',
'EG_f3'
]);
expect(ids(await json(ce, '/egresses?room=RM_abc'))).toEqual(['EG_act', 'EG_ok']);
expect(await issue(ce, '/egresses?status=running')).toMatchObject({
status: 400,
code: 'VALIDATION_ERROR',
first: { path: ['status'] }
});
expect((await issue(ce, '/egresses?room=demo-room')).first?.path).toEqual(['room']);
});
it('searches a room name prefix through the rooms and looks up an exact id outside the range', async () => {
const rooms = [
activeRoom({ _id: 'RM_demo1', name: 'Demo-Room', createdAt: minutesAgo(100) }),
activeRoom({ _id: 'RM_demo2', name: 'demo-2', createdAt: minutesAgo(90) }),
activeRoom({ _id: 'RM_mydemo', name: 'my-demo', createdAt: minutesAgo(80) })
];
await reseed(ce.dashboard.db, {
rooms,
egresses: [
recording('EG_d1', 'RM_demo1', minutesAgo(50), { roomName: 'Demo-Room' }),
recording('EG_d2', 'RM_demo2', minutesAgo(40), { roomName: 'demo-2' }),
recording('EG_my', 'RM_mydemo', minutesAgo(30), { roomName: 'my-demo' }),
recording('EG_ab12cd', 'RM_demo1', minutesAgo(40 * 24 * 60), { roomName: 'Demo-Room' }),
recording('EG_long', 'RM_demo2', minutesAgo(3 * 24 * 60), { status: 'active', roomName: 'demo-2' })
]
});
expect(ids(await json(ce, '/egresses?q=DE'))).toEqual(['EG_d2', 'EG_d1']);
expect(await issue(ce, '/egresses?q=d')).toMatchObject({
status: 400,
first: { path: ['q'], message: 'must have at least two characters' }
});
expect(await json(ce, '/egresses?q=EG_ab12cd&from=now-24h&status=active')).toMatchObject({
items: [{ egressId: 'EG_ab12cd' }],
total: 1,
nextCursor: null,
prevCursor: null
});
expect(ids(await json(ce, '/egresses?status=active&from=now-24h&to=now'))).toEqual([]);
expect(ids(await json(ce, '/egresses?q=EG_long'))).toEqual(['EG_long']);
});
it('matches every egress of a room name prefix, with no room cap, also once the room expired', async () => {
const egresses = Array.from({ length: 150 }, (_, i) =>
recording(`EG_load${String(i).padStart(3, '0')}`, `RM_load${String(i)}`, minutesAgo(200 - i), {
roomName: `load-${String(i)}`
})
);
await reseed(ce.dashboard.db, { egresses });
const first = await json<ListResponse<EgressListItem>>(ce, '/egresses?q=load-&limit=100');
const second = await json<ListResponse<EgressListItem>>(
ce,
`/egresses?q=load-&limit=100&cursor=${first.nextCursor ?? ''}`
);
expect([...ids(first), ...ids(second)]).toEqual(egresses.map((doc) => doc._id).reverse());
expect([first.total, second.total, second.nextCursor]).toEqual([150, 150, null]);
});
it('ranks by end time and duration, egresses in flight at their live value', async () => {
await reseed(ce.dashboard.db, {
egresses: [
recording('EG_2h', 'RM_a', minutesAgo(180), {
endedAt: minutesAgo(60),
durationMs: 120 * MINUTE
}),
recording('EG_10m', 'RM_a', minutesAgo(20), { endedAt: minutesAgo(10), durationMs: 10 * MINUTE }),
recording('EG_live', 'RM_a', minutesAgo(45), { status: 'active' })
]
});
expect(ids(await json(ce, '/egresses?sort=duration'))).toEqual(['EG_2h', 'EG_live', 'EG_10m']);
expect(ids(await json(ce, '/egresses?sort=endedAt'))).toEqual(['EG_live', 'EG_10m', 'EG_2h']);
expect(await issue(ce, '/egresses?sort=duration&from=now-30d&to=now')).toMatchObject({
status: 400,
code: 'SORT_RANGE_TOO_WIDE'
});
});
it('summarizes the egresses of a short range exactly and of a long one from the rollups', async () => {
const egresses = [
...Array.from({ length: 3 }, (_, i) =>
recording(`EG_a${String(i)}`, 'RM_a', minutesAgo(10 + i), { status: 'active' })
),
...Array.from({ length: 8 }, (_, i) =>
recording(`EG_c${String(i)}`, 'RM_a', minutesAgo(100 + i), {
type: (i < 6 ? 'room_composite' : 'track') as EgressType
})
),
recording('EG_fail', 'RM_a', minutesAgo(200), { status: 'failed', error: 'x', type: 'track' }),
recording('EG_old', 'RM_a', minutesAgo(10 * 24 * 60))
];
const bucket = new Date(Math.floor((Date.now() - 20 * 24 * HOUR) / HOUR) * HOUR);
await reseed(ce.dashboard.db, {
egresses,
hourly: [
hourly(bucket, {
started: 4,
byType: { web: 4 },
endedByStatus: { complete: 3, aborted: 1 },
durationMs: 1_000
}),
hourly(new Date(bucket.getTime() + HOUR), { started: 2, byType: { track: 2 }, durationMs: 500 })
]
});
expect(await json<EgressSummary>(ce, '/egresses/summary?from=now-24h&to=now')).toEqual({
active: 3,
startedInRange: 12,
byType: { room_composite: 9, track: 3 },
byEndStatus: { complete: 8, failed: 1 },
totalDurationMs: 9 * 30 * MINUTE,
approximate: false
});
expect(await json<EgressSummary>(ce, '/egresses/summary?from=now-30d&to=now-15d')).toEqual({
active: 3,
startedInRange: 6,
byType: { web: 4, track: 2 },
byEndStatus: { complete: 3, aborted: 1 },
totalDurationMs: 1_500,
approximate: true
});
});
});
describe('CE edition', () => {
it.each(['/egresses/EG_ab12cd', '/egresses/EG_ab12cd/events'])('answers PRO_FEATURE on %s', async (path) => {
expect(await issue(ce, path)).toMatchObject({ status: 403, code: 'PRO_FEATURE', feature: 'egress-detail' });
});
it('documents the list and the summary, and none of the locked routes', () => {
const registry = ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry);
const paths = Object.keys(openApiDocument(registry, { version: 'test' }).paths ?? {});
expect(paths).toEqual(expect.arrayContaining(['/api/v1/egresses', '/api/v1/egresses/summary']));
expect(paths.filter((path) => path.startsWith('/api/v1/egresses/{id}'))).toEqual([]);
});
});
});