From f8a176b4fad8f7b22e71465bd1fa553c20ddbc96 Mon Sep 17 00:00:00 2001 From: Carlos Santos <4a.santos@gmail.com> Date: Wed, 23 Apr 2025 11:01:46 +0200 Subject: [PATCH] backend: Add bulk delete recordings tests and enhance test utilities --- .../recordings/bulk-delete-recording.test.ts | 187 ++++++++++++++++++ backend/tests/utils/helpers.ts | 85 ++------ backend/tests/utils/test-scenarios.ts | 101 ++++++++-- 3 files changed, 294 insertions(+), 79 deletions(-) create mode 100644 backend/tests/integration/api/recordings/bulk-delete-recording.test.ts diff --git a/backend/tests/integration/api/recordings/bulk-delete-recording.test.ts b/backend/tests/integration/api/recordings/bulk-delete-recording.test.ts new file mode 100644 index 0000000..04bebfe --- /dev/null +++ b/backend/tests/integration/api/recordings/bulk-delete-recording.test.ts @@ -0,0 +1,187 @@ +import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; +import { + bulkDeleteRecordings, + deleteAllRecordings, + deleteAllRooms, + startTestServer, + stopRecording, + stopTestServer +} from '../../../utils/helpers'; +import { setupMultiRecordingsTestContext } from '../../../utils/test-scenarios'; + +describe('Recording API Tests', () => { + beforeAll(async () => { + await startTestServer(); + }); + + afterAll(async () => { + await deleteAllRooms(); + await deleteAllRecordings(); + await stopTestServer(); + }); + + describe('Bulk Delete Recording Tests', () => { + it('"should return 200 when mixed valid and non-existent IDs are provided', async () => { + const testContext = await setupMultiRecordingsTestContext(3, 3, 3, '0s'); + const recordingIds = testContext.rooms.map((room) => room.recordingId); + const nonExistentIds = ['nonExistent--EG_000--1234', 'nonExistent--EG_111--5678']; + const mixedIds = [...recordingIds, ...nonExistentIds]; + + const deleteResponse = await bulkDeleteRecordings(mixedIds); + + expect(deleteResponse.status).toBe(200); + expect(deleteResponse.body).toEqual({ + deleted: expect.arrayContaining(recordingIds), + notDeleted: expect.arrayContaining( + nonExistentIds.map((id) => ({ + recordingId: id, + error: expect.stringContaining(`Recording '${id}' not found`) + })) + ) + }); + }); + + it('should return 200 with mixed results when some recordings are in active state', async () => { + const testContext = await setupMultiRecordingsTestContext(3, 3, 2, '0s'); + const activeRecordingRoom = testContext.getLastRoom(); + const recordingIds = testContext.rooms + .map((room) => room.recordingId) + .filter((id) => id !== activeRecordingRoom!.recordingId); + + const activeRecordingId = activeRecordingRoom?.recordingId; + let deleteResponse = await bulkDeleteRecordings([...recordingIds, activeRecordingId]); + + expect(deleteResponse.status).toBe(200); + expect(deleteResponse.body).toEqual({ + deleted: expect.arrayContaining(recordingIds), + notDeleted: [ + { + recordingId: activeRecordingId, + error: expect.stringContaining(`Recording '${activeRecordingId}' is not stopped yet`) + } + ] + }); + + await stopRecording(activeRecordingId!, activeRecordingRoom!.moderatorCookie); + + deleteResponse = await bulkDeleteRecordings([activeRecordingId]); + + expect(deleteResponse.status).toBe(204); + expect(deleteResponse.body).toStrictEqual({}); + }); + + it('should not delete any recordings and return 200', async () => { + const testContext = await setupMultiRecordingsTestContext(2, 2, 0, '0s'); + const recordingIds = testContext.rooms.map((room) => room.recordingId); + const deleteResponse = await bulkDeleteRecordings(recordingIds); + expect(deleteResponse.status).toBe(200); + expect(deleteResponse.body).toEqual({ + deleted: [], + notDeleted: expect.arrayContaining( + recordingIds.map((id) => ({ + recordingId: id, + error: expect.stringContaining(`Recording '${id}' is not stopped yet`) + })) + ) + }); + + await Promise.all( + recordingIds.map((id, index) => { + return stopRecording(id!, testContext.getRoomByIndex(index)!.moderatorCookie); + }) + ); + }); + + it('should delete all recordings and return 204 when all operations succeed', async () => { + const response = await setupMultiRecordingsTestContext(5, 5, 5, '0s'); + const recordingIds = response.rooms.map((room) => room.recordingId); + const deleteResponse = await bulkDeleteRecordings(recordingIds); + + expect(deleteResponse.status).toBe(204); + }); + + it('should handle single recording deletion correctly', async () => { + const testContext = await setupMultiRecordingsTestContext(1, 1, 1, '0s'); + const recordingId = testContext.rooms[0].recordingId; + const deleteResponse = await bulkDeleteRecordings([recordingId]); + + expect(deleteResponse.status).toBe(204); + expect(deleteResponse.body).toStrictEqual({}); + }); + + it('should handle duplicate recording IDs by treating them as a single delete', async () => { + const testContext = await setupMultiRecordingsTestContext(1, 1, 1, '0s'); + const recordingId = testContext.getRoomByIndex(0)!.recordingId; + const deleteResponse = await bulkDeleteRecordings([recordingId, recordingId]); + + expect(deleteResponse.status).toBe(204); + expect(deleteResponse.body).toStrictEqual({}); + }); + }); + + describe('Bulk Delete Recording Validation', () => { + it('should handle empty recordingIds array gracefully', async () => { + const deleteResponse = await bulkDeleteRecordings([]); + + expect(deleteResponse.status).toBe(422); + expect(deleteResponse.body).toEqual({ + details: [ + { + field: 'recordingIds', + message: 'recordingIds must contain at least one item' + } + ], + error: 'Unprocessable Entity', + message: 'Invalid request' + }); + }); + + it('should reject a CSV string with invalid format', async () => { + const invalidRecordingIds = 'invalid--recording.id,invalid--EG_111--5678'; + const deleteResponse = await bulkDeleteRecordings([invalidRecordingIds]); + + expect(deleteResponse.status).toBe(422); + expect(deleteResponse.body).toMatchObject({ + details: [ + { + message: 'recordingId does not follow the expected format' + } + ], + error: 'Unprocessable Entity', + message: 'Invalid request' + }); + }); + + it('should reject an array containing empty strings after sanitization', async () => { + const invalidRecordingIds = ['', ' ']; + const deleteResponse = await bulkDeleteRecordings(invalidRecordingIds); + + expect(deleteResponse.status).toBe(422); + expect(deleteResponse.body).toMatchObject({ + details: [ + { + message: 'recordingIds must contain at least one item' + } + ], + error: 'Unprocessable Entity', + message: 'Invalid request' + }); + }); + + it('should reject an array with mixed valid and totally invalid IDs', async () => { + const invalidRecordingIds = ['valid--EG_111--5678', 'invalid--recording.id']; + const deleteResponse = await bulkDeleteRecordings(invalidRecordingIds); + + expect(deleteResponse.status).toBe(422); + expect(deleteResponse.body).toMatchObject({ + details: [ + { + message: 'recordingId does not follow the expected format' + } + ], + error: 'Unprocessable Entity', + message: 'Invalid request' + }); + }); + }); +}); diff --git a/backend/tests/utils/helpers.ts b/backend/tests/utils/helpers.ts index 523635a..b634dad 100644 --- a/backend/tests/utils/helpers.ts +++ b/backend/tests/utils/helpers.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import request from 'supertest'; +import request, { Response } from 'supertest'; import { Express } from 'express'; import { Server } from 'http'; import { createApp, registerDependencies } from '../../src/server.js'; @@ -20,8 +20,8 @@ import INTERNAL_CONFIG from '../../src/config/internal-config.js'; import { ChildProcess, execSync, spawn } from 'child_process'; import { container } from '../../src/config/dependency-injector.config.js'; import { RoomService } from '../../src/services/room.service.js'; -import { MeetRoomHelper } from '../../src/helpers/room.helper.js'; import { RecordingService } from '../../src/services/recording.service.js'; +import ms, { StringValue } from 'ms'; const CREDENTIALS = { user: { @@ -39,8 +39,8 @@ let server: Server; const fakeParticipantsProcesses = new Map(); -export const sleep = (ms: number) => { - return new Promise((resolve) => setTimeout(resolve, ms)); +export const sleep = (time: StringValue) => { + return new Promise((resolve) => setTimeout(resolve, ms(time))); }; /** @@ -187,40 +187,6 @@ export const updateRoomPreferences = async (roomId: string, preferences: any) => .send(preferences); }; -/** - * Asserts that a rooms response matches the expected values for testing purposes. - * Validates the room array length and pagination properties. - * - * @param body - The API response body to validate - * @param expectedRoomLength - The expected number of rooms in the response - * @param expectedMaxItems - The expected maximum number of items in pagination - * @param expectedTruncated - The expected value for pagination.isTruncated flag - * @param expectedNextPageToken - The expected presence of pagination.nextPageToken - * (if true, expects nextPageToken to be defined; - * if false, expects nextPageToken to be undefined) - */ -export const assertSuccessRoomsResponse = ( - response: any, - expectedRoomLength: number, - expectedMaxItems: number, - expectedTruncated: boolean, - expectedNextPageToken: boolean -) => { - const { body } = response; - expect(response.status).toBe(200); - expect(body).toBeDefined(); - expect(body.rooms).toBeDefined(); - expect(Array.isArray(body.rooms)).toBe(true); - expect(body.rooms.length).toBe(expectedRoomLength); - expect(body.pagination).toBeDefined(); - expect(body.pagination.isTruncated).toBe(expectedTruncated); - - expectedNextPageToken - ? expect(body.pagination.nextPageToken).toBeDefined() - : expect(body.pagination.nextPageToken).toBeUndefined(); - expect(body.pagination.maxItems).toBe(expectedMaxItems); -}; - /** * Retrieves information about a specific room from the API. * @@ -262,16 +228,6 @@ export const bulkDeleteRooms = async (roomIds: any[], force?: any) => { .query({ roomIds: roomIds.join(','), force }); }; -export const assertEmptyRooms = async () => { - if (!app) { - throw new Error('App instance is not defined'); - } - - const response = await getRooms(); - - assertSuccessRoomsResponse(response, 0, 10, false, false); -}; - /** * Runs the room garbage collector to delete expired rooms. * @@ -286,8 +242,6 @@ export const runRoomGarbageCollector = async () => { const roomService = container.get(RoomService); await (roomService as any)['deleteExpiredRooms'](); - - await sleep(1000); }; export const runReleaseActiveRecordingLock = async (roomId: string) => { @@ -327,8 +281,6 @@ export const deleteAllRooms = async () => { .delete(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/rooms`) .query({ roomIds: roomIds.join(','), force: true }) .set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY); - - await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait for 1 second } while (nextPageToken); }; @@ -407,7 +359,7 @@ export const joinFakeParticipant = async (roomId: string, participantName: strin // Store the process to be able to terminate it later fakeParticipantsProcesses.set(participantName, process); - await sleep(1000); + await sleep('1s'); }; export const disconnectFakeParticipants = async () => { @@ -417,7 +369,7 @@ export const disconnectFakeParticipants = async () => { }); fakeParticipantsProcesses.clear(); - await sleep(1000); + await sleep('1s'); }; export const startRecording = async (roomId: string, moderatorCookie = '') => { @@ -442,7 +394,7 @@ export const stopRecording = async (recordingId: string, moderatorCookie = '') = .post(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/recordings/${recordingId}/stop`) .set('Cookie', moderatorCookie) .send(); - await sleep(2500); + await sleep('2.5s'); return response; }; @@ -469,7 +421,7 @@ export const stopAllRecordings = async (moderatorCookie: string) => { .send() ); await Promise.all(tasks); - await sleep(1000); // Wait for 1 second + await sleep('1s'); }; export const getAllRecordings = async (query: Record = {}) => { @@ -483,6 +435,18 @@ export const getAllRecordings = async (query: Record = {}) => { .query(query); }; +export const bulkDeleteRecordings = async (recordingIds: any[]): Promise => { + if (!app) { + throw new Error('App instance is not defined'); + } + + const response = await request(app) + .delete(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings`) + .query({ recordingIds: recordingIds.join(',') }) + .set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY); + return response; +}; + export const deleteAllRecordings = async () => { if (!app) { throw new Error('App instance is not defined'); @@ -507,14 +471,7 @@ export const deleteAllRecordings = async () => { break; } - console.log(`Deleting ${recordingIds.length} recordings...`); - console.log('Recording IDs:', recordingIds); - await request(app) - .delete(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings`) - .query({ recordingIds: recordingIds.join(',')}) - .set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY); - - await sleep(1000); // Wait for 1 second + await bulkDeleteRecordings(recordingIds); } while (nextPageToken); }; diff --git a/backend/tests/utils/test-scenarios.ts b/backend/tests/utils/test-scenarios.ts index c624661..992ec68 100644 --- a/backend/tests/utils/test-scenarios.ts +++ b/backend/tests/utils/test-scenarios.ts @@ -1,10 +1,20 @@ import { MeetRoomHelper } from '../../src/helpers'; -import { createRoom, loginUserAsRole, generateParticipantToken, joinFakeParticipant } from './helpers'; +import { + createRoom, + loginUserAsRole, + generateParticipantToken, + joinFakeParticipant, + startRecording, + stopRecording, + sleep +} from './helpers'; -import { UserRole } from '../../src/typings/ce'; +import { MeetRoom, UserRole } from '../../src/typings/ce'; +import ms, { StringValue } from 'ms'; +import { expectValidStartRecordingResponse } from './assertion-helpers'; export interface RoomData { - room: any; + room: MeetRoom; moderatorCookie: string; moderatorSecret: string; recordingId?: string; @@ -13,6 +23,7 @@ export interface RoomData { export interface TestContext { rooms: RoomData[]; getRoomByIndex(index: number): RoomData | undefined; + getLastRoom(): RoomData | undefined; } /** @@ -28,18 +39,11 @@ export async function setupMultiRoomTestContext(numRooms: number, withParticipan roomIdPrefix: `test-recording-room-${i + 1}` }); const { moderatorSecret } = MeetRoomHelper.extractSecretsFromRoom(room); - const moderatorCookie = await generateParticipantToken( - adminCookie, - room.roomId, - `Moderator-${i + 1}`, - moderatorSecret - ); - - if (withParticipants) { - const participantId = `TEST_P-${i + 1}`; - - await joinFakeParticipant(room.roomId, participantId); - } + const [moderatorCookie, _] = await Promise.all([ + generateParticipantToken(adminCookie, room.roomId, `Moderator-${i + 1}`, moderatorSecret), + // Join participant (if needed) concurrently with token generation + withParticipants ? joinFakeParticipant(room.roomId, `TEST_P-${i + 1}`) : Promise.resolve() + ]); rooms.push({ room, @@ -56,6 +60,73 @@ export async function setupMultiRoomTestContext(numRooms: number, withParticipan } return rooms[index]; + }, + + getLastRoom: () => { + if (rooms.length === 0) { + return undefined; + } + + return rooms[rooms.length - 1]; } }; } + +/** + * Quickly creates multiple recordings for bulk delete testing. + * Allows customizing how many recordings to start and how many to stop after a delay. + * + * @param numRooms Number of rooms to use. + * @param numStarts Number of recordings to start. + * @param numStops Number of recordings to stop after the delay. + * @param stopDelayMs Delay in milliseconds before stopping recordings. + * @returns Test context with created recordings (some stopped, some still running). + */ +export async function setupMultiRecordingsTestContext( + numRooms: number, + numStarts: number, + numStops: number, + stopDelay: StringValue +): Promise { + // Setup rooms with participants + const testContext = await setupMultiRoomTestContext(numRooms, true); + + // Start the specified number of recordings in parallel + const startPromises = Array.from({ length: numStarts }).map(async (_, i) => { + const roomIndex = i % numRooms; + const roomData = testContext.getRoomByIndex(roomIndex); + + if (!roomData) { + throw new Error(`Room at index ${roomIndex} not found`); + } + + // Send start recording request + const response = await startRecording(roomData.room.roomId, roomData.moderatorCookie); + expectValidStartRecordingResponse(response, roomData.room.roomId); + + // Store the recordingId in context + roomData.recordingId = response.body.recordingId; + return roomData; + }); + const startedRooms = await Promise.all(startPromises); + + // Wait for the configured delay before stopping recordings + if (ms(stopDelay) > 0) { + await sleep(stopDelay); + } + + // Stop recordings for the first numStops rooms + const stopPromises = startedRooms.slice(0, numStops).map(async (roomData) => { + if (roomData.recordingId) { + await stopRecording(roomData.recordingId, roomData.moderatorCookie); + console.log(`Recording stopped for room ${roomData.room.roomId}`); + return roomData.recordingId; + } + + return null; + }); + const stoppedIds = (await Promise.all(stopPromises)).filter((id): id is string => Boolean(id)); + console.log(`Stopped ${stoppedIds.length} recordings after ${stopDelay}ms:`, stoppedIds); + + return testContext; +}