From c5bca6e1334a04f321d63a1d3bd2b596f38f3a4e Mon Sep 17 00:00:00 2001 From: CSantosM <4a.santos@gmail.com> Date: Fri, 6 Mar 2026 16:38:12 +0100 Subject: [PATCH] backend: Enhances test reliability with active waiting Replaces arbitrary `sleep()` calls in integration tests with explicit `wait-helpers`. These new helpers actively poll for specific conditions (e.g., room deletion, participant connection, recording status) directly from the database or LiveKit, rather than relying on fixed delays. This significantly reduces test flakiness and improves the accuracy of assertions. Extracts LiveKit CLI interaction helpers (`joinFakeParticipant`, `disconnectFakeParticipants`, `updateParticipantMetadata`) into a dedicated `livekit-cli-helpers.ts` file for better organization and separation of concerns. Updates numerous integration tests to utilize the new waiting and LiveKit CLI helpers. --- .../tests/helpers/livekit-cli-helpers.ts | 219 +++++++++++++ .../backend/tests/helpers/request-helpers.ts | 152 ++------- .../backend/tests/helpers/test-scenarios.ts | 2 +- meet-ce/backend/tests/helpers/wait-helpers.ts | 303 ++++++++++++++++++ .../api/meetings/end-meeting.test.ts | 10 +- .../api/meetings/kick-participant.test.ts | 9 +- .../api/meetings/update-participant.test.ts | 9 +- .../recordings/bulk-delete-recording.test.ts | 3 +- .../api/recordings/delete-recording.test.ts | 3 +- .../recordings/download-recordings.test.ts | 3 +- .../recordings/get-media-recording.test.ts | 3 +- .../api/recordings/get-recording-url.test.ts | 5 +- .../api/recordings/get-recording.test.ts | 3 +- .../api/recordings/get-recordings.test.ts | 3 +- .../api/recordings/race-conditions.test.ts | 7 +- .../recording-header-fields.test.ts | 3 +- .../api/recordings/start-recording.test.ts | 4 +- .../api/recordings/stop-recording.test.ts | 3 +- .../bulk-delete-room-members.test.ts | 4 +- .../room-members/delete-room-member.test.ts | 11 +- .../generate-room-member-token.test.ts | 2 +- .../room-members/update-room-member.test.ts | 4 +- .../api/rooms/active-status-rooms-gc.test.ts | 2 +- .../api/rooms/bulk-delete-rooms.test.ts | 33 +- .../integration/api/rooms/delete-room.test.ts | 12 +- .../api/rooms/expired-rooms-gc.test.ts | 11 +- .../api/rooms/room-header-fields.test.ts | 2 +- .../api/rooms/update-room-status.test.ts | 7 +- .../api/security/meeting-security.test.ts | 8 +- .../api/security/recording-security.test.ts | 3 +- .../integration/webhooks/webhook.test.ts | 17 +- 31 files changed, 652 insertions(+), 208 deletions(-) create mode 100644 meet-ce/backend/tests/helpers/livekit-cli-helpers.ts create mode 100644 meet-ce/backend/tests/helpers/wait-helpers.ts diff --git a/meet-ce/backend/tests/helpers/livekit-cli-helpers.ts b/meet-ce/backend/tests/helpers/livekit-cli-helpers.ts new file mode 100644 index 00000000..031a7f68 --- /dev/null +++ b/meet-ce/backend/tests/helpers/livekit-cli-helpers.ts @@ -0,0 +1,219 @@ +import { MeetRoomMemberTokenMetadata } from '@openvidu-meet/typings'; +import { ChildProcess, spawn } from 'child_process'; +import { MEET_ENV } from '../../src/environment.js'; +import { + waitForParticipantsToDisconnect, + waitForParticipantToConnect, + waitForParticipantToUpdateMetadata +} from './wait-helpers.js'; + +const fakeParticipantsProcesses = new Map(); +/** Tracks all room IDs that currently have at least one fake participant joined via joinFakeParticipant. */ +const fakeParticipantRooms = new Set(); + +/** + * Adds a fake participant to a LiveKit room for testing purposes. + * + * @param roomId The ID of the room to join + * @param participantIdentity The identity for the fake participant + */ +export const joinFakeParticipant = async (roomId: string, participantIdentity: string) => { + await ensureLivekitCliInstalled(); + const process = spawnLivekitCliProcess([ + 'room', + 'join', + '--identity', + participantIdentity, + '--publish-demo', + roomId + ]); + + // Store the process to be able to terminate it later + fakeParticipantsProcesses.set(`${roomId}-${participantIdentity}`, process); + fakeParticipantRooms.add(roomId); + await waitForParticipantToConnect(roomId, participantIdentity); +}; + +/** + * Updates the metadata for a participant in a LiveKit room. + * + * @param roomId The ID of the room + * @param participantIdentity The identity of the participant + * @param metadata The metadata to update + */ +export const updateParticipantMetadata = async ( + roomId: string, + participantIdentity: string, + metadata: MeetRoomMemberTokenMetadata +) => { + await ensureLivekitCliInstalled(); + spawnLivekitCliProcess([ + 'room', + 'participants', + 'update', + '--room', + roomId, + '--identity', + participantIdentity, + '--metadata', + JSON.stringify(metadata) + ]); + await waitForParticipantToUpdateMetadata(roomId, participantIdentity, metadata); +}; + +export const disconnectFakeParticipants = async () => { + // Capture the rooms that had fake participants before clearing the set + const roomIds = [...fakeParticipantRooms]; + await ensureLivekitCliInstalled(); + + fakeParticipantsProcesses.forEach((process, participant) => { + process.kill(); + console.log(`Stopped process for participant '${participant}'`); + }); + + fakeParticipantsProcesses.clear(); + + for (const roomId of roomIds) { + const identities = await listRoomParticipantIdentities(roomId); + + for (const identity of identities) { + await executeLivekitCliCommand([ + 'room', + 'participants', + 'remove', + '--room', + roomId, + '--identity', + identity + ]); + } + } + + fakeParticipantRooms.clear(); + + // Wait until LiveKit confirms no participants remain in any of the affected rooms + await waitForParticipantsToDisconnect(roomIds); +}; + +const withLivekitCredentials = (args: string[]): string[] => { + return [...args, '--api-key', MEET_ENV.LIVEKIT_API_KEY, '--api-secret', MEET_ENV.LIVEKIT_API_SECRET]; +}; + +const spawnLivekitCliProcess = (args: string[], stdio: 'pipe' | 'inherit' = 'pipe'): ChildProcess => { + return spawn('lk', withLivekitCredentials(args), { stdio }); +}; + +const executeLivekitCliCommand = async (args: string[], timeoutMs = 10000): Promise => { + return new Promise((resolve, reject) => { + const process = spawnLivekitCliProcess(args, 'pipe'); + + let stdout = ''; + let stderr = ''; + let hasResolved = false; + + const resolveOnce = (success: boolean, payload?: string) => { + if (hasResolved) return; + + hasResolved = true; + + if (success) { + resolve(payload ?? ''); + } else { + reject(new Error(payload ?? 'LiveKit CLI command failed')); + } + }; + + process.stdout?.on('data', (chunk) => { + stdout += chunk.toString(); + }); + + process.stderr?.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + process.on('error', (error) => { + resolveOnce(false, `Failed to execute LiveKit CLI: ${error.message}`); + }); + + process.on('exit', (code) => { + if (code === 0) { + resolveOnce(true, stdout); + } else { + resolveOnce( + false, + `LiveKit CLI exited with code ${code}. stderr: ${stderr.trim() || 'N/A'}. stdout: ${stdout.trim() || 'N/A'}` + ); + } + }); + + setTimeout(() => { + process.kill(); + resolveOnce(false, `LiveKit CLI command timed out after ${timeoutMs}ms`); + }, timeoutMs); + }); +}; + +const parseParticipantIdentities = (participantsListOutput: string): string[] => { + const headerTokens = new Set(['identity', 'id', 'name']); + + const identities = participantsListOutput + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .filter((line) => !/^[-=+|]+$/.test(line)) + .map((line) => line.split(/\s+/)[0]) + .map((token) => token.replace(/^\|+|\|+$/g, '')) + .filter((token) => token.length > 0) + .filter((token) => !headerTokens.has(token.toLowerCase())); + + return [...new Set(identities)]; +}; + +const listRoomParticipantIdentities = async (roomId: string): Promise => { + const output = await executeLivekitCliCommand(['room', 'participants', 'list', roomId]); + + return parseParticipantIdentities(output); +}; + +const ensureLivekitCliInstalled = async (): Promise => { + return new Promise((resolve, reject) => { + const checkProcess = spawn('lk', ['--version'], { + stdio: 'pipe' + }); + + let hasResolved = false; + + const resolveOnce = (success: boolean, message?: string) => { + if (hasResolved) return; + + hasResolved = true; + + if (success) { + resolve(); + } else { + reject(new Error(message || 'LiveKit CLI check failed')); + } + }; + + checkProcess.on('error', (error) => { + if (error.message.includes('ENOENT')) { + resolveOnce(false, '❌ LiveKit CLI tool "lk" is not installed or not in PATH.'); + } else { + resolveOnce(false, `Failed to check LiveKit CLI: ${error.message}`); + } + }); + + checkProcess.on('exit', (code) => { + if (code === 0) { + resolveOnce(true); + } else { + resolveOnce(false, `LiveKit CLI exited with code ${code}`); + } + }); + + setTimeout(() => { + checkProcess.kill(); + resolveOnce(false, 'LiveKit CLI check timed out'); + }, 5000); + }); +}; diff --git a/meet-ce/backend/tests/helpers/request-helpers.ts b/meet-ce/backend/tests/helpers/request-helpers.ts index 31f2c575..6856c6f8 100644 --- a/meet-ce/backend/tests/helpers/request-helpers.ts +++ b/meet-ce/backend/tests/helpers/request-helpers.ts @@ -14,7 +14,6 @@ import { MeetRoomField, MeetRoomMemberOptions, MeetRoomMemberRole, - MeetRoomMemberTokenMetadata, MeetRoomMemberTokenOptions, MeetRoomOptions, MeetRoomRolesConfig, @@ -23,7 +22,6 @@ import { SecurityConfig, WebhookConfig } from '@openvidu-meet/typings'; -import { ChildProcess, spawn } from 'child_process'; import { Express } from 'express'; import ms, { StringValue } from 'ms'; import request, { Response } from 'supertest'; @@ -31,12 +29,19 @@ import { container, initializeEagerServices } from '../../src/config/dependency- import { INTERNAL_CONFIG } from '../../src/config/internal-config.js'; import { MEET_ENV } from '../../src/environment.js'; import { GlobalConfigRepository } from '../../src/repositories/global-config.repository.js'; +import { RoomRepository } from '../../src/repositories/room.repository.js'; import { createApp, registerDependencies } from '../../src/server.js'; import { ApiKeyService } from '../../src/services/api-key.service.js'; import { GlobalConfigService } from '../../src/services/global-config.service.js'; import { RecordingService } from '../../src/services/recording.service.js'; import { RoomScheduledTasksService } from '../../src/services/room-scheduled-tasks.service.js'; import { getBasePath } from '../../src/utils/html-dynamic-base-path.utils.js'; +import { + waitForAllRecordingsToStop, + waitForAllRoomsToDelete, + waitForMeetingToEnd, + waitForRecordingToStop +} from './wait-helpers.js'; /** * Constructs the full API path by prepending the base path. @@ -54,7 +59,6 @@ export const getFullPath = (apiPath: string): string => { }; let app: Express; -const fakeParticipantsProcesses = new Map(); export const sleep = (time: StringValue) => { return new Promise((resolve) => setTimeout(resolve, ms(time))); @@ -576,9 +580,7 @@ export const deleteRoom = async ( req.set('x-extrafields', headers.xExtraFields); } - const result = await req; - await sleep('5s'); // TODO - replace with a more robust solution to ensure webhook is processed before proceeding with the tests - return result; + return await req; }; export const bulkDeleteRooms = async ( @@ -618,9 +620,7 @@ export const bulkDeleteRooms = async ( req.set('x-extrafields', headers.xExtraFields); } - const result = await req; - await sleep('5s'); // TODO - replace with a more robust solution to ensure webhook is processed before proceeding with the tests - return result; + return await req; }; export const deleteAllRooms = async () => { @@ -657,9 +657,19 @@ export const deleteAllRooms = async () => { export const runExpiredRoomsGC = async () => { checkAppIsRunning(); + // Capture expired rooms without active meetings — these are synchronously deleted by the GC, + // which in turn causes LiveKit to emit room_finished webhooks that the backend must process. + const roomRepository = container.get(RoomRepository); + const expiredRooms = await roomRepository.findExpiredRooms(); + const expiredRoomIdsToWait = expiredRooms + .filter((r) => r.status !== MeetRoomStatus.ACTIVE_MEETING) + .map((r) => r.roomId); + const roomTaskScheduler = container.get(RoomScheduledTasksService); await roomTaskScheduler['deleteExpiredRooms'](); - await sleep('5s'); // TODO - replace with a more robust solution to ensure webhook is processed before proceeding with the tests + + // Wait until the deleted rooms are confirmed gone (404) or no longer active in the Meet API. + await waitForAllRoomsToDelete(expiredRoomIdsToWait); }; /** @@ -767,73 +777,6 @@ export const generateRoomMemberToken = async ( // MEETING HELPERS -/** - * Adds a fake participant to a LiveKit room for testing purposes. - * - * @param roomId The ID of the room to join - * @param participantIdentity The identity for the fake participant - */ -export const joinFakeParticipant = async (roomId: string, participantIdentity: string) => { - await ensureLivekitCliInstalled(); - const process = spawn('lk', [ - 'room', - 'join', - '--identity', - participantIdentity, - '--publish-demo', - roomId, - '--api-key', - MEET_ENV.LIVEKIT_API_KEY, - '--api-secret', - MEET_ENV.LIVEKIT_API_SECRET - ]); - - // Store the process to be able to terminate it later - fakeParticipantsProcesses.set(`${roomId}-${participantIdentity}`, process); - await sleep('1s'); -}; - -/** - * Updates the metadata for a participant in a LiveKit room. - * - * @param roomId The ID of the room - * @param participantIdentity The identity of the participant - * @param metadata The metadata to update - */ -export const updateParticipantMetadata = async ( - roomId: string, - participantIdentity: string, - metadata: MeetRoomMemberTokenMetadata -) => { - await ensureLivekitCliInstalled(); - spawn('lk', [ - 'room', - 'participants', - 'update', - '--room', - roomId, - '--identity', - participantIdentity, - '--metadata', - JSON.stringify(metadata), - '--api-key', - MEET_ENV.LIVEKIT_API_KEY, - '--api-secret', - MEET_ENV.LIVEKIT_API_SECRET - ]); - await sleep('1s'); -}; - -export const disconnectFakeParticipants = async () => { - fakeParticipantsProcesses.forEach((process, participant) => { - process.kill(); - console.log(`Stopped process for participant '${participant}'`); - }); - - fakeParticipantsProcesses.clear(); - await sleep('1s'); // TODO - replace with a more robust solution to ensure webhook is processed before proceeding with the tests -}; - export const updateParticipant = async ( roomId: string, participantIdentity: string, @@ -874,7 +817,8 @@ export const endMeeting = async (roomId: string, moderatorToken: string) => { .delete(getFullPath(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/meetings/${roomId}`)) .set(INTERNAL_CONFIG.ROOM_MEMBER_TOKEN_HEADER, moderatorToken) .send(); - await sleep('5s'); // TODO - replace with a more robust solution to ensure webhook is processed before proceeding with the tests + + await waitForMeetingToEnd(roomId); return response; }; @@ -934,7 +878,7 @@ export const stopRecording = async ( } const response = await req; - await sleep('2.5s'); // TODO - replace with a more robust solution to ensure webhook is processed before proceeding with the tests + await waitForRecordingToStop(recordingId); return response; }; @@ -1107,7 +1051,7 @@ export const stopAllRecordings = async () => { results.forEach((response) => { expect(response.status).toBe(202); }); - await sleep('1s'); + await waitForAllRecordingsToStop(recordingIds); }; export const deleteAllRecordings = async () => { @@ -1164,51 +1108,3 @@ const checkAppIsRunning = () => { throw new Error('App instance is not defined'); } }; - -/** - * Verifies that the LiveKit CLI tool 'lk' is installed and accessible - * @throws Error if 'lk' command is not found - */ -const ensureLivekitCliInstalled = async (): Promise => { - return new Promise((resolve, reject) => { - const checkProcess = spawn('lk', ['--version'], { - stdio: 'pipe' - }); - - let hasResolved = false; - - const resolveOnce = (success: boolean, message?: string) => { - if (hasResolved) return; - - hasResolved = true; - - if (success) { - resolve(); - } else { - reject(new Error(message || 'LiveKit CLI check failed')); - } - }; - - checkProcess.on('error', (error) => { - if (error.message.includes('ENOENT')) { - resolveOnce(false, '❌ LiveKit CLI tool "lk" is not installed or not in PATH.'); - } else { - resolveOnce(false, `Failed to check LiveKit CLI: ${error.message}`); - } - }); - - checkProcess.on('exit', (code) => { - if (code === 0) { - resolveOnce(true); - } else { - resolveOnce(false, `LiveKit CLI exited with code ${code}`); - } - }); - - // Timeout after 5 seconds - setTimeout(() => { - checkProcess.kill(); - resolveOnce(false, 'LiveKit CLI check timed out'); - }, 5000); - }); -}; diff --git a/meet-ce/backend/tests/helpers/test-scenarios.ts b/meet-ce/backend/tests/helpers/test-scenarios.ts index 33f6ce8d..3e42e072 100644 --- a/meet-ce/backend/tests/helpers/test-scenarios.ts +++ b/meet-ce/backend/tests/helpers/test-scenarios.ts @@ -16,13 +16,13 @@ import { MeetRoomHelper } from '../../src/helpers/room.helper'; import { RoomRepository } from '../../src/repositories/room.repository'; import { RoomData, RoomMemberData, RoomTestUsers, TestContext, TestUsers, UserData } from '../interfaces/scenarios'; import { expectValidStartRecordingResponse } from './assertion-helpers'; +import { joinFakeParticipant } from './livekit-cli-helpers.js'; import { changePassword, createRoom, createRoomMember, createUser, generateRoomMemberToken, - joinFakeParticipant, loginUser, sleep, startRecording, diff --git a/meet-ce/backend/tests/helpers/wait-helpers.ts b/meet-ce/backend/tests/helpers/wait-helpers.ts new file mode 100644 index 00000000..ccad813e --- /dev/null +++ b/meet-ce/backend/tests/helpers/wait-helpers.ts @@ -0,0 +1,303 @@ +import { MeetRecordingStatus, MeetRoomStatus } from '@openvidu-meet/typings'; +import { container } from '../../src/config/dependency-injector.config.js'; +import { RecordingRepository } from '../../src/repositories/recording.repository.js'; +import { RoomRepository } from '../../src/repositories/room.repository.js'; +import { LiveKitService } from '../../src/services/livekit.service.js'; + +// ─── CONFIGURATION ─────────────────────────────────────────────────────────── + +const DEFAULT_POLL_INTERVAL_MS = 250; +const DEFAULT_ROOM_TIMEOUT_MS = 15_000; +const DEFAULT_RECORDING_TIMEOUT_MS = 30_000; +const DEFAULT_PARTICIPANT_TIMEOUT_MS = 15_000; + +// ─── GENERIC POLLING ───────────────────────────────────────────────────────── + +/** + * Generic active-wait utility. + * + * Repeatedly invokes `condition` every `intervalMs` milliseconds until it + * returns `true` or `timeoutMs` milliseconds have elapsed. + * Throws a descriptive error if the condition is never satisfied. + */ +const pollUntil = async ( + condition: () => Promise, + options: { + intervalMs?: number; + timeoutMs?: number; + errorMessage?: string; + } = {} +): Promise => { + const intervalMs = options.intervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const timeoutMs = options.timeoutMs ?? DEFAULT_ROOM_TIMEOUT_MS; + const errorMessage = options.errorMessage ?? 'pollUntil: condition was not met within the timeout'; + + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (await condition()) return; + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error(`${errorMessage} (timeout: ${timeoutMs}ms)`); +}; + +// ─── ROOM WAIT HELPERS ──────────────────────────────────────────────────────── + +/** + * Waits until a room no longer exists in the repository. + * + * This helper is intended for flows where the expected backend side effect is + * deletion of the room document. + * + * Polls `RoomRepository` directly to observe the persisted backend state with + * no HTTP indirection. + * + * @param roomId - Room identifier to poll. + * @param timeoutMs - Maximum wait time in milliseconds (default: 15 000). + */ +export const waitForRoomToDelete = async (roomId: string, timeoutMs = DEFAULT_ROOM_TIMEOUT_MS): Promise => { + const roomRepository = container.get(RoomRepository); + + await pollUntil( + async () => { + const room = await roomRepository.findByRoomId(roomId); + return !room; + }, + { timeoutMs, errorMessage: `Room '${roomId}': meeting did not end` } + ); +}; + +/** + * Waits until every room in `roomIds` has been deleted. + * + * All rooms are polled concurrently. + * + * @param roomIds - Array of room identifiers to poll. + * @param timeoutMs - Maximum wait time per room in milliseconds (default: 15 000). + */ +export const waitForAllRoomsToDelete = async ( + roomIds: string[], + timeoutMs = DEFAULT_ROOM_TIMEOUT_MS +): Promise => { + await Promise.all(roomIds.map((id) => waitForRoomToDelete(id, timeoutMs))); +}; + +/** + * Waits until a room has been created and is available in the repository. + * + * Polls `RoomRepository` directly until `findByRoomId(roomId)` returns a + * document. + * + * @param roomId - Room identifier to poll. + * @param timeoutMs - Maximum wait time in milliseconds (default: 15 000). + */ +export const waitForRoomToCreate = async (roomId: string, timeoutMs = DEFAULT_ROOM_TIMEOUT_MS): Promise => { + const roomRepository = container.get(RoomRepository); + + await pollUntil( + async () => { + const room = await roomRepository.findByRoomId(roomId); + return !!room; + }, + { timeoutMs, errorMessage: `Room '${roomId}' was not created` } + ); +}; + +// ─── PARTICIPANT WAIT HELPERS ───────────────────────────────────────────────── + +/** + * Waits until all participants have disconnected from the given LiveKit rooms. + * + * Queries `LiveKitService.roomHasParticipants()` directly — the authoritative + * source of truth for participant presence — so no HTTP auth overhead is + * incurred. + * + * Resolves immediately if `roomIds` is empty. + * + * @param roomIds - Room identifiers to check for remaining participants. + * @param timeoutMs - Maximum wait time in milliseconds (default: 15 000). + */ +export const waitForParticipantsToDisconnect = async ( + roomIds: string[], + timeoutMs = DEFAULT_PARTICIPANT_TIMEOUT_MS +): Promise => { + if (roomIds.length === 0) return; + + const livekitService = container.get(LiveKitService); + + await pollUntil( + async () => { + const checks = await Promise.all(roomIds.map((id) => livekitService.roomHasParticipants(id))); + console.log(`Checked participant presence in rooms [${roomIds.join(', ')}]:`, checks); + return checks.every((hasParticipants) => !hasParticipants); + }, + { + timeoutMs, + errorMessage: `Participants in rooms [${roomIds.join(', ')}] did not disconnect` + } + ); +}; + +/** + * Waits until a room reaches the {@link MeetRoomStatus.CLOSED} state. + * + * The room must still exist in the repository; deletion does not satisfy this + * condition. + * + * @param roomId - Room identifier to poll. + * @param timeoutMs - Maximum wait time in milliseconds (default: 15 000). + */ +export const waitForRoomToClose = async (roomId: string, timeoutMs = DEFAULT_ROOM_TIMEOUT_MS): Promise => { + const roomRepository = container.get(RoomRepository); + + await pollUntil( + async () => { + const room = await roomRepository.findByRoomId(roomId); + return !!room && room.status === MeetRoomStatus.CLOSED; + }, + { timeoutMs, errorMessage: `Room '${roomId}' was not closed` } + ); +}; + +/** + * Waits until a participant is present in the given LiveKit room. + * + * Uses `LiveKitService.participantExists()` as the source of truth for + * participant presence. + * + * @param roomId - Room identifier to query. + * @param participantIdentity - Participant identity expected to appear. + * @param timeoutMs - Maximum wait time in milliseconds (default: 15 000). + */ +export const waitForParticipantToConnect = async ( + roomId: string, + participantIdentity: string, + timeoutMs = DEFAULT_PARTICIPANT_TIMEOUT_MS +): Promise => { + const livekitService = container.get(LiveKitService); + + await pollUntil( + async () => { + return await livekitService.participantExists(roomId, participantIdentity); + }, + { + timeoutMs, + errorMessage: `No participants connected to room '${roomId}'` + } + ); +}; + +/** + * Waits until a participant's metadata matches the expected serialized value. + * + * The helper fetches the participant directly from LiveKit and compares its + * `metadata` field with `JSON.stringify(metadata)`. + * + * @param roomId - Room identifier to query. + * @param participantIdentity - Participant identity expected to update. + * @param metadata - Metadata object expected to be stored. + * @param timeoutMs - Maximum wait time in milliseconds (default: 15 000). + */ +export const waitForParticipantToUpdateMetadata = async ( + roomId: string, + participantIdentity: string, + metadata: Record, + timeoutMs = DEFAULT_PARTICIPANT_TIMEOUT_MS +): Promise => { + const livekitService = container.get(LiveKitService); + + await pollUntil( + async () => { + const participant = await livekitService.getParticipant(roomId, participantIdentity); + + if (!participant) return false; + + return participant.metadata === JSON.stringify(metadata); + }, + { + timeoutMs, + errorMessage: `Participant '${participantIdentity}' in room '${roomId}' did not update metadata to ${JSON.stringify( + metadata + )}` + } + ); +}; + +// ─── RECORDING WAIT HELPERS ─────────────────────────────────────────────────── + +/** + * Waits until a recording's `egress_ended` LiveKit webhook has been fully + * processed by the backend handler. + * + * The condition is satisfied when the recording no longer exists in the database + * or its status is no longer {@link MeetRecordingStatus.ACTIVE}. + * + * Polls `RecordingRepository` directly to observe the exact side-effect written + * by the webhook handler. + * + * @param recordingId - Recording identifier to poll. + * @param timeoutMs - Maximum wait time in milliseconds (default: 30 000). + */ +export const waitForRecordingToStop = async ( + recordingId: string, + timeoutMs = DEFAULT_RECORDING_TIMEOUT_MS +): Promise => { + const recordingRepository = container.get(RecordingRepository); + + await pollUntil( + async () => { + const recording = await recordingRepository.findByRecordingId(recordingId); + + // Recording deleted → handler finished. + if (!recording) return true; + + return [MeetRecordingStatus.COMPLETE, MeetRecordingStatus.FAILED, MeetRecordingStatus.ABORTED].includes( + recording.status + ); + }, + { timeoutMs, errorMessage: `Recording '${recordingId}' did not stop` } + ); +}; + +/** + * Waits until a meeting has ended from both the backend and LiveKit point of view. + * + * The condition is satisfied when either the room no longer exists in the + * repository, or LiveKit no longer reports the room while the stored room state + * is no longer {@link MeetRoomStatus.ACTIVE_MEETING}. + * + * @param roomId - Room identifier to poll. + * @param timeoutMs - Maximum wait time in milliseconds (default: 15 000). + */ +export const waitForMeetingToEnd = async (roomId: string, timeoutMs = DEFAULT_ROOM_TIMEOUT_MS): Promise => { + const roomRepository = container.get(RoomRepository); + const livekitService = container.get(LiveKitService); + + await pollUntil( + async () => { + const lkRoomExists = await livekitService.roomExists(roomId); // Ensure we have the latest room state from LiveKit + const room = await roomRepository.findByRoomId(roomId); + + if (!room) return true; + + return !lkRoomExists && room.status !== MeetRoomStatus.ACTIVE_MEETING; + }, + { timeoutMs, errorMessage: `Meeting in room '${roomId}' did not end` } + ); +}; + +/** + * Waits until all recordings in `recordingIds` have stopped. + * All recordings are polled concurrently. + * + * @param recordingIds - Array of recording identifiers to poll. + * @param timeoutMs - Maximum wait time per recording in milliseconds (default: 30 000). + */ +export const waitForAllRecordingsToStop = async ( + recordingIds: string[], + timeoutMs = DEFAULT_RECORDING_TIMEOUT_MS +): Promise => { + await Promise.all(recordingIds.map((id) => waitForRecordingToStop(id, timeoutMs))); +}; diff --git a/meet-ce/backend/tests/integration/api/meetings/end-meeting.test.ts b/meet-ce/backend/tests/integration/api/meetings/end-meeting.test.ts index 0d9e9ed8..ee20dcde 100644 --- a/meet-ce/backend/tests/integration/api/meetings/end-meeting.test.ts +++ b/meet-ce/backend/tests/integration/api/meetings/end-meeting.test.ts @@ -2,13 +2,9 @@ import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; import { container } from '../../../../src/config/dependency-injector.config.js'; import { OpenViduMeetError } from '../../../../src/models/error.model.js'; import { LiveKitService } from '../../../../src/services/livekit.service.js'; -import { - deleteAllRooms, - disconnectFakeParticipants, - endMeeting, - getRoom, - startTestServer -} from '../../../helpers/request-helpers.js'; +import { disconnectFakeParticipants } from '../../../helpers/livekit-cli-helpers.js'; +import { deleteAllRooms, endMeeting, getRoom, startTestServer } from '../../../helpers/request-helpers.js'; + import { setupSingleRoom } from '../../../helpers/test-scenarios.js'; import { RoomData } from '../../../interfaces/scenarios.js'; diff --git a/meet-ce/backend/tests/integration/api/meetings/kick-participant.test.ts b/meet-ce/backend/tests/integration/api/meetings/kick-participant.test.ts index 7e2c054d..6b216946 100644 --- a/meet-ce/backend/tests/integration/api/meetings/kick-participant.test.ts +++ b/meet-ce/backend/tests/integration/api/meetings/kick-participant.test.ts @@ -3,11 +3,10 @@ import { container } from '../../../../src/config/dependency-injector.config.js' import { OpenViduMeetError } from '../../../../src/models/error.model.js'; import { LiveKitService } from '../../../../src/services/livekit.service.js'; import { - deleteAllRooms, - disconnectFakeParticipants, - kickParticipant, - startTestServer -} from '../../../helpers/request-helpers.js'; + disconnectFakeParticipants +} from '../../../helpers/livekit-cli-helpers.js'; +import { deleteAllRooms, kickParticipant, startTestServer } from '../../../helpers/request-helpers.js'; + import { setupSingleRoom } from '../../../helpers/test-scenarios.js'; import { RoomData } from '../../../interfaces/scenarios.js'; diff --git a/meet-ce/backend/tests/integration/api/meetings/update-participant.test.ts b/meet-ce/backend/tests/integration/api/meetings/update-participant.test.ts index 7e884ff0..0b387fb0 100644 --- a/meet-ce/backend/tests/integration/api/meetings/update-participant.test.ts +++ b/meet-ce/backend/tests/integration/api/meetings/update-participant.test.ts @@ -4,13 +4,8 @@ import { container } from '../../../../src/config/dependency-injector.config.js' import { MEET_ENV } from '../../../../src/environment.js'; import { FrontendEventService } from '../../../../src/services/frontend-event.service.js'; import { LiveKitService } from '../../../../src/services/livekit.service.js'; -import { - deleteAllRooms, - disconnectFakeParticipants, - startTestServer, - updateParticipant, - updateParticipantMetadata -} from '../../../helpers/request-helpers.js'; +import { disconnectFakeParticipants, updateParticipantMetadata } from '../../../helpers/livekit-cli-helpers.js'; +import { deleteAllRooms, startTestServer, updateParticipant } from '../../../helpers/request-helpers.js'; import { setupSingleRoom } from '../../../helpers/test-scenarios.js'; import { RoomData } from '../../../interfaces/scenarios.js'; diff --git a/meet-ce/backend/tests/integration/api/recordings/bulk-delete-recording.test.ts b/meet-ce/backend/tests/integration/api/recordings/bulk-delete-recording.test.ts index cbff64cb..f7e005c5 100644 --- a/meet-ce/backend/tests/integration/api/recordings/bulk-delete-recording.test.ts +++ b/meet-ce/backend/tests/integration/api/recordings/bulk-delete-recording.test.ts @@ -1,15 +1,16 @@ import { afterEach, beforeAll, describe, expect, it } from '@jest/globals'; import { expectValidationError } from '../../../helpers/assertion-helpers'; +import { disconnectFakeParticipants } from '../../../helpers/livekit-cli-helpers.js'; import { bulkDeleteRecordings, deleteAllRecordings, deleteAllRooms, - disconnectFakeParticipants, generateRoomMemberToken, getAllRecordings, startTestServer, stopRecording } from '../../../helpers/request-helpers'; + import { setupMultiRecordingsTestContext, setupSingleRoomWithRecording } from '../../../helpers/test-scenarios'; describe('Recording API Tests', () => { diff --git a/meet-ce/backend/tests/integration/api/recordings/delete-recording.test.ts b/meet-ce/backend/tests/integration/api/recordings/delete-recording.test.ts index 88bb3fba..90d9ff81 100644 --- a/meet-ce/backend/tests/integration/api/recordings/delete-recording.test.ts +++ b/meet-ce/backend/tests/integration/api/recordings/delete-recording.test.ts @@ -1,15 +1,16 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; import { MeetRoom } from '@openvidu-meet/typings'; import { expectValidationError } from '../../../helpers/assertion-helpers'; +import { disconnectFakeParticipants } from '../../../helpers/livekit-cli-helpers.js'; import { deleteAllRecordings, deleteAllRooms, deleteRecording, - disconnectFakeParticipants, startTestServer, stopAllRecordings, stopRecording } from '../../../helpers/request-helpers'; + import { setupMultiRecordingsTestContext } from '../../../helpers/test-scenarios'; describe('Recording API Tests', () => { diff --git a/meet-ce/backend/tests/integration/api/recordings/download-recordings.test.ts b/meet-ce/backend/tests/integration/api/recordings/download-recordings.test.ts index d95920b3..3602851f 100644 --- a/meet-ce/backend/tests/integration/api/recordings/download-recordings.test.ts +++ b/meet-ce/backend/tests/integration/api/recordings/download-recordings.test.ts @@ -2,14 +2,15 @@ import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; import stream from 'stream'; import unzipper from 'unzipper'; import { expectValidationError } from '../../../helpers/assertion-helpers.js'; +import { disconnectFakeParticipants } from '../../../helpers/livekit-cli-helpers.js'; import { deleteAllRecordings, deleteAllRooms, - disconnectFakeParticipants, downloadRecordings, generateRoomMemberToken, startTestServer } from '../../../helpers/request-helpers'; + import { setupMultiRecordingsTestContext, setupSingleRoomWithRecording } from '../../../helpers/test-scenarios'; describe('Recording API Tests', () => { diff --git a/meet-ce/backend/tests/integration/api/recordings/get-media-recording.test.ts b/meet-ce/backend/tests/integration/api/recordings/get-media-recording.test.ts index b4435367..c19f787a 100644 --- a/meet-ce/backend/tests/integration/api/recordings/get-media-recording.test.ts +++ b/meet-ce/backend/tests/integration/api/recordings/get-media-recording.test.ts @@ -1,14 +1,15 @@ import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; import { MeetRoom } from '@openvidu-meet/typings'; import { expectSuccessRecordingMediaResponse, expectValidationError } from '../../../helpers/assertion-helpers'; +import { disconnectFakeParticipants } from '../../../helpers/livekit-cli-helpers.js'; import { deleteAllRecordings, deleteAllRooms, - disconnectFakeParticipants, getRecordingMedia, startTestServer, stopRecording } from '../../../helpers/request-helpers'; + import { setupMultiRecordingsTestContext } from '../../../helpers/test-scenarios'; describe('Recording API Tests', () => { diff --git a/meet-ce/backend/tests/integration/api/recordings/get-recording-url.test.ts b/meet-ce/backend/tests/integration/api/recordings/get-recording-url.test.ts index 57bd7f61..0f18c281 100644 --- a/meet-ce/backend/tests/integration/api/recordings/get-recording-url.test.ts +++ b/meet-ce/backend/tests/integration/api/recordings/get-recording-url.test.ts @@ -4,14 +4,17 @@ import request from 'supertest'; import { INTERNAL_CONFIG } from '../../../../src/config/internal-config.js'; import { errorRecordingNotFound } from '../../../../src/models/error.model.js'; import { expectValidGetRecordingUrlResponse } from '../../../helpers/assertion-helpers.js'; +import { + disconnectFakeParticipants +} from '../../../helpers/livekit-cli-helpers.js'; import { deleteAllRecordings, deleteAllRooms, - disconnectFakeParticipants, getFullPath, getRecordingUrl, startTestServer } from '../../../helpers/request-helpers.js'; + import { setupSingleRoomWithRecording } from '../../../helpers/test-scenarios.js'; describe('Recording API Tests', () => { diff --git a/meet-ce/backend/tests/integration/api/recordings/get-recording.test.ts b/meet-ce/backend/tests/integration/api/recordings/get-recording.test.ts index 2fe2b1c7..2ffd4a0a 100644 --- a/meet-ce/backend/tests/integration/api/recordings/get-recording.test.ts +++ b/meet-ce/backend/tests/integration/api/recordings/get-recording.test.ts @@ -6,14 +6,15 @@ import { expectValidGetRecordingResponse, expectValidRecordingWithFields } from '../../../helpers/assertion-helpers.js'; +import { disconnectFakeParticipants } from '../../../helpers/livekit-cli-helpers.js'; import { deleteAllRecordings, deleteAllRooms, - disconnectFakeParticipants, getRecording, startTestServer, stopAllRecordings } from '../../../helpers/request-helpers.js'; + import { setupMultiRecordingsTestContext } from '../../../helpers/test-scenarios.js'; import { TestContext } from '../../../interfaces/scenarios.js'; diff --git a/meet-ce/backend/tests/integration/api/recordings/get-recordings.test.ts b/meet-ce/backend/tests/integration/api/recordings/get-recordings.test.ts index bd695ae1..c5be6372 100644 --- a/meet-ce/backend/tests/integration/api/recordings/get-recordings.test.ts +++ b/meet-ce/backend/tests/integration/api/recordings/get-recordings.test.ts @@ -6,10 +6,10 @@ import { expectValidRecording, expectValidRecordingWithFields } from '../../../helpers/assertion-helpers.js'; +import { disconnectFakeParticipants } from '../../../helpers/livekit-cli-helpers.js'; import { deleteAllRecordings, deleteAllRooms, - disconnectFakeParticipants, generateRoomMemberToken, getAllRecordings, getAllRecordingsFromRoom, @@ -17,6 +17,7 @@ import { startTestServer, stopRecording } from '../../../helpers/request-helpers.js'; + import { setupMultiRecordingsTestContext, setupSingleRoomWithRecording } from '../../../helpers/test-scenarios.js'; import { RoomData, TestContext } from '../../../interfaces/scenarios.js'; diff --git a/meet-ce/backend/tests/integration/api/recordings/race-conditions.test.ts b/meet-ce/backend/tests/integration/api/recordings/race-conditions.test.ts index b54e7d24..759a0adf 100644 --- a/meet-ce/backend/tests/integration/api/recordings/race-conditions.test.ts +++ b/meet-ce/backend/tests/integration/api/recordings/race-conditions.test.ts @@ -9,12 +9,12 @@ import { expectValidStopRecordingResponse } from '../../../helpers/assertion-helpers'; import { eventController } from '../../../helpers/event-controller'; +import { disconnectFakeParticipants } from '../../../helpers/livekit-cli-helpers.js'; import { bulkDeleteRecordings, deleteAllRecordings, deleteAllRooms, deleteRecording, - disconnectFakeParticipants, getRecording, getRecordingMedia, sleep, @@ -23,6 +23,7 @@ import { stopAllRecordings, stopRecording } from '../../../helpers/request-helpers'; + import { setupMultiRecordingsTestContext, setupMultiRoomTestContext } from '../../../helpers/test-scenarios'; import { TestContext } from '../../../interfaces/scenarios.js'; @@ -39,12 +40,12 @@ describe('Recording API Race Conditions Tests', () => { }); afterEach(async () => { + await disconnectFakeParticipants(); await stopAllRecordings(); - eventController.reset(); - await disconnectFakeParticipants(); await deleteAllRooms(); await deleteAllRecordings(); + eventController.reset(); jest.clearAllMocks(); }); diff --git a/meet-ce/backend/tests/integration/api/recordings/recording-header-fields.test.ts b/meet-ce/backend/tests/integration/api/recordings/recording-header-fields.test.ts index 8766ba29..03318369 100644 --- a/meet-ce/backend/tests/integration/api/recordings/recording-header-fields.test.ts +++ b/meet-ce/backend/tests/integration/api/recordings/recording-header-fields.test.ts @@ -3,13 +3,14 @@ import { expectValidRecordingLocationHeader, expectValidRecordingWithFields } from '../../../helpers/assertion-helpers.js'; +import { disconnectFakeParticipants } from '../../../helpers/livekit-cli-helpers.js'; import { deleteAllRecordings, deleteAllRooms, - disconnectFakeParticipants, startTestServer, stopRecording } from '../../../helpers/request-helpers.js'; + import { setupSingleRoomWithRecording } from '../../../helpers/test-scenarios.js'; /** diff --git a/meet-ce/backend/tests/integration/api/recordings/start-recording.test.ts b/meet-ce/backend/tests/integration/api/recordings/start-recording.test.ts index a7c55d4b..0a0916b9 100644 --- a/meet-ce/backend/tests/integration/api/recordings/start-recording.test.ts +++ b/meet-ce/backend/tests/integration/api/recordings/start-recording.test.ts @@ -18,16 +18,16 @@ import { expectValidStartRecordingResponse, expectValidStopRecordingResponse } from '../../../helpers/assertion-helpers.js'; +import { disconnectFakeParticipants, joinFakeParticipant } from '../../../helpers/livekit-cli-helpers.js'; import { deleteAllRecordings, deleteAllRooms, - disconnectFakeParticipants, - joinFakeParticipant, startRecording, startTestServer, stopAllRecordings, stopRecording } from '../../../helpers/request-helpers.js'; + import { setupMultiRoomTestContext } from '../../../helpers/test-scenarios.js'; import { TestContext } from '../../../interfaces/scenarios.js'; diff --git a/meet-ce/backend/tests/integration/api/recordings/stop-recording.test.ts b/meet-ce/backend/tests/integration/api/recordings/stop-recording.test.ts index 9c745dc1..b5f41222 100644 --- a/meet-ce/backend/tests/integration/api/recordings/stop-recording.test.ts +++ b/meet-ce/backend/tests/integration/api/recordings/stop-recording.test.ts @@ -6,15 +6,16 @@ import { expectValidRecordingWithFields, expectValidStopRecordingResponse } from '../../../helpers/assertion-helpers'; +import { disconnectFakeParticipants } from '../../../helpers/livekit-cli-helpers.js'; import { deleteAllRecordings, deleteAllRooms, - disconnectFakeParticipants, startRecording, startTestServer, stopAllRecordings, stopRecording } from '../../../helpers/request-helpers'; + import { setupMultiRoomTestContext } from '../../../helpers/test-scenarios'; import { TestContext } from '../../../interfaces/scenarios'; diff --git a/meet-ce/backend/tests/integration/api/room-members/bulk-delete-room-members.test.ts b/meet-ce/backend/tests/integration/api/room-members/bulk-delete-room-members.test.ts index 059c96d3..d2a71146 100644 --- a/meet-ce/backend/tests/integration/api/room-members/bulk-delete-room-members.test.ts +++ b/meet-ce/backend/tests/integration/api/room-members/bulk-delete-room-members.test.ts @@ -4,6 +4,7 @@ import { container } from '../../../../src/config/dependency-injector.config.js' import { OpenViduMeetError } from '../../../../src/models/error.model.js'; import { LiveKitService } from '../../../../src/services/livekit.service.js'; import { expectValidationError } from '../../../helpers/assertion-helpers.js'; +import { disconnectFakeParticipants, joinFakeParticipant } from '../../../helpers/livekit-cli-helpers.js'; import { bulkDeleteRoomMembers, createRoom, @@ -11,10 +12,9 @@ import { createUser, deleteAllRooms, deleteAllUsers, - disconnectFakeParticipants, + getRoomMember, getUser, - joinFakeParticipant, startTestServer } from '../../../helpers/request-helpers.js'; diff --git a/meet-ce/backend/tests/integration/api/room-members/delete-room-member.test.ts b/meet-ce/backend/tests/integration/api/room-members/delete-room-member.test.ts index 5492b487..94ba9838 100644 --- a/meet-ce/backend/tests/integration/api/room-members/delete-room-member.test.ts +++ b/meet-ce/backend/tests/integration/api/room-members/delete-room-member.test.ts @@ -11,14 +11,17 @@ import { deleteAllRooms, deleteAllUsers, deleteRoomMember, - disconnectFakeParticipants, getRoomMember, getUser, - joinFakeParticipant, - startTestServer, - updateParticipantMetadata + startTestServer } from '../../../helpers/request-helpers.js'; +import { + disconnectFakeParticipants, + joinFakeParticipant, + updateParticipantMetadata +} from '../../../helpers/livekit-cli-helpers.js'; + describe('Room Members API Tests', () => { let roomId: string; diff --git a/meet-ce/backend/tests/integration/api/room-members/generate-room-member-token.test.ts b/meet-ce/backend/tests/integration/api/room-members/generate-room-member-token.test.ts index e3116b1c..446bbff1 100644 --- a/meet-ce/backend/tests/integration/api/room-members/generate-room-member-token.test.ts +++ b/meet-ce/backend/tests/integration/api/room-members/generate-room-member-token.test.ts @@ -8,12 +8,12 @@ import { MeetUserRole } from '@openvidu-meet/typings'; import { expectValidationError, expectValidRoomMemberTokenResponse } from '../../../helpers/assertion-helpers.js'; +import { disconnectFakeParticipants } from '../../../helpers/livekit-cli-helpers.js'; import { createRoom, createRoomMember, deleteAllRooms, deleteAllUsers, - disconnectFakeParticipants, endMeeting, generateRoomMemberToken, generateRoomMemberTokenRequest, diff --git a/meet-ce/backend/tests/integration/api/room-members/update-room-member.test.ts b/meet-ce/backend/tests/integration/api/room-members/update-room-member.test.ts index 28888be3..663032ce 100644 --- a/meet-ce/backend/tests/integration/api/room-members/update-room-member.test.ts +++ b/meet-ce/backend/tests/integration/api/room-members/update-room-member.test.ts @@ -4,15 +4,15 @@ import { container } from '../../../../src/config/dependency-injector.config.js' import { OpenViduMeetError } from '../../../../src/models/error.model.js'; import { LiveKitService } from '../../../../src/services/livekit.service.js'; import { expectValidationError } from '../../../helpers/assertion-helpers.js'; +import { disconnectFakeParticipants, joinFakeParticipant } from '../../../helpers/livekit-cli-helpers.js'; import { createRoom, createRoomMember, createUser, deleteAllRooms, deleteAllUsers, - disconnectFakeParticipants, + getRoomMember, - joinFakeParticipant, sleep, startTestServer, updateRoomMember diff --git a/meet-ce/backend/tests/integration/api/rooms/active-status-rooms-gc.test.ts b/meet-ce/backend/tests/integration/api/rooms/active-status-rooms-gc.test.ts index e2a6b2ee..e2964e51 100644 --- a/meet-ce/backend/tests/integration/api/rooms/active-status-rooms-gc.test.ts +++ b/meet-ce/backend/tests/integration/api/rooms/active-status-rooms-gc.test.ts @@ -3,11 +3,11 @@ import { MeetRoomStatus } from '@openvidu-meet/typings'; import { container } from '../../../../src/config/dependency-injector.config.js'; import { RoomRepository } from '../../../../src/repositories/room.repository.js'; import { LiveKitService } from '../../../../src/services/livekit.service.js'; +import { disconnectFakeParticipants } from '../../../helpers/livekit-cli-helpers.js'; import { createRoom, deleteAllRecordings, deleteAllRooms, - disconnectFakeParticipants, executeRoomStatusValidationGC, getRoom, startTestServer diff --git a/meet-ce/backend/tests/integration/api/rooms/bulk-delete-rooms.test.ts b/meet-ce/backend/tests/integration/api/rooms/bulk-delete-rooms.test.ts index 0f708b57..d2e11d7b 100644 --- a/meet-ce/backend/tests/integration/api/rooms/bulk-delete-rooms.test.ts +++ b/meet-ce/backend/tests/integration/api/rooms/bulk-delete-rooms.test.ts @@ -9,17 +9,17 @@ import { MeetRoomStatus } from '@openvidu-meet/typings'; import { expectExtraFieldsInResponse, expectValidRoom } from '../../../helpers/assertion-helpers.js'; +import { disconnectFakeParticipants } from '../../../helpers/livekit-cli-helpers.js'; import { bulkDeleteRooms, createRoom, deleteAllRecordings, deleteAllRooms, - disconnectFakeParticipants, endMeeting, - getRoom, startTestServer } from '../../../helpers/request-helpers.js'; import { setupSingleRoom, setupSingleRoomWithRecording } from '../../../helpers/test-scenarios.js'; +import { waitForAllRoomsToDelete, waitForRoomToClose } from '../../../helpers/wait-helpers.js'; describe('Room API Tests', () => { beforeAll(async () => { @@ -37,6 +37,7 @@ describe('Room API Tests', () => { const { roomId } = await createRoom(); const response = await bulkDeleteRooms([roomId]); + await waitForAllRoomsToDelete([roomId]); expect(response.status).toBe(200); expect(response.body).toEqual({ message: 'All rooms successfully processed for deletion', @@ -55,6 +56,7 @@ describe('Room API Tests', () => { const { room: room2 } = await setupSingleRoom(true); const response = await bulkDeleteRooms([room1.roomId, room2.roomId]); + await waitForAllRoomsToDelete([room1.roomId]); expect(response.status).toBe(400); expect(response.body).toEqual({ message: '1 room(s) failed to process while deleting', @@ -97,6 +99,7 @@ describe('Room API Tests', () => { const { roomId } = await createRoom(); const response = await bulkDeleteRooms([roomId, roomId, roomId]); + await waitForAllRoomsToDelete([roomId]); expect(response.status).toBe(200); expect(response.body).toEqual({ message: 'All rooms successfully processed for deletion', @@ -114,6 +117,7 @@ describe('Room API Tests', () => { const { roomId } = await createRoom(); const response = await bulkDeleteRooms([roomId, '!!@##$']); + await waitForAllRoomsToDelete([roomId]); expect(response.status).toBe(200); expect(response.body).toEqual({ message: 'All rooms successfully processed for deletion', @@ -147,27 +151,32 @@ describe('Room API Tests', () => { }); // Verify all rooms are deleted - for (const room of rooms) { - const getResponse = await getRoom(room.roomId); - expect(getResponse.status).toBe(404); - } + await waitForAllRoomsToDelete(rooms.map((r) => r.roomId)); }); it('should handle deletion when specifying withMeeting and withRecordings parameters', async () => { - const [room1, { room: room2 }, { room: room3 }, { room: room4, moderatorToken }] = await Promise.all([ + const [ + room1, + { room: room2, moderatorToken: modToken2 }, + { room: room3, moderatorToken: modToken3 }, + { room: room4, moderatorToken: modToken4 } + ] = await Promise.all([ createRoom(), // Room without active meeting or recordings setupSingleRoom(true), // Room with active meeting setupSingleRoomWithRecording(true), // Room with active meeting and recordings setupSingleRoomWithRecording(true) // Room with recordings ]); - await endMeeting(room4.roomId, moderatorToken); + await endMeeting(room4.roomId, modToken4); // End meeting for room4 so it has recordings but no active meeting const fakeRoomId = 'fake_room-123'; // Non-existing room - const response = await bulkDeleteRooms( [room1.roomId, room2.roomId, room3.roomId, room4.roomId, fakeRoomId], MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS, MeetRoomDeletionPolicyWithRecordings.CLOSE ); + + // Room 3 and Room 2 are scheduled to be closed, so we need to wait for the meeting to end before asserting the response + await waitForAllRoomsToDelete([room1.roomId]); + await waitForRoomToClose(room4.roomId); // Room 4 should be CLOSED expect(response.status).toBe(400); expect(response.body).toEqual({ message: '1 room(s) failed to process while deleting', @@ -252,6 +261,12 @@ describe('Room API Tests', () => { MeetingEndAction.NONE ); expectExtraFieldsInResponse(successfulRoom4.room); + + await endMeeting(room2.roomId, modToken2); + await endMeeting(room3.roomId, modToken3); + + await waitForAllRoomsToDelete([room2.roomId]); + await waitForRoomToClose(room3.roomId); // Room 3 should be CLOSED }); it('should return partial room properties based on fields parameter when some rooms fail due to active meetings', async () => { diff --git a/meet-ce/backend/tests/integration/api/rooms/delete-room.test.ts b/meet-ce/backend/tests/integration/api/rooms/delete-room.test.ts index cafb1f42..5178c662 100644 --- a/meet-ce/backend/tests/integration/api/rooms/delete-room.test.ts +++ b/meet-ce/backend/tests/integration/api/rooms/delete-room.test.ts @@ -12,18 +12,19 @@ import { expectSuccessListRecordingResponse, expectValidRoom } from '../../../helpers/assertion-helpers.js'; +import { disconnectFakeParticipants } from "../../../helpers/livekit-cli-helpers.js"; import { createRoom, deleteAllRecordings, deleteAllRooms, deleteRoom, - disconnectFakeParticipants, endMeeting, getAllRecordings, getRoom, startTestServer } from '../../../helpers/request-helpers.js'; import { setupSingleRoom, setupSingleRoomWithRecording } from '../../../helpers/test-scenarios.js'; +import { waitForParticipantsToDisconnect, waitForRoomToClose, waitForRoomToDelete } from '../../../helpers/wait-helpers.js'; describe('Room API Tests', () => { beforeAll(async () => { @@ -76,7 +77,7 @@ describe('Room API Tests', () => { MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_DELETED ); expect(response.body).not.toHaveProperty('room'); - + await waitForRoomToDelete(roomId); // Wait for the meeting to end and webhook to be processed // Check room is deleted const getResponse = await getRoom(roomId); expect(getResponse.status).toBe(404); @@ -105,6 +106,7 @@ describe('Room API Tests', () => { // End meeting and check the room is deleted await endMeeting(roomId, moderatorToken); + await waitForRoomToDelete(roomId); // Wait for the meeting to end and webhook to be processed const getResponse = await getRoom(roomId); expect(getResponse.status).toBe(404); }); @@ -279,6 +281,7 @@ describe('Room API Tests', () => { const response = await deleteRoom(roomId, { withRecordings: MeetRoomDeletionPolicyWithRecordings.FORCE }); + await waitForRoomToDelete(roomId); // Wait for the webhook to process the deletion expect(response.status).toBe(200); expect(response.body).toHaveProperty( 'successCode', @@ -350,6 +353,7 @@ describe('Room API Tests', () => { MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_AND_RECORDINGS_DELETED ); expect(response.body).not.toHaveProperty('room'); + await waitForRoomToDelete(roomId); // Wait for the meeting to end and webhook to be processed // Check the room and recordings are deleted const roomResponse = await getRoom(roomId); @@ -363,6 +367,7 @@ describe('Room API Tests', () => { withMeeting: MeetRoomDeletionPolicyWithMeeting.FORCE, withRecordings: MeetRoomDeletionPolicyWithRecordings.CLOSE }); + expect(response.status).toBe(200); expect(response.body).toHaveProperty( 'successCode', @@ -379,6 +384,8 @@ describe('Room API Tests', () => { MeetingEndAction.CLOSE ); expectExtraFieldsInResponse(response.body.room); + await waitForParticipantsToDisconnect([roomId]); // Wait for participants to be disconnected after meeting is closed + await waitForRoomToClose(roomId); // Wait for the room status to be updated to closed // Check that the room is closed and recordings are not deleted const roomResponse = await getRoom(roomId); @@ -434,6 +441,7 @@ describe('Room API Tests', () => { // End meeting and check the room and recordings are deleted await endMeeting(roomId, moderatorToken); + await waitForRoomToDelete(roomId); const roomResponse = await getRoom(roomId); expect(roomResponse.status).toBe(404); const recordingsResponse = await getAllRecordings({ roomId, maxItems: 1 }); diff --git a/meet-ce/backend/tests/integration/api/rooms/expired-rooms-gc.test.ts b/meet-ce/backend/tests/integration/api/rooms/expired-rooms-gc.test.ts index 86c98e18..b027ca4b 100644 --- a/meet-ce/backend/tests/integration/api/rooms/expired-rooms-gc.test.ts +++ b/meet-ce/backend/tests/integration/api/rooms/expired-rooms-gc.test.ts @@ -3,21 +3,22 @@ import { MeetRoomDeletionPolicyWithMeeting, MeetRoomDeletionPolicyWithRecordings import ms from 'ms'; import { setInternalConfig } from '../../../../src/config/internal-config.js'; import { MeetRoomHelper } from '../../../../src/helpers/room.helper.js'; +import { disconnectFakeParticipants, joinFakeParticipant } from '../../../helpers/livekit-cli-helpers.js'; import { createRoom, deleteAllRecordings, deleteAllRooms, - disconnectFakeParticipants, endMeeting, generateRoomMemberToken, getRoom, - joinFakeParticipant, runExpiredRoomsGC, sleep, startRecording, startTestServer } from '../../../helpers/request-helpers.js'; +import { waitForRoomToClose, waitForRoomToDelete } from '../../../helpers/wait-helpers.js'; + describe('Expired Rooms GC Tests', () => { beforeAll(async () => { setInternalConfig({ @@ -58,7 +59,7 @@ describe('Expired Rooms GC Tests', () => { autoDeletionDate: Date.now() + ms('1s') }); await joinFakeParticipant(createdRoom.roomId, 'test-participant'); - + await sleep('2s'); // Make sure the auto-deletion date has passed await runExpiredRoomsGC(); // The room should not be deleted but scheduled for deletion @@ -88,7 +89,7 @@ describe('Expired Rooms GC Tests', () => { autoDeletionDate: Date.now() + ms('1s') }); await joinFakeParticipant(room.roomId, 'test-participant'); - + await sleep('2s'); // Make sure the auto-deletion date has passed await runExpiredRoomsGC(); // The room should not be deleted but scheduled for deletion @@ -102,6 +103,7 @@ describe('Expired Rooms GC Tests', () => { const moderatorToken = await generateRoomMemberToken(room.roomId, { secret: moderatorSecret }); await endMeeting(room.roomId, moderatorToken); + await waitForRoomToDelete(room.roomId); // Verify that the room is deleted response = await getRoom(room.roomId); expect(response.status).toBe(404); @@ -179,6 +181,7 @@ describe('Expired Rooms GC Tests', () => { await startRecording(room1.roomId); await runExpiredRoomsGC(); + await waitForRoomToClose(room1.roomId); const response = await getRoom(room1.roomId); expect(response.status).toBe(200); diff --git a/meet-ce/backend/tests/integration/api/rooms/room-header-fields.test.ts b/meet-ce/backend/tests/integration/api/rooms/room-header-fields.test.ts index 40c8f0c6..e8121731 100644 --- a/meet-ce/backend/tests/integration/api/rooms/room-header-fields.test.ts +++ b/meet-ce/backend/tests/integration/api/rooms/room-header-fields.test.ts @@ -11,13 +11,13 @@ import { expectValidRoom, expectValidRoomWithFields } from '../../../helpers/assertion-helpers.js'; +import { disconnectFakeParticipants } from '../../../helpers/livekit-cli-helpers.js'; import { bulkDeleteRooms, createRoom, deleteAllRecordings, deleteAllRooms, deleteRoom, - disconnectFakeParticipants, getRoom, getRooms, startTestServer diff --git a/meet-ce/backend/tests/integration/api/rooms/update-room-status.test.ts b/meet-ce/backend/tests/integration/api/rooms/update-room-status.test.ts index 35ad4431..eaf86da1 100644 --- a/meet-ce/backend/tests/integration/api/rooms/update-room-status.test.ts +++ b/meet-ce/backend/tests/integration/api/rooms/update-room-status.test.ts @@ -1,15 +1,16 @@ import { afterEach, beforeAll, describe, expect, it } from '@jest/globals'; +import { MeetRoomStatus } from '@openvidu-meet/typings'; +import { disconnectFakeParticipants } from '../../../helpers/livekit-cli-helpers.js'; import { createRoom, deleteAllRooms, - disconnectFakeParticipants, endMeeting, getRoom, startTestServer, updateRoomStatus } from '../../../helpers/request-helpers.js'; import { setupSingleRoom } from '../../../helpers/test-scenarios.js'; -import { MeetRoomStatus } from '@openvidu-meet/typings'; +import { waitForRoomToClose } from '../../../helpers/wait-helpers.js'; describe('Room API Tests', () => { beforeAll(async () => { @@ -70,7 +71,7 @@ describe('Room API Tests', () => { // End meeting and verify closed status await endMeeting(roomData.room.roomId, roomData.moderatorToken); - + await waitForRoomToClose(roomData.room.roomId); getResponse = await getRoom(roomData.room.roomId); expect(getResponse.status).toBe(200); expect(getResponse.body.status).toEqual('closed'); diff --git a/meet-ce/backend/tests/integration/api/security/meeting-security.test.ts b/meet-ce/backend/tests/integration/api/security/meeting-security.test.ts index bdc80471..8b18503c 100644 --- a/meet-ce/backend/tests/integration/api/security/meeting-security.test.ts +++ b/meet-ce/backend/tests/integration/api/security/meeting-security.test.ts @@ -5,14 +5,12 @@ import request from 'supertest'; import { INTERNAL_CONFIG } from '../../../../src/config/internal-config.js'; import { MEET_ENV } from '../../../../src/environment.js'; import { - deleteAllRooms, disconnectFakeParticipants, - getFullPath, joinFakeParticipant, - loginRootAdmin, - startTestServer, updateParticipantMetadata -} from '../../../helpers/request-helpers.js'; +} from '../../../helpers/livekit-cli-helpers.js'; +import { deleteAllRooms, getFullPath, loginRootAdmin, startTestServer } from '../../../helpers/request-helpers.js'; + import { setupRoomMember, setupSingleRoom, updateRoomMemberPermissions } from '../../../helpers/test-scenarios.js'; import { RoomData, RoomMemberData } from '../../../interfaces/scenarios.js'; diff --git a/meet-ce/backend/tests/integration/api/security/recording-security.test.ts b/meet-ce/backend/tests/integration/api/security/recording-security.test.ts index 991b3961..ec71655d 100644 --- a/meet-ce/backend/tests/integration/api/security/recording-security.test.ts +++ b/meet-ce/backend/tests/integration/api/security/recording-security.test.ts @@ -5,11 +5,11 @@ import request from 'supertest'; import { INTERNAL_CONFIG } from '../../../../src/config/internal-config.js'; import { MEET_ENV } from '../../../../src/environment.js'; import { expectValidStartRecordingResponse } from '../../../helpers/assertion-helpers.js'; +import { disconnectFakeParticipants } from '../../../helpers/livekit-cli-helpers.js'; import { deleteAllRecordings, deleteAllRooms, deleteAllUsers, - disconnectFakeParticipants, getFullPath, getRecordingAccessSecret, sleep, @@ -17,6 +17,7 @@ import { startTestServer, stopAllRecordings } from '../../../helpers/request-helpers.js'; + import { setupCompletedRecording, setupRoomMember, diff --git a/meet-ce/backend/tests/integration/webhooks/webhook.test.ts b/meet-ce/backend/tests/integration/webhooks/webhook.test.ts index 4cf4f0ec..2ec60bfb 100644 --- a/meet-ce/backend/tests/integration/webhooks/webhook.test.ts +++ b/meet-ce/backend/tests/integration/webhooks/webhook.test.ts @@ -6,6 +6,7 @@ import { MeetRecordingStatus, MeetRoom, MeetRoomConfig, + MeetRoomDeletionPolicyWithMeeting, MeetWebhookEvent, MeetWebhookEventType } from '@openvidu-meet/typings'; @@ -18,7 +19,6 @@ import { disconnectFakeParticipants, endMeeting, restoreDefaultGlobalConfig, - sleep, startTestServer, updateWebhookConfig } from '../../helpers/request-helpers.js'; @@ -28,6 +28,7 @@ import { startWebhookServer, stopWebhookServer } from '../../helpers/test-scenarios.js'; +import { waitForRecordingToStop, waitForRoomToDelete } from '../../helpers/wait-helpers.js'; describe('Webhook Integration Tests', () => { let receivedWebhooks: { headers: http.IncomingHttpHeaders; body: MeetWebhookEvent }[] = []; @@ -86,8 +87,6 @@ describe('Webhook Integration Tests', () => { await setupSingleRoom(true); - // Wait for the room to be created - await sleep('3s'); expect(receivedWebhooks.length).toBe(0); }); @@ -95,9 +94,6 @@ describe('Webhook Integration Tests', () => { const context = await setupSingleRoom(true); const roomData = context.room; - // Wait for the room to be created - await sleep('1s'); - // Verify 'meetingStarted' webhook is sent expect(receivedWebhooks.length).toBeGreaterThanOrEqual(1); const meetingStartedWebhook = receivedWebhooks.find( @@ -123,9 +119,6 @@ describe('Webhook Integration Tests', () => { // Close the room await endMeeting(roomData.roomId, moderatorToken); - // Wait for the room to be closed - await sleep('1s'); - // Verify 'meetingEnded' webhook is sent expect(receivedWebhooks.length).toBeGreaterThanOrEqual(1); const meetingEndedWebhook = receivedWebhooks.find((w) => w.body.event === MeetWebhookEventType.MEETING_ENDED); @@ -144,8 +137,8 @@ describe('Webhook Integration Tests', () => { const context = await setupSingleRoom(true); const roomData = context.room; // Forcefully delete the room - await deleteRoom(roomData.roomId, { withMeeting: 'force' }); - + await deleteRoom(roomData.roomId, { withMeeting: MeetRoomDeletionPolicyWithMeeting.FORCE }); + await waitForRoomToDelete(roomData.roomId); // Wait for the webhook to process the deletion // Verify 'meetingEnded' webhook is sent expect(receivedWebhooks.length).toBeGreaterThanOrEqual(1); const meetingEndedWebhook = receivedWebhooks.find((w) => w.body.event === MeetWebhookEventType.MEETING_ENDED); @@ -167,6 +160,8 @@ describe('Webhook Integration Tests', () => { const roomData = context.room; const recordingId = context.recordingId; + await waitForRecordingToStop(recordingId!); // Wait for the recording to stop and webhook to be processed + const recordingWebhooks = receivedWebhooks.filter((w) => w.body.event.startsWith('recording')); // STARTED, ACTIVE, ENDING, COMPLETE expect(recordingWebhooks.length).toBe(4);