- Added ParticipantNameService to manage unique participant name reservations. - Integrated name reservation in ParticipantService during token generation. - Implemented cleanup of expired name reservations in LivekitWebhookService. - Enhanced RedisService with atomic operations for name reservation. - Updated internal configuration for participant name reservation limits. - Added tests for participant name reservation and release functionality. - Updated frontend dependencies to use the latest version of openvidu-components-angular.
284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
import { describe, expect, it, beforeEach, afterEach, beforeAll } from '@jest/globals';
|
|
import { container, registerDependencies } from '../../../src/config/index.js';
|
|
import { ParticipantNameService } from '../../../src/services/participant-name.service.js';
|
|
import { RedisService } from '../../../src/services/redis.service.js';
|
|
import ms from 'ms';
|
|
|
|
describe('ParticipantNameService', () => {
|
|
let participantNameService: ParticipantNameService;
|
|
let redisService: RedisService;
|
|
const testRoomId = 'test-room-unique-names';
|
|
|
|
beforeAll(async () => {
|
|
registerDependencies();
|
|
participantNameService = container.get(ParticipantNameService);
|
|
redisService = container.get(RedisService);
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Clean up any existing test data
|
|
await cleanupTestData();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Clean up test data after each test
|
|
await cleanupTestData();
|
|
});
|
|
|
|
async function cleanupTestData() {
|
|
try {
|
|
const pattern = `ov_meet:room_participants:${testRoomId}:*`;
|
|
const keys = await redisService.getKeys(pattern);
|
|
|
|
if (keys.length > 0) {
|
|
await redisService.delete(keys);
|
|
}
|
|
|
|
const counterPattern = `ov_meet:participant_counter:${testRoomId}:*`;
|
|
const counterKeys = await redisService.getKeys(counterPattern);
|
|
|
|
if (counterKeys.length > 0) {
|
|
await redisService.delete(counterKeys);
|
|
}
|
|
} catch (error) {
|
|
// Ignore cleanup errors
|
|
}
|
|
}
|
|
|
|
describe('Reserve unique participant name', () => {
|
|
it('should reserve the original name when available', async () => {
|
|
const requestedName = 'Participant';
|
|
const reservedName = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
|
|
|
expect(reservedName).toBe(requestedName);
|
|
|
|
// Verify the name is actually reserved
|
|
const isReserved = await participantNameService.isNameReserved(testRoomId, requestedName);
|
|
expect(isReserved).toBe(true);
|
|
});
|
|
|
|
it('should treat names as case-insensitive if required', async () => {
|
|
await participantNameService.reserveUniqueName(testRoomId, 'Participant');
|
|
const reserved2 = await participantNameService.reserveUniqueName(testRoomId, 'participant');
|
|
expect(reserved2).toBe('participant_1');
|
|
});
|
|
|
|
it('should generate alternative names when original is taken', async () => {
|
|
const requestedName = 'Participant';
|
|
|
|
// Reserve the original name
|
|
const firstReservation = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
|
expect(firstReservation).toBe(requestedName);
|
|
|
|
// Try to reserve the same name again - should get alternative
|
|
const secondReservation = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
|
expect(secondReservation).toBe(`${requestedName}_1`);
|
|
|
|
// Try again - should get next alternative
|
|
const thirdReservation = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
|
expect(thirdReservation).toBe(`${requestedName}_2`);
|
|
});
|
|
|
|
it('should handle concurrent reservations properly', async () => {
|
|
const requestedName = 'Participant';
|
|
const concurrentRequests = 5;
|
|
|
|
// Simulate concurrent requests for the same name
|
|
const reservationPromises = Array.from({ length: concurrentRequests }, () =>
|
|
participantNameService.reserveUniqueName(testRoomId, requestedName)
|
|
);
|
|
|
|
const reservedNames = await Promise.all(reservationPromises);
|
|
|
|
// All names should be unique
|
|
const uniqueNames = new Set(reservedNames);
|
|
expect(uniqueNames.size).toBe(concurrentRequests);
|
|
|
|
// First name should be the original
|
|
expect(reservedNames).toContain(requestedName);
|
|
|
|
// Others should be alternatives
|
|
for (let i = 1; i < concurrentRequests; i++) {
|
|
expect(reservedNames).toContain(`${requestedName}_${i}`);
|
|
}
|
|
});
|
|
|
|
it('should reuse numbers when participants disconnect', async () => {
|
|
const requestedName = 'Participant';
|
|
|
|
// Reserve multiple names
|
|
const name1 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
|
const name2 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
|
const name3 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
|
|
|
expect(name1).toBe('Participant');
|
|
expect(name2).toBe('Participant_1');
|
|
expect(name3).toBe('Participant_2');
|
|
|
|
// Release the middle one
|
|
await participantNameService.releaseName(testRoomId, name2);
|
|
|
|
// Next reservation should reuse the released number
|
|
const name4 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
|
expect(name4).toBe('Participant_1'); // Should reuse the released number
|
|
});
|
|
|
|
it('should maintain optimal numbering after multiple releases', async () => {
|
|
const requestedName = 'Optimized';
|
|
|
|
// Create several names
|
|
const names: string[] = [];
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
names.push(await participantNameService.reserveUniqueName(testRoomId, requestedName));
|
|
}
|
|
|
|
expect(names).toEqual(['Optimized', 'Optimized_1', 'Optimized_2', 'Optimized_3', 'Optimized_4']);
|
|
|
|
// Release some names (simulate participants leaving)
|
|
await participantNameService.releaseName(testRoomId, 'Optimized_1');
|
|
await participantNameService.releaseName(testRoomId, 'Optimized_3');
|
|
|
|
// New participants should get the lowest available numbers
|
|
const newName1 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
|
const newName2 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
|
|
|
expect(newName1).toBe('Optimized_1'); // Lowest available
|
|
expect(newName2).toBe('Optimized_3'); // Next lowest available
|
|
});
|
|
});
|
|
|
|
describe('releaseName', () => {
|
|
it('should release a reserved name', async () => {
|
|
const participantName = 'Participant';
|
|
|
|
// Reserve a name
|
|
await participantNameService.reserveUniqueName(testRoomId, participantName);
|
|
expect(await participantNameService.isNameReserved(testRoomId, participantName)).toBe(true);
|
|
|
|
// Release the name
|
|
await participantNameService.releaseName(testRoomId, participantName);
|
|
expect(await participantNameService.isNameReserved(testRoomId, participantName)).toBe(false);
|
|
});
|
|
|
|
it('should allow reusing a released name', async () => {
|
|
const participantName = 'Frank';
|
|
|
|
// Reserve, release, and reserve again
|
|
await participantNameService.reserveUniqueName(testRoomId, participantName);
|
|
await participantNameService.releaseName(testRoomId, participantName);
|
|
|
|
const newReservation = await participantNameService.reserveUniqueName(testRoomId, participantName);
|
|
expect(newReservation).toBe(participantName);
|
|
});
|
|
});
|
|
|
|
describe('getReservedNames', () => {
|
|
it('should return all reserved names in a room in lowercase', async () => {
|
|
const names = ['Grace', 'Henry', 'Iris'];
|
|
|
|
// Reserve multiple names
|
|
for (const name of names) {
|
|
await participantNameService.reserveUniqueName(testRoomId, name);
|
|
}
|
|
|
|
const reservedNames = await participantNameService.getReservedNames(testRoomId);
|
|
|
|
for (const name of names) {
|
|
expect(reservedNames).toContain(name.toLowerCase());
|
|
}
|
|
});
|
|
|
|
it('should return empty array when no names are reserved', async () => {
|
|
const reservedNames = await participantNameService.getReservedNames(testRoomId);
|
|
expect(reservedNames).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('Reserve unique participant name - edge cases', () => {
|
|
it('should be able to reserve same 20 names', async () => {
|
|
const requestedName = 'LimitTest';
|
|
|
|
const promises: Promise<string>[] = [];
|
|
const twentyNames = participantNameService['MAX_CONCURRENT_NAME_REQUESTS'];
|
|
|
|
for (let i = 0; i <= twentyNames; i++) {
|
|
promises.push(participantNameService.reserveUniqueName(testRoomId, requestedName));
|
|
}
|
|
|
|
// Los primeros MAX_RETRIES deben resolverse bien
|
|
const results = await Promise.allSettled(promises);
|
|
|
|
const fulfilled = results.filter((r) => r.status === 'fulfilled') as PromiseFulfilledResult<string>[];
|
|
const rejected = results.filter((r) => r.status === 'rejected') as PromiseRejectedResult[];
|
|
|
|
console.log(fulfilled);
|
|
expect(fulfilled.length).toBe(twentyNames + 1); // +1 for the original name
|
|
expect(rejected.length).toBe(0);
|
|
});
|
|
|
|
it('should handle race condition when reusing released numbers', async () => {
|
|
const requestedName = 'RaceTest';
|
|
|
|
// Try to reserve two names
|
|
const n1 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
|
const n2 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
|
|
|
expect([n1, n2]).toEqual(['RaceTest', 'RaceTest_1']);
|
|
|
|
// Release _1
|
|
await participantNameService.releaseName(testRoomId, n2);
|
|
|
|
// Try to reserve again concurrently
|
|
const [c1, c2] = await Promise.all([
|
|
participantNameService.reserveUniqueName(testRoomId, requestedName),
|
|
participantNameService.reserveUniqueName(testRoomId, requestedName)
|
|
]);
|
|
|
|
// One of them should be _1 and the other should be _2
|
|
expect([c1, c2].sort()).toEqual(['RaceTest_1', 'RaceTest_2']);
|
|
});
|
|
|
|
it('should reuse expired names after TTL', async () => {
|
|
(participantNameService as any)['PARTICIPANT_NAME_TTL'] = ms('1ms');
|
|
const requestedName = 'TTLTest';
|
|
|
|
// Reserva con TTL muy corto (simulado)
|
|
const name = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
|
expect(name).toBe('TTLTest');
|
|
|
|
// Wait for TTL to expire
|
|
await new Promise((resolve) =>
|
|
setTimeout(resolve, (participantNameService['PARTICIPANT_NAME_TTL'] + 1) * 1000)
|
|
);
|
|
|
|
// Try to reserve again
|
|
const newName = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
|
expect(newName).toBe('TTLTest'); // Reuse original name
|
|
});
|
|
|
|
it('should keep names isolated per room', async () => {
|
|
const requestedName = 'Isolated';
|
|
|
|
// Reserve in two different rooms
|
|
const room1Name = await participantNameService.reserveUniqueName('room1', requestedName);
|
|
const room2Name = await participantNameService.reserveUniqueName('room2', requestedName);
|
|
|
|
// Both names should be isolated
|
|
expect(room1Name).toBe(requestedName);
|
|
expect(room2Name).toBe(requestedName);
|
|
});
|
|
|
|
it('should treat names case-insensitively if normalization is enabled', async () => {
|
|
const requestedName = 'CaseTest';
|
|
|
|
const n1 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
|
expect(n1).toBe('CaseTest');
|
|
|
|
// Try to reserve with different casing
|
|
const n2 = await participantNameService.reserveUniqueName(testRoomId, 'casetest');
|
|
expect(n2).toBe('casetest_1'); // Should return alternative name with original case
|
|
});
|
|
});
|
|
});
|