backend: Add bulk delete recordings tests and enhance test utilities

This commit is contained in:
Carlos Santos 2025-04-23 11:01:46 +02:00
parent 32c0c9d242
commit f8a176b4fa
3 changed files with 294 additions and 79 deletions

View File

@ -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'
});
});
});
});

View File

@ -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<string, ChildProcess>();
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<string, any> = {}) => {
@ -483,6 +435,18 @@ export const getAllRecordings = async (query: Record<string, any> = {}) => {
.query(query);
};
export const bulkDeleteRecordings = async (recordingIds: any[]): Promise<Response> => {
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);
};

View File

@ -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<TestContext> {
// 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;
}