diff --git a/backend/tests/integration/api/recordings/start-recording.test.ts b/backend/tests/integration/api/recordings/start-recording.test.ts new file mode 100644 index 0000000..3145aaf --- /dev/null +++ b/backend/tests/integration/api/recordings/start-recording.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from '@jest/globals'; +import { + deleteAllRecordings, + deleteAllRooms, + disconnectFakeParticipants, + joinFakeParticipant, + startRecording, + startTestServer, + stopAllRecordings, + stopRecording, + stopTestServer +} from '../../../utils/helpers.js'; +import { setInternalConfig } from '../../../../src/config/internal-config.js'; + +import { errorRoomNotFound } from '../../../../src/models/error.model.js'; +import { + expectValidRecordingLocationHeader, + expectValidStartRecordingResponse, + expectValidStopRecordingResponse +} from '../../../utils/assertion-helpers.js'; +import { setupMultiRoomTestContext, TestContext } from '../../../utils/test-scenarios.js'; +import { MeetRoom } from '../../../../src/typings/ce/room.js'; + +describe('Recording API Tests', () => { + let context: TestContext | null = null; + let room: MeetRoom, moderatorCookie: string; + + beforeAll(async () => { + await startTestServer(); + }); + + afterAll(async () => { + await stopAllRecordings(moderatorCookie); + await disconnectFakeParticipants(); + await deleteAllRooms(); + await deleteAllRecordings(); + await stopTestServer(); + }); + + describe('Start Recording Tests', () => { + beforeAll(async () => { + // Create a room and join a participant + context = await setupMultiRoomTestContext(1, true); + ({ room, moderatorCookie } = context.getRoomByIndex(0)!); + }); + + afterAll(async () => { + await disconnectFakeParticipants(); + await deleteAllRooms(); + await deleteAllRecordings(); + context = null; + }); + + it('should return 201 with proper response and location header when recording starts successfully', async () => { + const response = await startRecording(room.roomId, moderatorCookie); + const recordingId = response.body.recordingId; + expectValidStartRecordingResponse(response, room.roomId); + + expectValidRecordingLocationHeader(response); + const stopResponse = await stopRecording(recordingId, moderatorCookie); + expectValidStopRecordingResponse(stopResponse, recordingId, room.roomId); + }); + + it('should successfully start recording, stop it, and start again (sequential operations)', async () => { + const firstStartResponse = await startRecording(room.roomId, moderatorCookie); + const firstRecordingId = firstStartResponse.body.recordingId; + + expectValidStartRecordingResponse(firstStartResponse, room.roomId); + + const firstStopResponse = await stopRecording(firstRecordingId, moderatorCookie); + expectValidStopRecordingResponse(firstStopResponse, firstRecordingId, room.roomId); + + const secondStartResponse = await startRecording(room.roomId, moderatorCookie); + expectValidStartRecordingResponse(secondStartResponse, room.roomId); + const secondRecordingId = secondStartResponse.body.recordingId; + + const secondStopResponse = await stopRecording(secondRecordingId, moderatorCookie); + expectValidStopRecordingResponse(secondStopResponse, secondRecordingId, room.roomId); + }); + + it('should handle simultaneous recordings in different rooms correctly', async () => { + const context = await setupMultiRoomTestContext(2, true); + + const roomDataA = context.getRoomByIndex(0)!; + const roomDataB = context.getRoomByIndex(1)!; + + const firstResponse = await startRecording(roomDataA.room.roomId, roomDataA.moderatorCookie); + const secondResponse = await startRecording(roomDataB.room.roomId, roomDataB.moderatorCookie); + + expectValidStartRecordingResponse(firstResponse, roomDataA.room.roomId); + expectValidStartRecordingResponse(secondResponse, roomDataB.room.roomId); + + const firstRecordingId = firstResponse.body.recordingId; + const secondRecordingId = secondResponse.body.recordingId; + + const [firstStopResponse, secondStopResponse] = await Promise.all([ + stopRecording(firstRecordingId, roomDataA.moderatorCookie), + stopRecording(secondRecordingId, roomDataB.moderatorCookie) + ]); + expectValidStopRecordingResponse(firstStopResponse, firstRecordingId, roomDataA.room.roomId); + expectValidStopRecordingResponse(secondStopResponse, secondRecordingId, roomDataB.room.roomId); + }); + }); + + describe('Start Recording Validation failures', () => { + beforeAll(async () => { + // Create a room without participants + context = await setupMultiRoomTestContext(1, false); + ({ room, moderatorCookie } = context.getRoomByIndex(0)!); + }); + + afterEach(async () => { + await disconnectFakeParticipants(); + await stopAllRecordings(moderatorCookie); + }); + + it('should accept valid roomId but reject with 409', async () => { + const response = await startRecording(room.roomId, moderatorCookie); + // Room exists but it has no participants + expect(response.status).toBe(409); + expect(response.body.message).toContain(`The room '${room.roomId}' has no participants`); + }); + + it('should sanitize roomId and reject the request with 409 due to no participants', async () => { + const malformedRoomId = ' . { + const response = await startRecording('!@#$%^&*()', moderatorCookie); + + expect(response.status).toBe(422); + expect(response.body.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'roomId', + message: expect.stringContaining('cannot be empty after sanitization') + }) + ]) + ); + }); + + it('should reject request with non-string roomId', async () => { + const response = await startRecording(123 as unknown as string, moderatorCookie); + + expect(response.status).toBe(422); + expect(response.body.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'roomId', + message: expect.stringContaining('Expected string') + }) + ]) + ); + }); + + it('should reject request with very long roomId', async () => { + const longRoomId = 'a'.repeat(101); + const response = await startRecording(longRoomId, moderatorCookie); + + expect(response.status).toBe(422); + expect(response.body.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'roomId', + message: expect.stringContaining('cannot exceed 100 characters') + }) + ]) + ); + }); + + it('should handle room that does not exist', async () => { + const response = await startRecording('non-existing-room-id', moderatorCookie); + const error = errorRoomNotFound('non-existing-room-id'); + expect(response.status).toBe(404); + expect(response.body).toEqual({ + name: error.name, + message: error.message + }); + }); + + it('should return 409 when recording is already in progress', async () => { + await joinFakeParticipant(room.roomId, 'fakeParticipantId'); + const firstResponse = await startRecording(room.roomId, moderatorCookie); + const recordingId = firstResponse.body.recordingId; + expectValidStartRecordingResponse(firstResponse, room.roomId); + + const secondResponse = await startRecording(room!.roomId, moderatorCookie); + expect(secondResponse.status).toBe(409); + expect(secondResponse.body.message).toContain('already'); + const stopResponse = await stopRecording(recordingId, moderatorCookie); + expectValidStopRecordingResponse(stopResponse, recordingId, room.roomId); + }); + + it('should return 503 when recording start times out', async () => { + setInternalConfig({ + RECORDING_STARTED_TIMEOUT: '1s' + }); + await joinFakeParticipant(room.roomId, 'fakeParticipantId'); + const response = await startRecording(room.roomId, moderatorCookie); + expect(response.status).toBe(503); + expect(response.body.message).toContain('timed out while starting'); + setInternalConfig({ + RECORDING_STARTED_TIMEOUT: '30s' + }); + }); + }); +}); diff --git a/backend/tests/utils/assertion-helpers.ts b/backend/tests/utils/assertion-helpers.ts new file mode 100644 index 0000000..60d5a6a --- /dev/null +++ b/backend/tests/utils/assertion-helpers.ts @@ -0,0 +1,39 @@ +import { expect } from '@jest/globals'; +import INTERNAL_CONFIG from '../../src/config/internal-config'; +const RECORDINGS_PATH = `${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/recordings`; + +export const expectValidRecordingLocationHeader = (response: any) => { + // const locationRegex = new RegExp( + // `^http://127\\.0\\.0\\.1:\\d+/+${RECORDINGS_PATH.replace(/\//g, '\\/')}/${recordingId}$` + // ); + // expect(response.headers.location).toMatch(locationRegex); + expect(response.headers.location).toBeDefined(); + expect(response.headers.location).toContain('127.0.0.1'); + expect(response.headers.location).toContain(RECORDINGS_PATH); + expect(response.headers.location).toContain(response.body.recordingId); +}; + +export const expectValidStartRecordingResponse = (response: any, roomId: string) => { + expect(response.status).toBe(201); + expect(response.body).toHaveProperty('recordingId'); + const recordingId = response.body.recordingId; + expect(recordingId).toContain(roomId); + expect(response.body).toHaveProperty('roomId', roomId); + expect(response.body).toHaveProperty('startDate'); + expect(response.body).toHaveProperty('status', 'ACTIVE'); + expect(response.body).toHaveProperty('filename'); + expect(response.body).not.toHaveProperty('duration'); + expect(response.body).not.toHaveProperty('endDate'); + expect(response.body).not.toHaveProperty('size'); +}; + +export const expectValidStopRecordingResponse = (response: any, recordingId: string, roomId: string) => { + expect(response.status).toBe(202); + expect(response.body).toBeDefined(); + expect(response.body).toHaveProperty('recordingId', recordingId); + expect(response.body).toHaveProperty('status', 'ENDING'); + expect(response.body).toHaveProperty('roomId', roomId); + expect(response.body).toHaveProperty('filename'); + expect(response.body).toHaveProperty('startDate'); + expect(response.body).toHaveProperty('duration', expect.any(Number)); +}; diff --git a/backend/tests/utils/helpers.ts b/backend/tests/utils/helpers.ts index 2c4a310..523635a 100644 --- a/backend/tests/utils/helpers.ts +++ b/backend/tests/utils/helpers.ts @@ -20,6 +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'; const CREDENTIALS = { user: { @@ -288,6 +290,15 @@ export const runRoomGarbageCollector = async () => { await sleep(1000); }; +export const runReleaseActiveRecordingLock = async (roomId: string) => { + if (!app) { + throw new Error('App instance is not defined'); + } + + const recordingService = container.get(RecordingService); + await recordingService.releaseRoomRecordingActiveLock(roomId); +}; + /** * Deletes all rooms */ @@ -362,7 +373,7 @@ export const generateParticipantToken = async ( * @param roomId The ID of the room to join * @param participantName The name for the fake participant */ -export const joinFakeParticipant = (roomId: string, participantName: string) => { +export const joinFakeParticipant = async (roomId: string, participantName: string) => { // Step 1: Manually create the LiveKit room // In normal operation, the room is created when a real participant requests a token, // but for testing we need to create it ourselves since we're bypassing the token flow. @@ -396,15 +407,115 @@ export const joinFakeParticipant = (roomId: string, participantName: string) => // Store the process to be able to terminate it later fakeParticipantsProcesses.set(participantName, process); + await sleep(1000); }; -export const disconnectFakeParticipants = () => { +export const disconnectFakeParticipants = async () => { fakeParticipantsProcesses.forEach((process, participantName) => { process.kill(); console.log(`Stopped process for participant ${participantName}`); }); fakeParticipantsProcesses.clear(); + await sleep(1000); +}; + +export const startRecording = async (roomId: string, moderatorCookie = '') => { + if (!app) { + throw new Error('App instance is not defined'); + } + + return await request(app) + .post(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/recordings`) + .set('Cookie', moderatorCookie) + .send({ + roomId + }); +}; + +export const stopRecording = async (recordingId: string, moderatorCookie = '') => { + if (!app) { + throw new Error('App instance is not defined'); + } + + const response = await request(app) + .post(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/recordings/${recordingId}/stop`) + .set('Cookie', moderatorCookie) + .send(); + await sleep(2500); + + return response; +}; + +export const stopAllRecordings = async (moderatorCookie: string) => { + if (!app) { + throw new Error('App instance is not defined'); + } + + const response = await getAllRecordings({ fields: 'recordingId' }); + const recordingIds: string[] = response.body.recordings.map( + (recording: { recordingId: string }) => recording.recordingId + ); + + if (recordingIds.length === 0) { + return; + } + + console.log(`Stopping ${recordingIds.length} recordings...`); + const tasks = recordingIds.map((recordingId: string) => + request(app) + .post(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/recordings/${recordingId}/stop`) + .set('Cookie', moderatorCookie) + .send() + ); + await Promise.all(tasks); + await sleep(1000); // Wait for 1 second +}; + +export const getAllRecordings = async (query: Record = {}) => { + if (!app) { + throw new Error('App instance is not defined'); + } + + return await request(app) + .get(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings`) + .set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY) + .query(query); +}; + +export const deleteAllRecordings = async () => { + if (!app) { + throw new Error('App instance is not defined'); + } + + let nextPageToken: string | undefined; + + do { + const response: any = await getAllRecordings({ + fields: 'recordingId', + maxItems: 100, + nextPageToken + }); + expect(response.status).toBe(200); + + nextPageToken = response.body.pagination?.nextPageToken ?? undefined; + const recordingIds = response.body.recordings.map( + (recording: { recordingId: string }) => recording.recordingId + ); + + if (recordingIds.length === 0) { + 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 + } while (nextPageToken); }; // PRIVATE METHODS diff --git a/backend/tests/utils/test-scenarios.ts b/backend/tests/utils/test-scenarios.ts new file mode 100644 index 0000000..c624661 --- /dev/null +++ b/backend/tests/utils/test-scenarios.ts @@ -0,0 +1,61 @@ +import { MeetRoomHelper } from '../../src/helpers'; +import { createRoom, loginUserAsRole, generateParticipantToken, joinFakeParticipant } from './helpers'; + +import { UserRole } from '../../src/typings/ce'; + +export interface RoomData { + room: any; + moderatorCookie: string; + moderatorSecret: string; + recordingId?: string; +} + +export interface TestContext { + rooms: RoomData[]; + getRoomByIndex(index: number): RoomData | undefined; +} + +/** + * Configura un escenario de prueba con dos salas para pruebas de grabación concurrente + */ +export async function setupMultiRoomTestContext(numRooms: number, withParticipants: boolean): Promise { + const adminCookie = await loginUserAsRole(UserRole.ADMIN); + const rooms: RoomData[] = []; + + // Create additional rooms + for (let i = 0; i < numRooms; i++) { + const room = await createRoom({ + 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); + } + + rooms.push({ + room, + moderatorCookie, + moderatorSecret + }); + } + + return { + rooms, + getRoomByIndex: (index: number) => { + if (index < 0 || index >= rooms.length) { + return undefined; + } + + return rooms[index]; + } + }; +}