mirror of https://github.com/OpenVidu/openvidu.git
377 lines
13 KiB
TypeScript
377 lines
13 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest';
|
|
|
|
import { type ListResponse, type ParticipantListItem } 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 { minutesAgo, participant, reseed } from '../support/rooms-seed.js';
|
|
|
|
const mongoUri = inject('mongoUri');
|
|
const MINUTE = 60_000;
|
|
|
|
const sids = (page: { items: { sid: string }[] }) => page.items.map((item) => item.sid);
|
|
|
|
describe.skipIf(mongoUri === '')('participants API', () => {
|
|
let ce: Client;
|
|
|
|
beforeAll(async () => {
|
|
ce = await bootClient(mongoUri);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await ce.dashboard.stop();
|
|
});
|
|
|
|
describe('summary', () => {
|
|
it('counts STANDARD participants and the services apart, exactly within the threshold', async () => {
|
|
const sessions = (kind: 'STANDARD' | 'EGRESS' | 'AGENT', total: number, active: number) =>
|
|
Array.from({ length: total }, (_, i) =>
|
|
participant({
|
|
_id: `PA_${kind}${String(i)}`,
|
|
identity: `${kind}${String(i)}`,
|
|
roomSid: 'RM_s',
|
|
joinedAt: minutesAgo(i + 1),
|
|
kind,
|
|
...(i < active ? {} : { status: 'left' as const, leftAt: minutesAgo(i) })
|
|
})
|
|
);
|
|
const older = participant({
|
|
_id: 'PA_old',
|
|
identity: 'old',
|
|
roomSid: 'RM_s',
|
|
joinedAt: minutesAgo(25 * 60),
|
|
leftAt: minutesAgo(24 * 60 + 30)
|
|
});
|
|
|
|
await reseed(ce.dashboard.db, {
|
|
participants: [
|
|
...sessions('STANDARD', 900, 31),
|
|
...sessions('EGRESS', 40, 3),
|
|
...sessions('AGENT', 12, 2),
|
|
older
|
|
]
|
|
});
|
|
|
|
const none = { STANDARD: 0, INGRESS: 0, EGRESS: 0, SIP: 0, AGENT: 0, CONNECTOR: 0, BRIDGE: 0 };
|
|
|
|
expect(await json(ce, '/participants/summary?from=now-24h&to=now')).toEqual({
|
|
active: { participants: 31, services: 5, byKind: { ...none, STANDARD: 31, EGRESS: 3, AGENT: 2 } },
|
|
sessions: { participants: 900, byKind: { ...none, STANDARD: 900, EGRESS: 40, AGENT: 12 } },
|
|
approximate: false
|
|
});
|
|
});
|
|
|
|
it('sums the global hourly rollups beyond the threshold', async () => {
|
|
const HOUR = 60 * MINUTE;
|
|
const hour = Math.floor(Date.now() / HOUR) * HOUR;
|
|
const hourly = (hoursAgo: number, joined: number, egress: number, scope = 'global') => {
|
|
const bucket = new Date(hour - hoursAgo * HOUR);
|
|
|
|
return {
|
|
_id: `${scope}:${bucket.toISOString().replace('.000Z', 'Z')}`,
|
|
scope,
|
|
bucket,
|
|
participants: { joined, byKind: { STANDARD: joined, EGRESS: egress } }
|
|
};
|
|
};
|
|
|
|
await reseed(ce.dashboard.db, {
|
|
hourly: [
|
|
hourly(1, 50, 4),
|
|
hourly(24 * 10, 20, 1),
|
|
hourly(24 * 31, 1000, 100),
|
|
hourly(1, 999, 9, 'node:ND_a')
|
|
]
|
|
});
|
|
|
|
expect(await json(ce, '/participants/summary?from=now-30d&to=now')).toMatchObject({
|
|
sessions: { participants: 70, byKind: { STANDARD: 70, EGRESS: 5, AGENT: 0 } },
|
|
approximate: true
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('list', () => {
|
|
it('lists the sessions joined in the last 24 hours with total', async () => {
|
|
const recent = Array.from({ length: 40 }, (_, i) =>
|
|
participant({
|
|
_id: `PA_r${String(i).padStart(2, '0')}`,
|
|
identity: `user${String(i)}`,
|
|
roomSid: 'RM_a',
|
|
joinedAt: minutesAgo((i + 1) * 30)
|
|
})
|
|
);
|
|
const old = Array.from({ length: 5 }, (_, i) =>
|
|
participant({
|
|
_id: `PA_o${String(i)}`,
|
|
identity: 'old',
|
|
roomSid: 'RM_a',
|
|
joinedAt: minutesAgo(3 * 24 * 60)
|
|
})
|
|
);
|
|
|
|
await reseed(ce.dashboard.db, { participants: [...old, ...recent] });
|
|
|
|
const page = await json<ListResponse<ParticipantListItem>>(ce, '/participants');
|
|
|
|
expect(sids(page)).toEqual(recent.slice(0, 25).map((doc) => doc._id));
|
|
expect(page).toMatchObject({ prevCursor: null, total: 40 });
|
|
expect(page.nextCursor).not.toBeNull();
|
|
});
|
|
|
|
it('filters by room, status and kind, and rejects unknown values', async () => {
|
|
const at = (i: number) => minutesAgo(10 + i);
|
|
|
|
await reseed(ce.dashboard.db, {
|
|
participants: [
|
|
participant({ _id: 'PA_a1', identity: 'a1', roomSid: 'RM_abc', joinedAt: at(1) }),
|
|
participant({ _id: 'PA_a2', identity: 'a2', roomSid: 'RM_abc', joinedAt: at(2) }),
|
|
...[3, 4, 5].map((i) =>
|
|
participant({
|
|
_id: `PA_l${String(i)}`,
|
|
identity: 'l',
|
|
roomSid: 'RM_abc',
|
|
joinedAt: at(i),
|
|
leftAt: at(0)
|
|
})
|
|
),
|
|
participant({ _id: 'PA_other', identity: 'o', roomSid: 'RM_x', joinedAt: at(6) }),
|
|
participant({ _id: 'PA_ag', identity: 'agent', roomSid: 'RM_x', joinedAt: at(7), kind: 'AGENT' }),
|
|
participant({ _id: 'PA_eg', identity: 'EG_1', roomSid: 'RM_x', joinedAt: at(8), kind: 'EGRESS' })
|
|
]
|
|
});
|
|
|
|
expect(sids(await json(ce, '/participants?room=RM_abc&status=active'))).toEqual(['PA_a1', 'PA_a2']);
|
|
expect(sids(await json(ce, '/participants?kind=AGENT,EGRESS'))).toEqual(['PA_ag', 'PA_eg']);
|
|
expect(sids(await json(ce, '/participants?status=active&kind=STANDARD'))).toEqual([
|
|
'PA_a1',
|
|
'PA_a2',
|
|
'PA_other'
|
|
]);
|
|
expect(await issue(ce, '/participants?kind=ROBOT')).toMatchObject({
|
|
status: 400,
|
|
code: 'VALIDATION_ERROR',
|
|
first: { path: ['kind'] }
|
|
});
|
|
expect((await issue(ce, '/participants?sort=identity')).first?.path).toEqual(['sort']);
|
|
});
|
|
|
|
it('searches an identity prefix, also inside a room, and looks up an exact SID', async () => {
|
|
await reseed(ce.dashboard.db, {
|
|
participants: [
|
|
participant({ _id: 'PA_al1', identity: 'Alice', roomSid: 'RM_a', joinedAt: minutesAgo(10) }),
|
|
participant({ _id: 'PA_al2', identity: 'alice-2', roomSid: 'RM_b', joinedAt: minutesAgo(9) }),
|
|
participant({ _id: 'PA_mal', identity: 'malice', roomSid: 'RM_a', joinedAt: minutesAgo(8) }),
|
|
participant({ _id: 'PA_bob', identity: 'bob', roomSid: 'RM_a', joinedAt: minutesAgo(7) }),
|
|
participant({
|
|
_id: 'PA_x1y2',
|
|
identity: 'old',
|
|
roomSid: 'RM_a',
|
|
joinedAt: minutesAgo(10 * 24 * 60)
|
|
})
|
|
]
|
|
});
|
|
|
|
expect(sids(await json(ce, '/participants?q=al'))).toEqual(['PA_al2', 'PA_al1']);
|
|
expect(sids(await json(ce, '/participants?q=ali&room=RM_a'))).toEqual(['PA_al1']);
|
|
expect((await issue(ce, '/participants?q=a')).first).toEqual({
|
|
path: ['q'],
|
|
message: 'must have at least two characters'
|
|
});
|
|
|
|
const exact = await json<ListResponse<ParticipantListItem>>(
|
|
ce,
|
|
'/participants?q=PA_x1y2&from=now-24h&to=now'
|
|
);
|
|
|
|
expect(exact).toMatchObject({ total: 1, nextCursor: null, prevCursor: null });
|
|
expect(sids(exact)).toEqual(['PA_x1y2']);
|
|
expect((await json<ListResponse<ParticipantListItem>>(ce, '/participants?q=PA_missing')).items).toEqual([]);
|
|
});
|
|
|
|
it('ranks by left time and duration, open sessions at their live value, within 24 hours or one room', async () => {
|
|
await reseed(ce.dashboard.db, {
|
|
participants: [
|
|
participant({
|
|
_id: 'PA_50',
|
|
identity: 'fifty',
|
|
roomSid: 'RM_a',
|
|
joinedAt: minutesAgo(120),
|
|
leftAt: minutesAgo(70)
|
|
}),
|
|
participant({
|
|
_id: 'PA_5',
|
|
identity: 'five',
|
|
roomSid: 'RM_a',
|
|
joinedAt: minutesAgo(15),
|
|
leftAt: minutesAgo(10)
|
|
}),
|
|
participant({ _id: 'PA_open', identity: 'open', roomSid: 'RM_b', joinedAt: minutesAgo(20) })
|
|
]
|
|
});
|
|
|
|
expect(sids(await json(ce, '/participants?sort=duration'))).toEqual(['PA_50', 'PA_open', 'PA_5']);
|
|
expect(sids(await json(ce, '/participants?sort=leftAt'))).toEqual(['PA_open', 'PA_5', 'PA_50']);
|
|
expect(sids(await json(ce, '/participants?sort=leftAt&order=asc'))).toEqual(['PA_50', 'PA_5', 'PA_open']);
|
|
expect(await issue(ce, '/participants?sort=leftAt&from=now-48h&to=now')).toMatchObject({
|
|
status: 400,
|
|
code: 'SORT_RANGE_TOO_WIDE'
|
|
});
|
|
expect(sids(await json(ce, '/participants?sort=duration&room=RM_a&from=now-30d&to=now'))).toEqual([
|
|
'PA_50',
|
|
'PA_5'
|
|
]);
|
|
});
|
|
|
|
it('keeps the publishers and lists the sources each session published', async () => {
|
|
const published = (bySource: Record<string, number>) => ({
|
|
isPublisher: Object.keys(bySource).length > 0,
|
|
tracks: { published: 1, bySource, subscribed: 0, subscribeFailures: 0 }
|
|
});
|
|
|
|
await reseed(ce.dashboard.db, {
|
|
participants: [
|
|
participant({
|
|
_id: 'PA_pub',
|
|
identity: 'pub',
|
|
roomSid: 'RM_a',
|
|
joinedAt: minutesAgo(10),
|
|
...published({ CAMERA: 1, MICROPHONE: 1 })
|
|
}),
|
|
participant({
|
|
_id: 'PA_sub',
|
|
identity: 'sub',
|
|
roomSid: 'RM_a',
|
|
joinedAt: minutesAgo(9),
|
|
...published({})
|
|
})
|
|
]
|
|
});
|
|
|
|
const page = await json<ListResponse<ParticipantListItem>>(
|
|
ce,
|
|
'/participants?status=active&publisher=true'
|
|
);
|
|
|
|
expect(sids(page)).toEqual(['PA_pub']);
|
|
expect(page.total).toBe(1);
|
|
expect(page.items[0]?.publishedSources).toEqual(['CAMERA', 'MICROPHONE']);
|
|
expect(
|
|
(await json<ListResponse<ParticipantListItem>>(ce, '/participants?q=sub')).items[0]?.publishedSources
|
|
).toEqual([]);
|
|
expect((await issue(ce, '/participants?publisher=yes')).first?.path).toEqual(['publisher']);
|
|
});
|
|
|
|
it('walks forward and back without gaps or duplicates', async () => {
|
|
const docs = Array.from({ length: 30 }, (_, i) =>
|
|
participant({
|
|
_id: `PA_w${String(i).padStart(2, '0')}`,
|
|
identity: `walker${String(i)}`,
|
|
roomSid: 'RM_w',
|
|
joinedAt: minutesAgo(Math.floor(i / 2) + 1)
|
|
})
|
|
);
|
|
const expected = [...docs]
|
|
.sort((a, b) => b.joinedAt.getTime() - a.joinedAt.getTime() || (a._id < b._id ? 1 : -1))
|
|
.map((doc) => doc._id);
|
|
|
|
await reseed(ce.dashboard.db, { participants: docs });
|
|
|
|
const first = await json<ListResponse<ParticipantListItem>>(ce, '/participants?status=active&limit=10');
|
|
const second = await json<ListResponse<ParticipantListItem>>(
|
|
ce,
|
|
`/participants?status=active&limit=10&cursor=${first.nextCursor ?? ''}`
|
|
);
|
|
const third = await json<ListResponse<ParticipantListItem>>(
|
|
ce,
|
|
`/participants?status=active&limit=10&cursor=${second.nextCursor ?? ''}`
|
|
);
|
|
const back = await json<ListResponse<ParticipantListItem>>(
|
|
ce,
|
|
`/participants?status=active&limit=10&cursor=${third.prevCursor ?? ''}`
|
|
);
|
|
|
|
expect([...sids(first), ...sids(second), ...sids(third)]).toEqual(expected);
|
|
expect(sids(back)).toEqual(expected.slice(10, 20));
|
|
});
|
|
});
|
|
|
|
describe('CE edition', () => {
|
|
it.each([
|
|
['/participants/PA_x1y2', 'participant-detail'],
|
|
['/participants/PA_x1y2/sessions', 'participant-detail'],
|
|
['/participants/PA_x1y2/tracks', 'participant-detail'],
|
|
['/participants/PA_x1y2/subscriptions', 'participant-detail'],
|
|
['/participants/PA_x1y2/events', 'participant-detail'],
|
|
['/participants/PA_x1y2/series', 'participant-detail']
|
|
])('answers PRO_FEATURE on %s and 401 without a token', async (path, feature) => {
|
|
expect(await issue(ce, path)).toMatchObject({ status: 403, code: 'PRO_FEATURE', feature });
|
|
expect((await fetch(`${ce.dashboard.url}/api/v1${path}`)).status).toBe(401);
|
|
});
|
|
|
|
it('documents the lists 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, 'test').paths ?? {});
|
|
|
|
expect(paths).toEqual(
|
|
expect.arrayContaining(['/api/v1/participants', '/api/v1/participants/summary', '/api/v1/agents'])
|
|
);
|
|
expect(paths.filter((path) => /^\/api\/v1\/(participants|tracks)\/\{sid\}/.test(path))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('Agents', () => {
|
|
it('lists the agent sessions only, on CE', async () => {
|
|
await reseed(ce.dashboard.db, {
|
|
participants: [
|
|
participant({
|
|
_id: 'PA_ag1',
|
|
identity: 'agent-1',
|
|
roomSid: 'RM_a',
|
|
joinedAt: minutesAgo(5),
|
|
kind: 'AGENT'
|
|
}),
|
|
participant({
|
|
_id: 'PA_ag2',
|
|
identity: 'agent-2',
|
|
roomSid: 'RM_b',
|
|
joinedAt: minutesAgo(6),
|
|
kind: 'AGENT'
|
|
}),
|
|
participant({
|
|
_id: 'PA_ag3',
|
|
identity: 'agent-3',
|
|
roomSid: 'RM_b',
|
|
joinedAt: minutesAgo(7),
|
|
leftAt: minutesAgo(2),
|
|
kind: 'AGENT'
|
|
}),
|
|
...Array.from({ length: 10 }, (_, i) =>
|
|
participant({
|
|
_id: `PA_std${String(i)}`,
|
|
identity: `s${String(i)}`,
|
|
roomSid: 'RM_a',
|
|
joinedAt: minutesAgo(i)
|
|
})
|
|
)
|
|
]
|
|
});
|
|
|
|
const agents = await json<ListResponse<ParticipantListItem>>(ce, '/agents?status=active');
|
|
|
|
expect(sids(agents)).toEqual(['PA_ag1', 'PA_ag2']);
|
|
expect(agents.items.every((item) => item.kind === 'AGENT' && item.nodeId !== undefined)).toBe(true);
|
|
expect(sids(await json(ce, '/agents?q=agent-2'))).toEqual(['PA_ag2']);
|
|
expect(sids(await json(ce, '/agents?q=s1'))).toEqual([]);
|
|
expect(sids(await json(ce, '/agents?kind=STANDARD'))).toEqual(['PA_ag1', 'PA_ag2', 'PA_ag3']);
|
|
expect(sids(await json(ce, '/agents?sort=duration&from=now-6h&to=now'))).toEqual([
|
|
'PA_ag2',
|
|
'PA_ag1',
|
|
'PA_ag3'
|
|
]);
|
|
});
|
|
});
|
|
});
|