2026-10-25 16:35:47 +01:00
|
|
|
import { type CommandStartedEvent, type Db, type Document, MongoClient } from 'mongodb';
|
|
|
|
|
import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest';
|
|
|
|
|
|
|
|
|
|
import { Collection, type NodeDocument, SCHEMA_VERSION, type StatusResponse } from '@openvidu-dashboard/typings';
|
|
|
|
|
|
|
|
|
|
import { openApiDocument } from '../../src/api/openapi.js';
|
|
|
|
|
import { rankedSortLimits } from '../../src/api/ranked-limits.js';
|
|
|
|
|
import type { RouteRegistry } from '../../src/api/route-registry.js';
|
|
|
|
|
import { TOKENS } from '../../src/di/tokens.js';
|
|
|
|
|
import { InfoService } from '../../src/info/info-service.js';
|
|
|
|
|
import { statusResponse } from '../../src/status/status-routes.js';
|
|
|
|
|
import { StatusService } from '../../src/status/status-service.js';
|
|
|
|
|
import { bootClient, type Client, get, json } from '../support/api-client.js';
|
|
|
|
|
|
|
|
|
|
const mongoUri = inject('mongoUri');
|
|
|
|
|
const SECOND = 1000;
|
|
|
|
|
const HOUR = 3600 * SECOND;
|
|
|
|
|
|
|
|
|
|
const ago = (ms: number) => new Date(Date.now() - ms);
|
|
|
|
|
|
|
|
|
|
function node(id: string, seenAgo: number, batchIntervalMs?: number): NodeDocument {
|
|
|
|
|
return {
|
|
|
|
|
_id: id,
|
|
|
|
|
firstSeenAt: ago(HOUR),
|
|
|
|
|
lastSeenAt: ago(seenAgo),
|
|
|
|
|
serverVersion: '3.10.0',
|
|
|
|
|
region: 'eu-west-1',
|
|
|
|
|
...(batchIntervalMs === undefined ? {} : { batchIntervalMs })
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function replace(db: Db, collection: Collection, documents: Document[]): Promise<void> {
|
|
|
|
|
await db.collection(collection).deleteMany({});
|
|
|
|
|
|
|
|
|
|
if (documents.length > 0) {
|
|
|
|
|
await db.collection(collection).insertMany(documents);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe.skipIf(mongoUri === '')('status API', () => {
|
|
|
|
|
let ce: Client;
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
ce = await bootClient(mongoUri);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
|
|
|
|
await ce.dashboard.stop();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('reports a writer never seen on an uninitialized schema, without caching', async () => {
|
|
|
|
|
await replace(ce.dashboard.db, Collection.NODES, []);
|
|
|
|
|
await replace(ce.dashboard.db, Collection.SCHEMA_META, []);
|
|
|
|
|
|
|
|
|
|
const response = await get(ce, '/status');
|
|
|
|
|
const body = (await response.json()) as StatusResponse;
|
|
|
|
|
|
|
|
|
|
expect(response.status).toBe(200);
|
|
|
|
|
expect(response.headers.get('cache-control')).toBe('private, no-store');
|
|
|
|
|
expect(statusResponse.parse(body)).toEqual(body);
|
|
|
|
|
expect(body).toEqual({
|
|
|
|
|
generatedAt: expect.any(String) as string,
|
|
|
|
|
schema: { dashboard: SCHEMA_VERSION, status: 'uninitialized' },
|
|
|
|
|
migration: { state: 'none' },
|
openvidu-dashboard: media node labels, and nothing about nodes in CE
The contract gains mediaNode on nodes, rooms and participants, stoppedAt on nodes and the
partial mediaNode index that replaces the nodeId one; the scenario's two servers are
labelled. The status reports the writer without node counts and with stoppedAt when the
server stopped, the rooms list filters by mediaNode, list items carry it, the overview
groups by it for editions and a node scope's media statistics coverage follows it.
CE renders nothing about nodes without per-node-breakdown: no nodes line, By node card,
Node select, scope selector or Node columns, not even locked; the Overview reads "Server
stopped at" for a stopped server. The generator gains --media-nodes, which node rollups
now require.
2026-11-04 12:13:58 +01:00
|
|
|
writer: {}
|
2026-10-25 16:35:47 +01:00
|
|
|
});
|
|
|
|
|
expect(Math.abs(Date.parse(body.generatedAt) - Date.now())).toBeLessThan(SECOND);
|
|
|
|
|
});
|
|
|
|
|
|
openvidu-dashboard: media node labels, and nothing about nodes in CE
The contract gains mediaNode on nodes, rooms and participants, stoppedAt on nodes and the
partial mediaNode index that replaces the nodeId one; the scenario's two servers are
labelled. The status reports the writer without node counts and with stoppedAt when the
server stopped, the rooms list filters by mediaNode, list items carry it, the overview
groups by it for editions and a node scope's media statistics coverage follows it.
CE renders nothing about nodes without per-node-breakdown: no nodes line, By node card,
Node select, scope selector or Node columns, not even locked; the Overview reads "Server
stopped at" for a stopped server. The generator gains --media-nodes, which node rollups
now require.
2026-11-04 12:13:58 +01:00
|
|
|
it('reports the newest batch and a running migration', async () => {
|
2026-10-25 16:35:47 +01:00
|
|
|
const newest = node('node-a', 4 * SECOND);
|
|
|
|
|
|
|
|
|
|
await replace(ce.dashboard.db, Collection.NODES, [
|
|
|
|
|
newest,
|
|
|
|
|
node('node-b', 12 * SECOND),
|
|
|
|
|
node('node-c', 50 * SECOND)
|
|
|
|
|
]);
|
|
|
|
|
await replace(ce.dashboard.db, Collection.SCHEMA_META, [
|
|
|
|
|
{ _id: 'schema', version: SCHEMA_VERSION, writerVersion: '3.10.0', updatedAt: new Date() },
|
|
|
|
|
{
|
|
|
|
|
_id: 'migration',
|
|
|
|
|
state: 'running',
|
|
|
|
|
processed: 400_000,
|
|
|
|
|
total: 1_200_000,
|
|
|
|
|
startedAt: ago(HOUR),
|
|
|
|
|
writerVersion: '3.10.0',
|
|
|
|
|
fromSchemaVersion: 0
|
|
|
|
|
}
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const body = await json<StatusResponse>(ce, '/status');
|
|
|
|
|
|
openvidu-dashboard: media node labels, and nothing about nodes in CE
The contract gains mediaNode on nodes, rooms and participants, stoppedAt on nodes and the
partial mediaNode index that replaces the nodeId one; the scenario's two servers are
labelled. The status reports the writer without node counts and with stoppedAt when the
server stopped, the rooms list filters by mediaNode, list items carry it, the overview
groups by it for editions and a node scope's media statistics coverage follows it.
CE renders nothing about nodes without per-node-breakdown: no nodes line, By node card,
Node select, scope selector or Node columns, not even locked; the Overview reads "Server
stopped at" for a stopped server. The generator gains --media-nodes, which node rollups
now require.
2026-11-04 12:13:58 +01:00
|
|
|
expect(body.writer).toEqual({ lastSeenAt: newest.lastSeenAt.toISOString() });
|
2026-10-25 16:35:47 +01:00
|
|
|
expect(body.schema.status).toBe('ok');
|
|
|
|
|
expect(body.migration).toMatchObject({
|
|
|
|
|
state: 'running',
|
|
|
|
|
progress: { processed: 400_000, total: 1_200_000, startedAt: expect.any(String) as string }
|
|
|
|
|
});
|
|
|
|
|
expect(Object.keys(body)).not.toContain('retention');
|
|
|
|
|
});
|
|
|
|
|
|
openvidu-dashboard: media node labels, and nothing about nodes in CE
The contract gains mediaNode on nodes, rooms and participants, stoppedAt on nodes and the
partial mediaNode index that replaces the nodeId one; the scenario's two servers are
labelled. The status reports the writer without node counts and with stoppedAt when the
server stopped, the rooms list filters by mediaNode, list items carry it, the overview
groups by it for editions and a node scope's media statistics coverage follows it.
CE renders nothing about nodes without per-node-breakdown: no nodes line, By node card,
Node select, scope selector or Node columns, not even locked; the Overview reads "Server
stopped at" for a stopped server. The generator gains --media-nodes, which node rollups
now require.
2026-11-04 12:13:58 +01:00
|
|
|
it('reports the largest batch interval of the last hour', async () => {
|
2026-10-25 16:35:47 +01:00
|
|
|
await replace(ce.dashboard.db, Collection.NODES, [
|
|
|
|
|
node('node-a', 80 * SECOND, 60_000),
|
|
|
|
|
node('node-b', 40 * SECOND, 10_000),
|
|
|
|
|
node('node-c', 2 * HOUR, 120_000)
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const body = await json<StatusResponse>(ce, '/status');
|
|
|
|
|
|
openvidu-dashboard: media node labels, and nothing about nodes in CE
The contract gains mediaNode on nodes, rooms and participants, stoppedAt on nodes and the
partial mediaNode index that replaces the nodeId one; the scenario's two servers are
labelled. The status reports the writer without node counts and with stoppedAt when the
server stopped, the rooms list filters by mediaNode, list items carry it, the overview
groups by it for editions and a node scope's media statistics coverage follows it.
CE renders nothing about nodes without per-node-breakdown: no nodes line, By node card,
Node select, scope selector or Node columns, not even locked; the Overview reads "Server
stopped at" for a stopped server. The generator gains --media-nodes, which node rollups
now require.
2026-11-04 12:13:58 +01:00
|
|
|
expect(body.writer).toMatchObject({ batchIntervalMs: 60_000 });
|
|
|
|
|
expect(body.writer).not.toHaveProperty('stoppedAt');
|
2026-10-25 16:35:47 +01:00
|
|
|
expect(statusResponse.parse(body)).toEqual(body);
|
|
|
|
|
});
|
|
|
|
|
|
openvidu-dashboard: media node labels, and nothing about nodes in CE
The contract gains mediaNode on nodes, rooms and participants, stoppedAt on nodes and the
partial mediaNode index that replaces the nodeId one; the scenario's two servers are
labelled. The status reports the writer without node counts and with stoppedAt when the
server stopped, the rooms list filters by mediaNode, list items carry it, the overview
groups by it for editions and a node scope's media statistics coverage follows it.
CE renders nothing about nodes without per-node-breakdown: no nodes line, By node card,
Node select, scope selector or Node columns, not even locked; the Overview reads "Server
stopped at" for a stopped server. The generator gains --media-nodes, which node rollups
now require.
2026-11-04 12:13:58 +01:00
|
|
|
it('reports a server stopped gracefully until another node reports', async () => {
|
|
|
|
|
const stoppedAt = ago(6 * SECOND);
|
|
|
|
|
|
|
|
|
|
await replace(ce.dashboard.db, Collection.NODES, [
|
|
|
|
|
{ ...node('node-a', 6 * SECOND), stoppedAt },
|
|
|
|
|
node('node-old', 2 * HOUR)
|
|
|
|
|
]);
|
|
|
|
|
expect((await json<StatusResponse>(ce, '/status')).writer).toEqual({
|
|
|
|
|
lastSeenAt: stoppedAt.toISOString(),
|
|
|
|
|
stoppedAt: stoppedAt.toISOString()
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await replace(ce.dashboard.db, Collection.NODES, [
|
|
|
|
|
{ ...node('node-a', 60 * SECOND), stoppedAt: ago(60 * SECOND) },
|
|
|
|
|
node('node-b', 3 * SECOND)
|
|
|
|
|
]);
|
|
|
|
|
expect((await json<StatusResponse>(ce, '/status')).writer).not.toHaveProperty('stoppedAt');
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-25 16:35:47 +01:00
|
|
|
it('documents the route', () => {
|
2026-10-27 10:42:41 +01:00
|
|
|
const document = openApiDocument(ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry), {
|
|
|
|
|
version: 'test'
|
|
|
|
|
});
|
2026-10-25 16:35:47 +01:00
|
|
|
|
|
|
|
|
expect(Object.keys(document.paths ?? {})).toContain('/api/v1/status');
|
|
|
|
|
expect(Object.keys(document.components?.schemas ?? {})).toContain('StatusResponse');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects a request without a token', async () => {
|
|
|
|
|
const response = await fetch(`${ce.dashboard.url}/api/v1/status`);
|
|
|
|
|
|
|
|
|
|
expect(response.status).toBe(401);
|
|
|
|
|
expect(((await response.json()) as { error: { code: string } }).error.code).toBe('UNAUTHORIZED');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('reads only schema_meta, nodes and the clock', async () => {
|
|
|
|
|
const client = new MongoClient(mongoUri, { monitorCommands: true });
|
|
|
|
|
const commands: CommandStartedEvent[] = [];
|
|
|
|
|
|
|
|
|
|
client.on('commandStarted', (event) => {
|
|
|
|
|
commands.push(event);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const db = client.db(ce.dashboard.db.databaseName);
|
|
|
|
|
const service = new StatusService(db, {
|
|
|
|
|
info: new InfoService(db, {
|
|
|
|
|
edition: 'ce',
|
|
|
|
|
version: 'test',
|
|
|
|
|
features: () => [],
|
|
|
|
|
limits: rankedSortLimits(7 * 86_400_000),
|
|
|
|
|
openapiDocsEnabled: false
|
|
|
|
|
}),
|
|
|
|
|
now: () => new Date()
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await service.status();
|
|
|
|
|
|
|
|
|
|
const sent = commands.map((event) => ({
|
|
|
|
|
name: event.commandName,
|
|
|
|
|
collection: (event.command as Record<string, unknown>)[event.commandName]
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
sent
|
|
|
|
|
.filter((command) => command.name === 'find')
|
|
|
|
|
.map((command) => command.collection)
|
|
|
|
|
.sort()
|
|
|
|
|
).toEqual([Collection.NODES, Collection.SCHEMA_META]);
|
|
|
|
|
expect(sent.map((command) => command.name).sort()).toEqual(['find', 'find', 'hello']);
|
|
|
|
|
} finally {
|
|
|
|
|
await client.close();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|