backend: Enhance deleteRoom functionality and add delete room integration tests

This commit is contained in:
Carlos Santos 2025-04-14 13:40:53 +02:00
parent 7bcb3be1dd
commit 33a970d1ef
4 changed files with 297 additions and 1 deletions

View File

@ -6,6 +6,7 @@ const jestConfig = {
...createDefaultEsmPreset({
tsconfig: 'tsconfig.json'
}),
testTimeout: 30000,
resolver: 'ts-jest-resolver',
testMatch: ['**/?(*.)+(spec|test).[tj]s?(x)'],
moduleFileExtensions: ['js', 'ts', 'json', 'node'],

View File

@ -72,9 +72,11 @@ export const deleteRoom = async (req: Request, res: Response) => {
const { deleted } = await roomService.bulkDeleteRooms([roomId], forceDelete);
if (deleted.length > 0) {
// Room was deleted
return res.status(204).send();
}
// Room was marked as deleted
return res.status(202).json({ message: `Room ${roomId} marked as deleted` });
} catch (error) {
logger.error(`Error deleting room: ${roomId}`);

View File

@ -0,0 +1,208 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from '@jest/globals';
import {
createRoom,
deleteAllRooms,
startTestServer,
stopTestServer,
getRoom,
deleteRoom,
joinFakeParticipant,
sleep,
disconnectFakeParticipants
} from '../../../utils/helpers.js';
import ms from 'ms';
describe('OpenVidu Meet Room API Tests', () => {
beforeAll(async () => {
await startTestServer();
});
afterAll(async () => {
await stopTestServer();
});
afterEach(async () => {
// Remove all rooms created
disconnectFakeParticipants();
await deleteAllRooms();
});
describe('Delete Room Tests', () => {
it('should return 204 when room does not exist (idempotent deletion)', async () => {
const response = await deleteRoom('non-existent-room-id');
expect(response.status).toBe(204);
});
it('should default to force=false when force parameter is invalid', async () => {
// Create a room first
const { roomId } = await createRoom({
roomIdPrefix: 'test-room'
});
const response = await deleteRoom(roomId, { force: 'not-a-boolean' });
expect(response.status).toBe(204);
// Verify it's deleted
const getResponse = await getRoom(roomId);
expect(getResponse.status).toBe(404);
});
it('should mark room for deletion when participants exist and force parameter is invalid', async () => {
// Create a room first
const { roomId } = await createRoom({
roomIdPrefix: 'test-room'
});
joinFakeParticipant(roomId, 'test-participant');
await sleep(500);
// The force parameter is not a boolean so it should be defined as false
// and the room should be marked for deletion
const response = await deleteRoom(roomId, { force: 'not-a-boolean' });
// Check operation accepted
expect(response.status).toBe(202);
// The room should be marked for deletion
const roomResponse = await getRoom(roomId);
expect(roomResponse.body).toBeDefined();
expect(roomResponse.body.roomId).toBe(roomId);
expect(roomResponse.body.markedForDeletion).toBeDefined();
expect(roomResponse.body.markedForDeletion).toBe(true);
});
it('should delete an empty room completely (204)', async () => {
const { roomId } = await createRoom({ roomIdPrefix: 'test-room' });
const response = await deleteRoom(roomId);
expect(response.status).toBe(204);
// Try to retrieve the room again
const responseAfterDelete = await getRoom(roomId);
expect(responseAfterDelete.status).toBe(404);
});
it('should sanitize roomId with spaces and special characters before deletion', async () => {
// Create a room first
const createdRoom = await createRoom({
roomIdPrefix: 'test-mixed'
});
// Add some spaces and special chars to the valid roomId
const modifiedId = ` ${createdRoom.roomId}!@# `;
const response = await deleteRoom(modifiedId);
// The validation should sanitize the ID and successfully delete
expect(response.status).toBe(204);
// Verify it's deleted
const getResponse = await getRoom(createdRoom.roomId);
expect(getResponse.status).toBe(404);
});
it('should handle explicit force=true for room with no participants', async () => {
const createdRoom = await createRoom({
roomIdPrefix: 'test-room'
});
const response = await deleteRoom(createdRoom.roomId, { force: true });
expect(response.status).toBe(204);
// Try to retrieve the room again
const responseAfterDelete = await getRoom(createdRoom.roomId);
expect(responseAfterDelete.status).toBe(404);
});
it('should mark room for deletion (202) when participants exist and force=false', async () => {
const { roomId } = await createRoom({
roomIdPrefix: 'test-room',
autoDeletionDate: Date.now() + ms('5h')
});
joinFakeParticipant(roomId, 'test-participant');
await sleep(500);
const response = await deleteRoom(roomId, { force: false });
expect(response.status).toBe(202);
const roomResponse = await getRoom(roomId);
expect(roomResponse.body).toBeDefined();
expect(roomResponse.body.roomId).toBe(roomId);
expect(roomResponse.body.markedForDeletion).toBeDefined();
expect(roomResponse.body.markedForDeletion).toBe(true);
disconnectFakeParticipants();
await sleep(2000);
const responseAfterDelete = await getRoom(roomId);
expect(responseAfterDelete.status).toBe(404);
});
it('should force delete (204) room with active participants when force=true', async () => {
const { roomId } = await createRoom({
roomIdPrefix: 'test-room'
});
joinFakeParticipant(roomId, 'test-participant');
await sleep(500);
const response = await deleteRoom(roomId, { force: true });
expect(response.status).toBe(204);
// Try to retrieve the room again
const responseAfterDelete = await getRoom(roomId);
expect(responseAfterDelete.status).toBe(404);
});
it('should successfully delete a room already marked for deletion', async () => {
const { roomId } = await createRoom({ roomIdPrefix: 'test-marked' });
// First mark it for deletion
joinFakeParticipant(roomId, 'test-participant');
await sleep(500);
await deleteRoom(roomId, { force: false });
// Then try to delete it again
const response = await deleteRoom(roomId, { force: true });
expect(response.status).toBe(204);
});
it('should handle repeated deletion of the same room gracefully', async () => {
const { roomId } = await createRoom({ roomIdPrefix: 'test-idempotent' });
// Delete first time
const response1 = await deleteRoom(roomId);
expect(response1.status).toBe(204);
// Delete second time - should still return 204 (no error)
const response2 = await deleteRoom(roomId);
expect(response2.status).toBe(204);
});
});
describe('Delete Room Validation failures', () => {
it('should fail when roomId becomes empty after sanitization', async () => {
const response = await deleteRoom('!!-*!@#$%^&*()_+{}|:"<>?');
expect(response.status).toBe(422);
// Expect an error message indicating the resulting roomId is empty.
expect(response.body.error).toContain('Unprocessable Entity');
expect(JSON.stringify(response.body.details)).toContain('roomId cannot be empty after sanitization');
});
it('should fail when force parameter is a number instead of boolean', async () => {
const response = await deleteRoom('testRoom', { force: { value: 123 } });
expect(response.status).toBe(422);
expect(response.body.error).toContain('Unprocessable Entity');
expect(JSON.stringify(response.body.details)).toContain('Expected boolean, received object');
});
});
});

View File

@ -9,11 +9,15 @@ import {
MEET_USER,
MEET_SECRET,
MEET_ADMIN_USER,
MEET_ADMIN_SECRET
MEET_ADMIN_SECRET,
LIVEKIT_API_SECRET,
LIVEKIT_API_KEY,
MEET_NAME_ID
} from '../../src/environment.js';
import { AuthMode, AuthType, MeetRoom, UserRole, MeetRoomOptions } from '../../src/typings/ce/index.js';
import { expect } from '@jest/globals';
import INTERNAL_CONFIG from '../../src/config/internal-config.js';
import { ChildProcess, execSync, spawn } from 'child_process';
const CREDENTIALS = {
user: {
@ -29,6 +33,8 @@ const CREDENTIALS = {
let app: Express;
let server: Server;
const fakeParticipantsProcesses = new Map<string, ChildProcess>();
/**
* Starts the test server
*/
@ -214,6 +220,17 @@ export const getRoom = async (roomId: string, fields?: string) => {
.query({ fields });
};
export const deleteRoom = async (roomId: string, query: Record<string, any> = {}) => {
if (!app) {
throw new Error('App instance is not defined');
}
return await request(app)
.delete(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/rooms/${roomId}`)
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY)
.query(query);
};
export const assertEmptyRooms = async () => {
if (!app) {
throw new Error('App instance is not defined');
@ -287,6 +304,74 @@ export const generateParticipantToken = async (
return participantTokenCookie;
};
/**
* Adds a fake participant to a LiveKit room for testing purposes.
*
* This workflow involves three key steps:
* 1. Create the LiveKit room manually
* 2. Set room metadata to mark it as managed by OpenVidu Meet
* 3. Connect a fake participant to the room
*
* @param roomId The ID of the room to join
* @param participantName The name for the fake participant
*/
export const joinFakeParticipant = (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.
// We set a short departureTimeout (1s) to ensure the room is quickly cleaned up
// when our tests disconnect participants, preventing lingering test resources.
const createRoomCommand = `lk room create --api-key ${LIVEKIT_API_KEY} --api-secret ${LIVEKIT_API_SECRET} --departure-timeout 1 ${roomId}`;
runCommandSync(createRoomCommand);
// Step 2: Set required room metadata
// The room must have the createdBy field set to MEET_NAME_ID so that:
// 1. OpenVidu Meet recognizes it as a managed room
// 2. The room can be properly deleted through our API later
// 3. Other OpenVidu Meet features know this room belongs to our system
const metadata = JSON.stringify({ createdBy: MEET_NAME_ID });
const updateMetadataCommand = `lk room update --metadata '${metadata}' --api-key ${LIVEKIT_API_KEY} --api-secret ${LIVEKIT_API_SECRET} ${roomId}`;
runCommandSync(updateMetadataCommand);
// Step 3: Join a fake participant with demo audio/video
const process = spawn('lk', [
'room',
'join',
'--identity',
participantName,
'--publish-demo',
roomId,
'--api-key',
LIVEKIT_API_KEY,
'--api-secret',
LIVEKIT_API_SECRET
]);
// Store the process to be able to terminate it later
fakeParticipantsProcesses.set(participantName, process);
};
export const disconnectFakeParticipants = () => {
fakeParticipantsProcesses.forEach((process, participantName) => {
process.kill();
console.log(`Stopped process for participant ${participantName}`);
});
fakeParticipantsProcesses.clear();
};
export const sleep = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
// PRIVATE METHODS
const runCommandSync = (command: string): string => {
try {
const stdout = execSync(command, { encoding: 'utf-8' });
return stdout;
} catch (error) {
console.error(`Error running command: ${error}`);
throw error;
}
};