backend: Add integration tests for recording API and enhance assertion helpers

This commit is contained in:
Carlos Santos 2025-04-21 16:22:29 +02:00
parent 9d42242ba0
commit dae12bcbe4
4 changed files with 425 additions and 2 deletions

View File

@ -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 = ' .<!?' + room.roomId + ' ';
const response = await startRecording(malformedRoomId, moderatorCookie);
console.log('Response:', response.body);
expect(response.status).toBe(409);
expect(response.body.message).toContain(`The room '${room.roomId}' has no participants`);
});
it('should reject request with roomId that becomes empty after sanitization', async () => {
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'
});
});
});
});

View File

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

View File

@ -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<string, any> = {}) => {
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

View File

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