openvidu/openvidu-dashboard/dashboard-ce/backend/test/unit/participants.test.ts

151 lines
5.7 KiB
TypeScript

import type * as z from 'zod';
import { describe, expect, it } from 'vitest';
import type { ParticipantDocument } from '@openvidu-dashboard/typings';
import { toParticipantListItem } from '../../src/participants/participants-mappers.js';
import { participantsListPlan } from '../../src/participants/participants-query.js';
import { participantListItem } from '../../src/participants/participants-responses.js';
import { agentsListQuery, participantsListQuery } from '../../src/participants/participants-schemas.js';
import { loadFixture } from '../support/fixtures.js';
const NOW = new Date('2026-10-13T12:00:00Z');
const FROM = new Date('2026-10-12T12:00:00Z');
const range = { $gte: FROM, $lt: NOW };
const list = participantsListQuery(() => NOW);
function firstIssue(schema: z.ZodType, value: unknown) {
const result = schema.safeParse(value);
return result.success
? undefined
: { path: result.error.issues[0]?.path, message: result.error.issues[0]?.message };
}
function plan(query: Record<string, string>) {
const result = participantsListPlan(list.parse(query));
if (result.kind !== 'page') {
throw new Error('expected a page plan');
}
return result;
}
const participants = loadFixture<ParticipantDocument>('participants');
const alice = participants.find((doc) => doc.identity === 'alice');
if (alice === undefined) {
throw new Error('the fixtures need alice');
}
describe('participants query schemas', () => {
it('rejects the invalid parameters on their own path', () => {
expect(firstIssue(list, { q: 'a' })).toEqual({ path: ['q'], message: 'must have at least two characters' });
expect(firstIssue(list, { kind: 'AGENT,ROBOT' })?.path).toEqual(['kind']);
expect(firstIssue(list, { sort: 'identity' })?.path).toEqual(['sort']);
expect(firstIssue(list, { status: 'gone' })?.path).toEqual(['status']);
expect(firstIssue(list, { room: 'abc' })?.path).toEqual(['room']);
});
it('accepts a participant SID of any length, searches agents and has no kind or publisher on the agents list', () => {
expect(list.parse({ q: 'PA_x' }).q).toBe('PA_x');
const agents = agentsListQuery(() => NOW).parse({ q: 'ab', kind: 'AGENT', publisher: 'true' });
expect(agents.q).toBe('ab');
expect(Object.keys(agents)).not.toContain('kind');
expect(Object.keys(agents)).not.toContain('publisher');
});
});
describe('participants list plan', () => {
it('ranks by left time and duration over the join range, open sessions at asOf', () => {
const ranked = participantsListPlan(list.parse({ status: 'active', sort: 'duration' }));
expect(ranked).toMatchObject({
kind: 'ranked',
match: { kind: { $in: expect.any(Array) as unknown[] }, status: 'active', joinedAt: range },
hint: 'kind_status_joinedAt_id'
});
expect(ranked.kind === 'ranked' ? ranked.rank(NOW) : undefined).toEqual({
$ifNull: ['$durationMs', { $subtract: [{ $ifNull: ['$leftAt', NOW] }, '$joinedAt'] }]
});
const left = participantsListPlan(list.parse({ sort: 'leftAt' }));
expect(left.kind === 'ranked' ? left.rank(NOW) : undefined).toEqual({ $ifNull: ['$leftAt', NOW] });
});
it('keeps publishers as a residual filter of every branch', () => {
expect(plan({ publisher: 'true' }).filter).toEqual({ joinedAt: range, isPublisher: true });
expect(plan({ status: 'active', publisher: 'true' }).filter).toMatchObject({
status: 'active',
isPublisher: true
});
expect(plan({ room: 'RM_abc', publisher: 'true' }).filter).toMatchObject({
roomSid: 'RM_abc',
isPublisher: true
});
expect(() => list.parse({ publisher: 'yes' })).toThrow();
});
it('uses joinedAt, a room, an identity prefix or kind and status', () => {
expect(plan({}).filter).toEqual({ joinedAt: range });
expect(plan({ room: 'RM_abc', status: 'active' }).filter).toEqual({
roomSid: 'RM_abc',
joinedAt: range,
status: 'active'
});
expect(plan({ q: 'Al' }).filter).toEqual({ identityLower: { $regex: '^al' }, joinedAt: range });
expect(plan({ q: 'ali', room: 'RM_a' }).filter).toEqual({
roomSid: 'RM_a',
identityLower: { $regex: '^ali' },
joinedAt: range
});
expect(plan({ kind: 'AGENT,EGRESS' }).filter).toEqual({
kind: { $in: ['AGENT', 'EGRESS'] },
status: { $in: ['joining', 'active', 'left', 'aborted'] },
joinedAt: range
});
expect(plan({ status: 'active' }).filter.kind).toEqual({
$in: ['STANDARD', 'INGRESS', 'EGRESS', 'SIP', 'AGENT', 'CONNECTOR', 'BRIDGE']
});
expect(participantsListPlan(list.parse({ q: 'PA_x1y2', status: 'left' }))).toEqual({
kind: 'sid',
filter: { _id: 'PA_x1y2' }
});
});
});
describe('participant mappers', () => {
it('lists the summary fields and the client subset', () => {
const item = toParticipantListItem(alice);
expect(participantListItem.strict().parse(item)).toEqual(item);
expect(item).toMatchObject({
sid: alice._id,
status: 'left',
connectionType: 'udp',
client: { sdk: 'JS', version: '2.15.7', browser: 'Chrome', os: 'Windows', osVersion: '11' }
});
expect(item.client).not.toHaveProperty('address');
});
it('lists the sources the session published, in track source order', () => {
const sources = (bySource: ParticipantDocument['tracks']['bySource']) =>
toParticipantListItem({ ...alice, tracks: { ...alice.tracks, bySource } }).publishedSources;
expect(sources({ MICROPHONE: 1, CAMERA: 2 })).toEqual(['CAMERA', 'MICROPHONE']);
expect(sources({ CAMERA: 0 })).toEqual([]);
expect(sources({})).toEqual([]);
});
it('omits the client and the connection type when the session reported none', () => {
const { client: _client, connectionType: _type, ...bare } = alice;
expect(toParticipantListItem(bare as ParticipantDocument)).not.toHaveProperty('client');
expect(toParticipantListItem(bare as ParticipantDocument)).not.toHaveProperty('connectionType');
});
});