backend: Add integration tests for recording API and enhance assertion helpers
This commit is contained in:
parent
9d42242ba0
commit
dae12bcbe4
212
backend/tests/integration/api/recordings/start-recording.test.ts
Normal file
212
backend/tests/integration/api/recordings/start-recording.test.ts
Normal 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'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
39
backend/tests/utils/assertion-helpers.ts
Normal file
39
backend/tests/utils/assertion-helpers.ts
Normal 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));
|
||||
};
|
||||
@ -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
|
||||
|
||||
61
backend/tests/utils/test-scenarios.ts
Normal file
61
backend/tests/utils/test-scenarios.ts
Normal 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];
|
||||
}
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user