backend: Enhance deleteRoom functionality and add delete room integration tests
This commit is contained in:
parent
7bcb3be1dd
commit
33a970d1ef
@ -6,6 +6,7 @@ const jestConfig = {
|
|||||||
...createDefaultEsmPreset({
|
...createDefaultEsmPreset({
|
||||||
tsconfig: 'tsconfig.json'
|
tsconfig: 'tsconfig.json'
|
||||||
}),
|
}),
|
||||||
|
testTimeout: 30000,
|
||||||
resolver: 'ts-jest-resolver',
|
resolver: 'ts-jest-resolver',
|
||||||
testMatch: ['**/?(*.)+(spec|test).[tj]s?(x)'],
|
testMatch: ['**/?(*.)+(spec|test).[tj]s?(x)'],
|
||||||
moduleFileExtensions: ['js', 'ts', 'json', 'node'],
|
moduleFileExtensions: ['js', 'ts', 'json', 'node'],
|
||||||
|
|||||||
@ -72,9 +72,11 @@ export const deleteRoom = async (req: Request, res: Response) => {
|
|||||||
const { deleted } = await roomService.bulkDeleteRooms([roomId], forceDelete);
|
const { deleted } = await roomService.bulkDeleteRooms([roomId], forceDelete);
|
||||||
|
|
||||||
if (deleted.length > 0) {
|
if (deleted.length > 0) {
|
||||||
|
// Room was deleted
|
||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Room was marked as deleted
|
||||||
return res.status(202).json({ message: `Room ${roomId} marked as deleted` });
|
return res.status(202).json({ message: `Room ${roomId} marked as deleted` });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error deleting room: ${roomId}`);
|
logger.error(`Error deleting room: ${roomId}`);
|
||||||
|
|||||||
208
backend/tests/integration/api/rooms/delete-room.test.ts
Normal file
208
backend/tests/integration/api/rooms/delete-room.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -9,11 +9,15 @@ import {
|
|||||||
MEET_USER,
|
MEET_USER,
|
||||||
MEET_SECRET,
|
MEET_SECRET,
|
||||||
MEET_ADMIN_USER,
|
MEET_ADMIN_USER,
|
||||||
MEET_ADMIN_SECRET
|
MEET_ADMIN_SECRET,
|
||||||
|
LIVEKIT_API_SECRET,
|
||||||
|
LIVEKIT_API_KEY,
|
||||||
|
MEET_NAME_ID
|
||||||
} from '../../src/environment.js';
|
} from '../../src/environment.js';
|
||||||
import { AuthMode, AuthType, MeetRoom, UserRole, MeetRoomOptions } from '../../src/typings/ce/index.js';
|
import { AuthMode, AuthType, MeetRoom, UserRole, MeetRoomOptions } from '../../src/typings/ce/index.js';
|
||||||
import { expect } from '@jest/globals';
|
import { expect } from '@jest/globals';
|
||||||
import INTERNAL_CONFIG from '../../src/config/internal-config.js';
|
import INTERNAL_CONFIG from '../../src/config/internal-config.js';
|
||||||
|
import { ChildProcess, execSync, spawn } from 'child_process';
|
||||||
|
|
||||||
const CREDENTIALS = {
|
const CREDENTIALS = {
|
||||||
user: {
|
user: {
|
||||||
@ -29,6 +33,8 @@ const CREDENTIALS = {
|
|||||||
let app: Express;
|
let app: Express;
|
||||||
let server: Server;
|
let server: Server;
|
||||||
|
|
||||||
|
const fakeParticipantsProcesses = new Map<string, ChildProcess>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts the test server
|
* Starts the test server
|
||||||
*/
|
*/
|
||||||
@ -214,6 +220,17 @@ export const getRoom = async (roomId: string, fields?: string) => {
|
|||||||
.query({ fields });
|
.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 () => {
|
export const assertEmptyRooms = async () => {
|
||||||
if (!app) {
|
if (!app) {
|
||||||
throw new Error('App instance is not defined');
|
throw new Error('App instance is not defined');
|
||||||
@ -287,6 +304,74 @@ export const generateParticipantToken = async (
|
|||||||
return participantTokenCookie;
|
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) => {
|
export const sleep = (ms: number) => {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user