openvidu/openvidu-dashboard/dashboard-ce/typings/test/contract.test.ts

313 lines
11 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { EJSON, Int32 } from 'bson';
import { describe, expect, it } from 'vitest';
import { buildArtifacts } from '../contract/artifacts.js';
import type { BsonSchema } from '../contract/schema-builder.js';
import {
Collection,
COLLECTIONS,
type CollectionDefinition,
DEFAULT_RETENTION_MS,
DEFAULT_UNSTORED_EVENT_TYPES,
ENTITY_RETENTION_CLASS,
type IndexDefinition,
RetentionClass,
SCHEMA_VERSION,
StoredEventType
} from '../src/index.js';
import { createBsonValidator, describeErrors } from './support/bson-validator.js';
const packageRoot = fileURLToPath(new URL('..', import.meta.url));
function readText(relativePath: string): string {
return readFileSync(join(packageRoot, relativePath), 'utf8');
}
function readJson(relativePath: string): unknown {
return JSON.parse(readText(relativePath));
}
function readSchema(collection: Collection): BsonSchema {
return readJson(`schema/${collection}.schema.json`) as BsonSchema;
}
function readFixtures(collection: Collection): Record<string, unknown>[] {
return EJSON.parse(readText(`fixtures/${collection}.json`), { relaxed: false }) as Record<string, unknown>[];
}
const ajv = createBsonValidator();
const validators = new Map(COLLECTIONS.map((collection) => [collection, ajv.compile(readSchema(collection))]));
function validate(collection: Collection, document: unknown): string | null {
const validator = validators.get(collection);
if (validator === undefined) {
throw new Error(`no validator for ${collection}`);
}
return validator(document) ? null : describeErrors(validator);
}
describe('generated contract files', () => {
it.each([...buildArtifacts().entries()])('%s matches its source', (relativePath, expected) => {
if (typeof expected === 'string') {
expect(readText(relativePath)).toBe(expected);
} else {
expect(readJson(relativePath)).toEqual(JSON.parse(JSON.stringify(expected)));
}
});
it('records the schema version', () => {
expect(readText('schema/contract_version.txt').trim()).toBe(String(SCHEMA_VERSION));
});
});
describe('schema files', () => {
const MONGODB_KEYWORDS = new Set([
'bsonType',
'description',
'enum',
'pattern',
'minimum',
'maxItems',
'items',
'required',
'properties',
'patternProperties',
'additionalProperties',
'oneOf'
]);
function unsupportedKeywords(schema: BsonSchema, path: string): string[] {
const found: string[] = [];
for (const [keyword, value] of Object.entries(schema)) {
if (!MONGODB_KEYWORDS.has(keyword)) {
found.push(`${path}.${keyword}`);
}
if (keyword === 'properties' || keyword === 'patternProperties') {
for (const [name, child] of Object.entries(value as Record<string, BsonSchema>)) {
found.push(...unsupportedKeywords(child, `${path}.${name}`));
}
} else if ((keyword === 'items' || keyword === 'additionalProperties') && typeof value === 'object') {
found.push(...unsupportedKeywords(value as BsonSchema, `${path}.${keyword}`));
} else if (keyword === 'oneOf') {
for (const [i, child] of (value as BsonSchema[]).entries()) {
found.push(...unsupportedKeywords(child, `${path}.oneOf[${String(i)}]`));
}
}
}
return found;
}
it.each(COLLECTIONS)('%s uses only keywords MongoDB accepts in $jsonSchema', (collection) => {
expect(unsupportedKeywords(readSchema(collection), collection)).toEqual([]);
});
it('rejects a room whose createdAt is a string', () => {
const [room] = readFixtures(Collection.ROOMS);
expect(validate(Collection.ROOMS, { ...room, createdAt: '2026-10-12T09:00:00Z' })).toContain('/createdAt');
});
it('rejects unknown fields and enum values', () => {
const [room] = readFixtures(Collection.ROOMS);
expect(validate(Collection.ROOMS, { ...room, turnPassword: 'x' })).not.toBeNull();
expect(validate(Collection.ROOMS, { ...room, status: 'ended' })).not.toBeNull();
});
it('rejects an int32 where a double is required', () => {
const [room] = readFixtures(Collection.ROOMS);
const quality = (room as { quality: Record<string, unknown> }).quality;
expect(validate(Collection.ROOMS, { ...room, quality: { ...quality, scoreSum: new Int32(1296) } })).toContain(
'/quality/scoreSum'
);
});
it('rejects a node batch interval below one second', () => {
const [node] = readFixtures(Collection.NODES);
expect(validate(Collection.NODES, { ...node, batchIntervalMs: new Int32(500) })).toContain('/batchIntervalMs');
expect(validate(Collection.NODES, { ...node, batchIntervalMs: new Int32(1000) })).toBeNull();
});
it('rejects API_CALL raw events', () => {
const [event] = readFixtures(Collection.EVENTS);
expect(validate(Collection.EVENTS, { ...event, type: 'API_CALL' })).not.toBeNull();
});
it('rejects rollup counters keyed by free text', () => {
const [hourly] = readFixtures(Collection.ANALYTICS_HOURLY);
const participants = (hourly as { participants: Record<string, unknown> }).participants;
expect(
validate(Collection.ANALYTICS_HOURLY, {
...hourly,
participants: { ...participants, byOs: { 'Windows 11': 1 } }
})
).not.toBeNull();
});
});
describe('fixtures', () => {
it.each(COLLECTIONS)('%s documents validate against their schema', (collection) => {
const documents = readFixtures(collection);
expect(documents.length).toBeGreaterThan(0);
for (const document of documents) {
expect(validate(collection, document), String(document._id)).toBeNull();
}
});
it('contain no secret field names', () => {
const forbidden = new Set(['turnpassword', 'accesskey', 'secret', 'streamkey', 'token', 'signingkey']);
const found: string[] = [];
const walk = (value: unknown, path: string): void => {
if (Array.isArray(value)) {
for (const [i, item] of value.entries()) {
walk(item, `${path}[${String(i)}]`);
}
} else if (value !== null && typeof value === 'object') {
for (const [key, child] of Object.entries(value)) {
if (forbidden.has(key.replaceAll('_', '').toLowerCase())) {
found.push(`${path}.${key}`);
}
walk(child, `${path}.${key}`);
}
}
};
for (const collection of COLLECTIONS) {
walk(readJson(`fixtures/${collection}.json`), collection);
}
expect(found).toEqual([]);
});
it('cover the cases the contract describes', () => {
const field = (collection: Collection, name: string): unknown[] =>
readFixtures(collection).map((doc) => doc[name]);
expect(field(Collection.ROOMS, 'status')).toEqual(expect.arrayContaining(['active', 'closed']));
expect(new Set(field(Collection.PARTICIPANTS, 'status'))).toEqual(
new Set(['joining', 'active', 'left', 'aborted'])
);
expect(field(Collection.PARTICIPANTS, 'kind')).toEqual(
expect.arrayContaining(['STANDARD', 'EGRESS', 'INGRESS', 'SIP', 'AGENT'])
);
expect(field(Collection.TRACK_SUBSCRIPTIONS, 'status')).toEqual(
expect.arrayContaining(['requested', 'subscribed', 'unsubscribed', 'failed'])
);
expect(field(Collection.EGRESSES, 'status')).toEqual(expect.arrayContaining(['complete', 'failed', 'active']));
expect(readFixtures(Collection.EGRESSES).every((egress) => (egress.outputs as unknown[]).length > 0)).toBe(
true
);
const sessionsPerIngress = new Map<unknown, number>();
for (const ingressId of field(Collection.INGRESS_SESSIONS, 'ingressId')) {
sessionsPerIngress.set(ingressId, (sessionsPerIngress.get(ingressId) ?? 0) + 1);
}
expect(Math.max(...sessionsPerIngress.values())).toBeGreaterThanOrEqual(2);
const storedByDefault = Object.values(StoredEventType).filter(
(type) => !DEFAULT_UNSTORED_EVENT_TYPES.includes(type)
);
expect(new Set(field(Collection.EVENTS, 'type'))).toEqual(new Set(storedByDefault));
const statsWithTrack = readFixtures(Collection.TRACK_STATS).map((doc) => 'trackSid' in (doc.meta as object));
expect(new Set(statsWithTrack)).toEqual(new Set([true, false]));
for (const rollups of [Collection.ANALYTICS_5MIN, Collection.ANALYTICS_HOURLY, Collection.ANALYTICS_DAILY]) {
expect(new Set(field(rollups, 'scope').map((scope) => String(scope).split(':')[0]))).toEqual(
new Set(['global', 'node'])
);
}
expect(field(Collection.SCHEMA_META, '_id')).toEqual(
expect.arrayContaining(['schema', 'retention', 'capabilities', 'config', 'migration', 'reconciler'])
);
const reconciled = readFixtures(Collection.PARTICIPANTS).filter((doc) => doc.closedBy === 'reconciler');
expect(reconciled.map((doc) => doc.disconnectReason)).toEqual(['RECONCILED']);
const synthetic = readFixtures(Collection.EVENTS).filter((doc) => doc.synthetic === true);
expect(synthetic.map((doc) => [doc._id, doc.participantSid, doc.ts])).toEqual([
[`AE_SYN_${String(reconciled[0]?._id)}`, reconciled[0]?._id, reconciled[0]?.leftAt]
]);
});
it('carry the schema version on every entity document', () => {
for (const collection of Object.keys(ENTITY_RETENTION_CLASS) as Collection[]) {
for (const document of readFixtures(collection)) {
expect(document.schemaVersion?.valueOf()).toBe(SCHEMA_VERSION);
}
}
});
});
describe('index definitions', () => {
const indexes = readJson('schema/indexes.json') as IndexDefinition[];
const collections = readJson('schema/collections.json') as CollectionDefinition[];
function hasPath(schema: BsonSchema, path: string): boolean {
const [head, ...rest] = path.split('.');
const child = head === undefined ? undefined : schema.properties?.[head];
return child !== undefined && (rest.length === 0 || hasPath(child, rest.join('.')));
}
it('list every collection once, in the order of the Collection enum', () => {
expect(collections.map((definition) => definition.name)).toEqual(COLLECTIONS);
});
it('use unique names per collection', () => {
const names = indexes.map((index) => `${index.collection}.${index.name}`);
expect(new Set(names).size).toBe(names.length);
});
it('index only fields of the collection schema', () => {
for (const index of indexes) {
const schema = readSchema(index.collection);
for (const [field] of index.key) {
expect(hasPath(schema, field), `${index.collection}.${index.name}: ${field}`).toBe(true);
}
}
});
it('give every entity collection an expiresAt TTL index', () => {
for (const collection of Object.keys(ENTITY_RETENTION_CLASS)) {
expect(
indexes.some(
(index) =>
index.collection === collection &&
index.key.length === 1 &&
index.key[0]?.[0] === 'expiresAt' &&
index.options.expireAfterSeconds === 0
),
collection
).toBe(true);
}
});
it('expire every rollup collection by bucket with its own retention class', () => {
const ttl = (collection: Collection) =>
indexes.find((index) => index.collection === collection && index.name === 'bucket_ttl');
expect(ttl(Collection.ANALYTICS_5MIN)?.retentionClass).toBe(RetentionClass.ROLLUPS_5MIN);
expect(ttl(Collection.ANALYTICS_HOURLY)?.retentionClass).toBe(RetentionClass.ROLLUPS_HOURLY);
expect(ttl(Collection.ANALYTICS_DAILY)?.retentionClass).toBe(RetentionClass.ROLLUPS_DAILY);
expect(DEFAULT_RETENTION_MS.rollupsDaily).toBeNull();
});
it('never combine a fixed TTL with a retention class', () => {
for (const index of indexes) {
expect(index.retentionClass !== undefined && index.options.expireAfterSeconds !== undefined).toBe(false);
}
});
});