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

316 lines
12 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest';
import { type ListResponse, type RoomDocument, type RoomListItem } 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, get, issue, json } from '../support/api-client.js';
import { activeRoom, closedRoom, globalHourly, minutesAgo, participant, reseed } from '../support/rooms-seed.js';
const mongoUri = inject('mongoUri');
const HOUR = 3_600_000;
const sids = (page: { items: { sid: string }[] }) => page.items.map((item) => item.sid);
describe.skipIf(mongoUri === '')('rooms API', () => {
let ce: Client;
beforeAll(async () => {
ce = await bootClient(mongoUri);
});
afterAll(async () => {
await ce.dashboard.stop();
});
it.each(['/rooms/summary', '/participants/summary', '/egresses/summary', '/ingresses/summary'])(
'answers %s before the detail route it shares a prefix with',
async (path) => {
expect((await get(ce, path)).status).toBe(200);
}
);
describe('list', () => {
it('lists the latest rooms of the last 24 hours with total', async () => {
const recent = Array.from({ length: 40 }, (_, i) =>
closedRoom({ _id: `RM_recent${String(i).padStart(2, '0')}`, createdAt: minutesAgo((i + 1) * 30) })
);
const old = Array.from({ length: 5 }, (_, i) =>
closedRoom({ _id: `RM_old${String(i)}`, createdAt: minutesAgo(30 * 60 + i * 60) })
);
await reseed(ce.dashboard.db, { rooms: [...old, ...recent] });
const page = await json<ListResponse<RoomListItem>>(ce, '/rooms');
expect(sids(page)).toEqual(recent.slice(0, 25).map((room) => room._id));
expect(page.nextCursor).not.toBeNull();
expect(page.prevCursor).toBeNull();
expect(page.total).toBe(40);
expect(page.items[0]).not.toHaveProperty('internal');
});
it('walks forward and back without gaps or duplicates, ties broken on the SID', async () => {
const rooms = Array.from({ length: 60 }, (_, i) =>
closedRoom({
_id: `RM_walk${String(i).padStart(2, '0')}`,
createdAt: minutesAgo((Math.floor(i / 2) + 1) * 10)
})
);
const expected = [...rooms]
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || (a._id < b._id ? 1 : -1))
.map((room) => room._id);
await reseed(ce.dashboard.db, { rooms });
const first = await json<ListResponse<RoomListItem>>(ce, '/rooms?limit=25');
const second = await json<ListResponse<RoomListItem>>(
ce,
`/rooms?limit=25&cursor=${first.nextCursor ?? ''}`
);
const third = await json<ListResponse<RoomListItem>>(
ce,
`/rooms?limit=25&cursor=${second.nextCursor ?? ''}`
);
const back = await json<ListResponse<RoomListItem>>(ce, `/rooms?limit=25&cursor=${third.prevCursor ?? ''}`);
expect(sids(first)).toEqual(expected.slice(0, 25));
expect(sids(second)).toEqual(expected.slice(25, 50));
expect(sids(third)).toEqual(expected.slice(50));
expect(third.nextCursor).toBeNull();
expect(sids(back)).toEqual(expected.slice(25, 50));
});
it('filters by status and ranks by end time within the creation range', async () => {
await reseed(ce.dashboard.db, {
rooms: [
activeRoom({ _id: 'RM_live', createdAt: minutesAgo(60) }),
closedRoom({ _id: 'RM_long', createdAt: minutesAgo(3 * 24 * 60), endedAt: minutesAgo(120) }),
closedRoom({ _id: 'RM_short', createdAt: minutesAgo(90), endedAt: minutesAgo(30) }),
closedRoom({ _id: 'RM_early', createdAt: minutesAgo(300), endedAt: minutesAgo(180) })
]
});
expect(sids(await json(ce, '/rooms?status=active'))).toEqual(['RM_live']);
expect(sids(await json(ce, '/rooms?status=closed'))).toEqual(['RM_short', 'RM_early']);
expect(sids(await json(ce, '/rooms?status=closed&sort=endedAt'))).toEqual(['RM_short', 'RM_early']);
expect(sids(await json(ce, '/rooms?sort=endedAt'))).toEqual(['RM_live', 'RM_short', 'RM_early']);
expect(sids(await json(ce, '/rooms?sort=endedAt&order=asc'))).toEqual(['RM_early', 'RM_short', 'RM_live']);
});
it('ranks by duration, unique identities and active participants, running rooms at their live value', async () => {
const counted = (room: RoomDocument, unique: number, active: number): RoomDocument => ({
...room,
participants: { ...room.participants, unique, active }
});
await reseed(ce.dashboard.db, {
rooms: [
counted(closedRoom({ _id: 'RM_40', createdAt: minutesAgo(100), endedAt: minutesAgo(60) }), 12, 0),
counted(closedRoom({ _id: 'RM_10', createdAt: minutesAgo(50), endedAt: minutesAgo(40) }), 3, 0),
counted(activeRoom({ _id: 'RM_run', createdAt: minutesAgo(25) }), 7, 5),
counted(activeRoom({ _id: 'RM_run2', createdAt: minutesAgo(5) }), 2, 2)
]
});
expect(sids(await json(ce, '/rooms?sort=duration'))).toEqual(['RM_40', 'RM_run', 'RM_10', 'RM_run2']);
expect(sids(await json(ce, '/rooms?sort=uniqueIdentities'))).toEqual([
'RM_40',
'RM_run',
'RM_10',
'RM_run2'
]);
expect(sids(await json(ce, '/rooms?sort=active'))).toEqual(['RM_run', 'RM_run2', 'RM_40', 'RM_10']);
const first = await json<ListResponse<RoomListItem>>(ce, '/rooms?sort=duration&limit=2');
const second = await json<ListResponse<RoomListItem>>(
ce,
`/rooms?sort=duration&limit=2&cursor=${first.nextCursor ?? ''}`
);
const back = await json<ListResponse<RoomListItem>>(
ce,
`/rooms?sort=duration&limit=2&cursor=${second.prevCursor ?? ''}`
);
expect([sids(first), sids(second), sids(back)]).toEqual([
['RM_40', 'RM_run'],
['RM_10', 'RM_run2'],
['RM_40', 'RM_run']
]);
expect([first.total, second.total, second.nextCursor]).toEqual([4, 4, null]);
});
it('refuses a ranked sort beyond the range limit, but never an exact SID', async () => {
await reseed(ce.dashboard.db, { rooms: [closedRoom({ _id: 'RM_ab12cd', createdAt: minutesAgo(10) })] });
expect(await issue(ce, '/rooms?sort=duration&from=now-30d&to=now')).toMatchObject({
status: 400,
code: 'SORT_RANGE_TOO_WIDE'
});
expect(sids(await json(ce, '/rooms?q=RM_ab12cd&sort=duration&from=now-30d&to=now'))).toEqual(['RM_ab12cd']);
expect(sids(await json(ce, '/rooms?sort=createdAt&from=now-90d&to=now'))).toEqual(['RM_ab12cd']);
});
it('searches a name prefix and looks up an exact SID outside the range', async () => {
await reseed(ce.dashboard.db, {
rooms: [
closedRoom({ _id: 'RM_d1', name: 'Demo-Room', createdAt: minutesAgo(30) }),
closedRoom({ _id: 'RM_d2', name: 'demo-2', createdAt: minutesAgo(20) }),
closedRoom({ _id: 'RM_d3', name: 'my-demo', createdAt: minutesAgo(10) }),
closedRoom({ _id: 'RM_ab12cd', name: 'archived', createdAt: minutesAgo(40 * 24 * 60) })
]
});
expect(sids(await json(ce, '/rooms?q=DE'))).toEqual(['RM_d2', 'RM_d1']);
const exact = await json<ListResponse<RoomListItem>>(ce, '/rooms?q=RM_ab12cd&from=now-24h&status=active');
expect(exact).toMatchObject({ nextCursor: null, prevCursor: null, total: 1 });
expect(sids(exact)).toEqual(['RM_ab12cd']);
});
it('filters by features and node', async () => {
const plain = { egress: false, ingress: false, agent: false, sip: false };
await reseed(ce.dashboard.db, {
rooms: [
closedRoom({ _id: 'RM_eg', createdAt: minutesAgo(30), features: { ...plain, egress: true } }),
closedRoom({ _id: 'RM_in', createdAt: minutesAgo(20), features: { ...plain, ingress: true } }),
closedRoom({ _id: 'RM_plain', createdAt: minutesAgo(10), features: plain }),
activeRoom({ _id: 'RM_nodeA', createdAt: minutesAgo(5), nodeId: 'node-a', features: plain }),
activeRoom({ _id: 'RM_nodeB', createdAt: minutesAgo(4), nodeId: 'node-b', features: plain })
]
});
expect(sids(await json(ce, '/rooms?features=egress,ingress'))).toEqual(['RM_in', 'RM_eg']);
expect(sids(await json(ce, '/rooms?nodeId=node-a&status=active'))).toEqual(['RM_nodeA']);
expect(sids(await json(ce, '/rooms?q=rm_node&nodeId=node-a'))).toEqual(['RM_nodeA']);
});
it('omits total beyond the exact-count range', async () => {
await reseed(ce.dashboard.db, { rooms: [closedRoom({ _id: 'RM_one', createdAt: minutesAgo(10) })] });
const page = await json<ListResponse<RoomListItem>>(ce, '/rooms?from=now-30d');
expect(sids(page)).toEqual(['RM_one']);
expect(page).not.toHaveProperty('total');
});
it('rejects invalid parameters with the issue on the parameter', async () => {
expect(await issue(ce, '/rooms?status=open')).toMatchObject({
status: 400,
code: 'VALIDATION_ERROR',
first: { path: ['status'] }
});
expect((await issue(ce, '/rooms?sort=name')).first?.path).toEqual(['sort']);
expect((await issue(ce, '/rooms?q=d')).first).toEqual({
path: ['q'],
message: 'must have at least two characters'
});
expect((await issue(ce, '/rooms?features=egress,webhook')).first?.path).toEqual(['features']);
expect((await issue(ce, '/rooms?cursor=bogus')).first?.path).toEqual(['cursor']);
});
});
describe('summary', () => {
it('counts exactly within the threshold', async () => {
const rooms = Array.from({ length: 120 }, (_, i) =>
i < 7
? activeRoom({ _id: `RM_s${String(i)}`, createdAt: minutesAgo(i + 1) })
: closedRoom({ _id: `RM_s${String(i)}`, createdAt: minutesAgo(i * 10) })
);
const older = closedRoom({ _id: 'RM_older', createdAt: minutesAgo(3 * 24 * 60) });
const sessions = Array.from({ length: 900 }, (_, i) =>
participant({
_id: `PA_s${String(i)}`,
identity: `user${String(i)}`,
roomSid: 'RM_s0',
joinedAt: minutesAgo(i + 1),
...(i < 31 ? {} : { leftAt: minutesAgo(i) })
})
);
const dependent = ['EGRESS', 'AGENT'].map((kind, i) =>
participant({
_id: `PA_dep${String(i)}`,
identity: `dep${String(i)}`,
roomSid: 'RM_s0',
joinedAt: minutesAgo(25 * 60),
kind: kind as 'EGRESS' | 'AGENT'
})
);
// Services joined in the range, some still active: neither sessions nor active participants.
const services = Array.from({ length: 40 }, (_, i) =>
participant({
_id: `PA_svc${String(i)}`,
identity: `EG_${String(i)}`,
roomSid: 'RM_s0',
joinedAt: minutesAgo(i + 1),
kind: i % 2 === 0 ? 'EGRESS' : 'INGRESS',
...(i < 5 ? {} : { leftAt: minutesAgo(i) })
})
);
await reseed(ce.dashboard.db, {
rooms: [...rooms, older],
participants: [...sessions, ...dependent, ...services]
});
expect(await json(ce, '/rooms/summary?from=now-24h&to=now')).toEqual({
activeRooms: 7,
roomsInRange: 120,
participantSessions: 900,
activeParticipants: 31,
approximate: false
});
});
it('sums the global hourly rollups beyond the threshold', async () => {
const hour = Math.floor(Date.now() / HOUR) * HOUR;
const at = (hoursAgo: number) => new Date(hour - hoursAgo * HOUR);
await reseed(ce.dashboard.db, {
hourly: [
globalHourly(at(1), 10, 50),
globalHourly(at(24 * 10), 5, 20),
globalHourly(at(24 * 29), 1, 2),
globalHourly(at(24 * 31), 100, 1000),
globalHourly(at(1), 999, 999, 'node:ND_a')
]
});
expect(await json(ce, '/rooms/summary?from=now-30d&to=now')).toMatchObject({
roomsInRange: 16,
participantSessions: 72,
approximate: true
});
});
});
describe('CE edition', () => {
it.each([
'/rooms/RM_x',
'/rooms/RM_x/participants',
'/rooms/RM_x/egresses',
'/rooms/RM_x/ingress-sessions',
'/rooms/RM_x/events',
'/rooms/RM_x/series'
])('answers PRO_FEATURE on %s', async (path) => {
const response = await get(ce, path);
expect(response.status).toBe(403);
expect(await response.json()).toMatchObject({
error: { code: 'PRO_FEATURE', details: { feature: 'room-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/rooms', '/api/v1/rooms/summary']));
expect(paths.filter((path) => path.startsWith('/api/v1/rooms/{sid}'))).toEqual([]);
});
});
});