mirror of https://github.com/OpenVidu/openvidu.git
258 lines
8.6 KiB
TypeScript
258 lines
8.6 KiB
TypeScript
import { CONTRACT_SCHEMAS } from '@openvidu-dashboard/data-generator/contract';
|
|
import { generate } from '@openvidu-dashboard/data-generator/generator';
|
|
import { COLLECTION_NAMES } from '@openvidu-dashboard/data-generator/schema';
|
|
import type {
|
|
EgressDocument,
|
|
EventDocument,
|
|
HourlyRollupDocument,
|
|
IngressSessionDocument
|
|
} from '@openvidu-dashboard/typings';
|
|
import { type Document, type Filter, MongoClient } from 'mongodb';
|
|
import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest';
|
|
|
|
/**
|
|
* The data generator writes what the writer would: every document of every collection, `track_stats` included (a
|
|
* time-series collection takes no validator), satisfies the contract validator in both stats modes.
|
|
*/
|
|
|
|
const mongoUri = inject('mongoUri');
|
|
|
|
describe.skipIf(mongoUri === '')('data generator', () => {
|
|
let client: MongoClient;
|
|
|
|
beforeAll(async () => {
|
|
client = await MongoClient.connect(mongoUri);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await client.close();
|
|
});
|
|
|
|
it.each(['participant', 'track'] as const)('writes documents of the contract in %s mode', async (statsMode) => {
|
|
const db = client.db(`dashboard_test_generator_${statsMode}`);
|
|
await db.dropDatabase();
|
|
|
|
try {
|
|
await generate(db, {
|
|
scenario: 'S',
|
|
days: 2,
|
|
statsMode,
|
|
seed: 7,
|
|
retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 },
|
|
timeseries: true,
|
|
rollups: { fiveMinute: true, nodes: true, roomSums: true },
|
|
activeWindowMinutes: 60,
|
|
minActiveRooms: 10
|
|
});
|
|
|
|
const invalid: Record<string, number> = {};
|
|
|
|
for (const name of COLLECTION_NAMES) {
|
|
const collection = db.collection(name);
|
|
|
|
expect(await collection.estimatedDocumentCount(), name).toBeGreaterThan(0);
|
|
invalid[name] = await collection.countDocuments({ $nor: [{ $jsonSchema: CONTRACT_SCHEMAS[name] }] });
|
|
}
|
|
|
|
expect(invalid).toEqual(Object.fromEntries(COLLECTION_NAMES.map((name) => [name, 0])));
|
|
|
|
// The validators check types, not ranges.
|
|
const generatedAt = new Date();
|
|
const outOfRange: Record<string, Filter<Document>> = {
|
|
rooms: { $or: [{ 'bytes.up': { $lt: 0 } }, { 'bytes.down': { $lt: 0 } }, { durationMs: { $lt: 0 } }] },
|
|
participants: {
|
|
$or: [
|
|
{ 'bytes.up': { $lt: 0 } },
|
|
{ 'bytes.down': { $lt: 0 } },
|
|
{ durationMs: { $lt: 0 } },
|
|
{ joinedAt: { $gt: generatedAt } }
|
|
]
|
|
},
|
|
tracks: {
|
|
$or: [
|
|
{ 'publishStats.bytes': { $lt: 0 } },
|
|
{ durationMs: { $lt: 0 } },
|
|
{ publishedAt: { $gt: generatedAt } }
|
|
]
|
|
},
|
|
track_subscriptions: {
|
|
$or: [
|
|
{ 'subscribeStats.bytes': { $lt: 0 } },
|
|
{ durationMs: { $lt: 0 } },
|
|
{ subscribedAt: { $gt: generatedAt } }
|
|
]
|
|
}
|
|
};
|
|
const outOfRangeCounts: Record<string, number> = {};
|
|
|
|
for (const [name, filter] of Object.entries(outOfRange)) {
|
|
outOfRangeCounts[name] = await db.collection(name).countDocuments(filter);
|
|
}
|
|
|
|
expect(outOfRangeCounts).toEqual(Object.fromEntries(Object.keys(outOfRange).map((name) => [name, 0])));
|
|
|
|
// Each ingress session has the INGRESS participant the ingress sessions API links to.
|
|
for (const session of await db.collection<IngressSessionDocument>('ingress_sessions').find({}).toArray()) {
|
|
const joined = await db.collection('participants').countDocuments({
|
|
roomSid: session.roomSid,
|
|
identityLower: session.participantIdentity?.toLowerCase(),
|
|
kind: 'INGRESS',
|
|
joinedAt: {
|
|
$gte: new Date(session.startedAt.getTime() - 60_000),
|
|
$lte: session.endedAt ?? new Date()
|
|
}
|
|
});
|
|
|
|
expect(joined, session._id).toBeGreaterThan(0);
|
|
}
|
|
|
|
// Each egress with a room has the EGRESS session the egress detail resolves: identity = egress id.
|
|
for (const egress of await db
|
|
.collection<EgressDocument>('egresses')
|
|
.find({ roomSid: { $exists: true } })
|
|
.toArray()) {
|
|
const joined = await db.collection('participants').countDocuments({
|
|
roomSid: egress.roomSid,
|
|
identityLower: egress._id.toLowerCase(),
|
|
kind: 'EGRESS'
|
|
});
|
|
|
|
expect(joined, egress._id).toBe(1);
|
|
}
|
|
|
|
// Display names differ from the identities, as clients set them.
|
|
expect(
|
|
await db.collection('participants').countDocuments({
|
|
kind: 'STANDARD',
|
|
$expr: { $eq: ['$name', '$identity'] }
|
|
})
|
|
).toBe(0);
|
|
|
|
// Participant counters count STANDARD sessions only, as the writer does.
|
|
expect(
|
|
await db.collection('rooms').countDocuments({
|
|
$expr: { $ne: ['$participants.sessions', { $ifNull: ['$participants.byKind.STANDARD', 0] }] }
|
|
})
|
|
).toBe(0);
|
|
|
|
// The search fields are the lower-cased names.
|
|
expect(
|
|
await db
|
|
.collection('ingresses')
|
|
.countDocuments({ $expr: { $ne: ['$nameLower', { $toLower: '$name' }] } })
|
|
).toBe(0);
|
|
expect(
|
|
await db.collection('egresses').countDocuments({
|
|
roomName: { $exists: true },
|
|
$expr: { $ne: ['$roomNameLower', { $toLower: '$roomName' }] }
|
|
})
|
|
).toBe(0);
|
|
|
|
// Node heartbeats report the writer's default batch interval.
|
|
expect(await db.collection('nodes').countDocuments({ batchIntervalMs: { $ne: 10_000 } })).toBe(0);
|
|
|
|
// Every ingress session started is counted once by input type.
|
|
let started = 0;
|
|
let byInputType = 0;
|
|
|
|
for (const hourly of await db
|
|
.collection<HourlyRollupDocument>('analytics_hourly')
|
|
.find({ scope: 'global' })
|
|
.toArray()) {
|
|
started += hourly.ingress?.sessionsStarted ?? 0;
|
|
byInputType += Object.values(hourly.ingress?.byInputType ?? {}).reduce((sum, count) => sum + count, 0);
|
|
}
|
|
|
|
expect(started).toBeGreaterThan(0);
|
|
expect(byInputType).toBe(started);
|
|
|
|
// Event payloads follow the writer's protojson shape: snake_case names nested by entity.
|
|
const camelCase = new Set<string>();
|
|
|
|
for (const event of await db.collection<EventDocument>('events').find({}).toArray()) {
|
|
for (const key of payloadKeys(event.payload)) {
|
|
if (!/^[a-z0-9_]+$/.test(key)) {
|
|
camelCase.add(`${event.type}.${key}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
expect([...camelCase]).toEqual([]);
|
|
const joined = (await db.collection<EventDocument>('events').findOne({ type: 'PARTICIPANT_JOINED' }))
|
|
?.payload as
|
|
| {
|
|
room?: { sid?: unknown };
|
|
participant?: { sid?: unknown; identity?: unknown };
|
|
client_info?: { sdk?: unknown };
|
|
}
|
|
| undefined;
|
|
|
|
expect(
|
|
[
|
|
joined?.room?.sid,
|
|
joined?.participant?.sid,
|
|
joined?.participant?.identity,
|
|
joined?.client_info?.sdk
|
|
].map((value) => typeof value)
|
|
).toEqual(['string', 'string', 'string', 'string']);
|
|
} finally {
|
|
await db.dropDatabase();
|
|
}
|
|
});
|
|
|
|
it('generates a node without media statistics on request', async () => {
|
|
const db = client.db('dashboard_test_generator_media_stats');
|
|
await db.dropDatabase();
|
|
|
|
try {
|
|
await generate(db, {
|
|
scenario: 'S',
|
|
days: 1,
|
|
statsMode: 'track',
|
|
seed: 7,
|
|
retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 },
|
|
timeseries: true,
|
|
rollups: { fiveMinute: true, nodes: true, roomSums: true },
|
|
nodesWithoutMediaStats: 1
|
|
});
|
|
|
|
const nodes = await db.collection<{ _id: string; mediaStats: boolean }>('nodes').find().toArray();
|
|
const unmarked = nodes.filter((node) => !node.mediaStats).map((node) => node._id);
|
|
|
|
expect(nodes.filter((node) => node.mediaStats)).toHaveLength(nodes.length - 1);
|
|
expect(unmarked).toHaveLength(1);
|
|
|
|
const rooms = db.collection<{ mediaStats?: boolean; quality: { scoreCount: number } }>('rooms');
|
|
const marked = await rooms.find({ nodeId: unmarked[0] }).toArray();
|
|
|
|
expect(marked.length).toBeGreaterThan(0);
|
|
expect(marked.every((room) => room.mediaStats === false && room.quality.scoreCount === 0)).toBe(true);
|
|
expect(await rooms.countDocuments({ nodeId: unmarked[0], 'bytes.up': { $ne: 0 } })).toBe(0);
|
|
expect(await rooms.countDocuments({ nodeId: { $ne: unmarked[0] }, mediaStats: { $exists: true } })).toBe(0);
|
|
expect(
|
|
await db.collection('participants').countDocuments({ nodeId: unmarked[0], mediaStats: { $ne: false } })
|
|
).toBe(0);
|
|
expect(await db.collection('track_stats').countDocuments({ 'meta.nodeId': unmarked[0] })).toBe(0);
|
|
expect(await db.collection('track_stats').countDocuments({})).toBeGreaterThan(0);
|
|
expect(
|
|
await db.collection('rooms').countDocuments({ $nor: [{ $jsonSchema: CONTRACT_SCHEMAS.rooms }] })
|
|
).toBe(0);
|
|
} finally {
|
|
await db.dropDatabase();
|
|
}
|
|
});
|
|
});
|
|
|
|
/** Every key of a payload at any depth; BSON values such as `Long` are leaves. */
|
|
function payloadKeys(value: unknown): string[] {
|
|
if (Array.isArray(value)) {
|
|
return value.flatMap(payloadKeys);
|
|
}
|
|
|
|
if (value === null || typeof value !== 'object' || value instanceof Date || '_bsontype' in value) {
|
|
return [];
|
|
}
|
|
|
|
return Object.entries(value).flatMap(([key, child]) => [key, ...payloadKeys(child)]);
|
|
}
|