diff --git a/backend/jest.config.mjs b/backend/jest.config.mjs index 275a949..4cde45d 100644 --- a/backend/jest.config.mjs +++ b/backend/jest.config.mjs @@ -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'], diff --git a/backend/src/controllers/room.controller.ts b/backend/src/controllers/room.controller.ts index 4473866..05e5484 100644 --- a/backend/src/controllers/room.controller.ts +++ b/backend/src/controllers/room.controller.ts @@ -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}`); diff --git a/backend/tests/integration/api/rooms/delete-room.test.ts b/backend/tests/integration/api/rooms/delete-room.test.ts new file mode 100644 index 0000000..55f2622 --- /dev/null +++ b/backend/tests/integration/api/rooms/delete-room.test.ts @@ -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'); + }); + }); +}); diff --git a/backend/tests/utils/helpers.ts b/backend/tests/utils/helpers.ts index e434b8a..6dbb4c0 100644 --- a/backend/tests/utils/helpers.ts +++ b/backend/tests/utils/helpers.ts @@ -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(); + /** * 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 = {}) => { + 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; + } +};