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>(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>( 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>(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) => ({ 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>( 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>(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>(ce, '/participants?status=active&limit=10'); const second = await json>( ce, `/participants?status=active&limit=10&cursor=${first.nextCursor ?? ''}` ); const third = await json>( ce, `/participants?status=active&limit=10&cursor=${second.nextCursor ?? ''}` ); const back = await json>( 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(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>(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' ]); }); }); });