From a636ad485fe3940d7d9bbf0a7766feb30fe27b18 Mon Sep 17 00:00:00 2001 From: Carlos Santos <4a.santos@gmail.com> Date: Mon, 18 Aug 2025 18:49:46 +0200 Subject: [PATCH] backend: Implement participant name reservation system - 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. --- .../openapi/paths/internal/participants.yaml | 2 - backend/package-lock.json | 102 +++--- .../src/config/dependency-injector.config.ts | 4 +- backend/src/config/internal-config.ts | 4 + .../controllers/livekit-webhook.controller.ts | 3 + backend/src/models/redis.model.ts | 5 + backend/src/services/index.ts | 1 + .../src/services/livekit-webhook.service.ts | 26 +- .../src/services/participant-name.service.ts | 314 ++++++++++++++++++ backend/src/services/participant.service.ts | 91 ++++- backend/src/services/redis.service.ts | 93 +++++- backend/src/services/token.service.ts | 2 +- .../api/participants/generate-token.test.ts | 35 +- .../services/participant-name.service.test.ts | 283 ++++++++++++++++ frontend/package-lock.json | 8 +- frontend/package.json | 2 +- .../lib/pages/meeting/meeting.component.html | 2 +- 17 files changed, 889 insertions(+), 88 deletions(-) create mode 100644 backend/src/services/participant-name.service.ts create mode 100644 backend/tests/unit/services/participant-name.service.test.ts diff --git a/backend/openapi/paths/internal/participants.yaml b/backend/openapi/paths/internal/participants.yaml index 8932a45..626a555 100644 --- a/backend/openapi/paths/internal/participants.yaml +++ b/backend/openapi/paths/internal/participants.yaml @@ -21,8 +21,6 @@ $ref: '../../components/responses/forbidden-error.yaml' '404': $ref: '../../components/responses/error-room-not-found.yaml' - '409': - $ref: '../../components/responses/internal/error-participant-already-exists.yaml' '422': $ref: '../../components/responses/validation-error.yaml' '500': diff --git a/backend/package-lock.json b/backend/package-lock.json index ab58ace..65364d5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1182,22 +1182,22 @@ } }, "node_modules/@babel/core": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", - "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", + "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", + "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.6", - "@babel/parser": "^7.28.0", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.3", + "@babel/parser": "^7.28.3", "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.0", - "@babel/types": "^7.28.0", + "@babel/traverse": "^7.28.3", + "@babel/types": "^7.28.2", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -1223,14 +1223,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", - "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.0", - "@babel/types": "^7.28.0", + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -1291,15 +1291,15 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", - "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.3" + "@babel/traverse": "^7.28.3" }, "engines": { "node": ">=6.9.0" @@ -1349,9 +1349,9 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.2.tgz", - "integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz", + "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==", "dev": true, "license": "MIT", "dependencies": { @@ -1363,13 +1363,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", - "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", + "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.0" + "@babel/types": "^7.28.2" }, "bin": { "parser": "bin/babel-parser.js" @@ -1633,18 +1633,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", - "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", + "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", + "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.0", + "@babel/parser": "^7.28.3", "@babel/template": "^7.27.2", - "@babel/types": "^7.28.0", + "@babel/types": "^7.28.2", "debug": "^4.3.1" }, "engines": { @@ -2326,9 +2326,9 @@ } }, "node_modules/@inquirer/confirm": { - "version": "5.1.14", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.14.tgz", - "integrity": "sha512-5yR4IBfe0kXe59r1YCTG8WXkUbl7Z35HK87Sw+WUyGD8wNUx7JvY7laahzeytyE1oLn74bQnL7hstctQxisQ8Q==", + "version": "5.1.15", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.15.tgz", + "integrity": "sha512-SwHMGa8Z47LawQN0rog0sT+6JpiL0B7eW9p1Bb7iCeKDGTI5Ez25TSc2l8kw52VV7hA4sX/C78CGkMrKXfuspA==", "dev": true, "license": "MIT", "dependencies": { @@ -2548,14 +2548,14 @@ } }, "node_modules/@inquirer/prompts": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.8.2.tgz", - "integrity": "sha512-nqhDw2ZcAUrKNPwhjinJny903bRhI0rQhiDz1LksjeRxqa36i3l75+4iXbOy0rlDpLJGxqtgoPavQjmmyS5UJw==", + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.8.3.tgz", + "integrity": "sha512-iHYp+JCaCRktM/ESZdpHI51yqsDgXu+dMs4semzETftOaF8u5hwlqnbIsuIR/LrWZl8Pm1/gzteK9I7MAq5HTA==", "dev": true, "license": "MIT", "dependencies": { "@inquirer/checkbox": "^4.2.1", - "@inquirer/confirm": "^5.1.14", + "@inquirer/confirm": "^5.1.15", "@inquirer/editor": "^4.2.17", "@inquirer/expand": "^4.0.17", "@inquirer/input": "^4.2.1", @@ -2739,9 +2739,9 @@ } }, "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", "license": "MIT", "engines": { "node": ">=12" @@ -3648,9 +3648,9 @@ "license": "MIT" }, "node_modules/@livekit/protocol": { - "version": "1.39.3", - "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.39.3.tgz", - "integrity": "sha512-hfOnbwPCeZBEvMRdRhU2sr46mjGXavQcrb3BFRfG+Gm0Z7WUSeFdy5WLstXJzEepz17Iwp/lkGwJ4ZgOOYfPuA==", + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.40.0.tgz", + "integrity": "sha512-1q0TNqlSTDW9ZuQCYTLDusyO+StvAXPmECQgyuszThDjzhTwQOkA6Rv7B1wPcvpTcwieIG0vuAO4cw4+kg+xWA==", "license": "Apache-2.0", "dependencies": { "@bufbuild/protobuf": "^1.10.0" @@ -7517,9 +7517,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.200", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.200.tgz", - "integrity": "sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==", + "version": "1.5.203", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.203.tgz", + "integrity": "sha512-uz4i0vLhfm6dLZWbz/iH88KNDV+ivj5+2SA+utpgjKaj9Q0iDLuwk6Idhe9BTxciHudyx6IvTvijhkPvFGUQ0g==", "dev": true, "license": "ISC" }, @@ -12366,14 +12366,14 @@ } }, "node_modules/openapi-generate-html/node_modules/inquirer": { - "version": "12.9.2", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-12.9.2.tgz", - "integrity": "sha512-XPukbomHpZc3GAajQdAcuqa5NCIFhUcLMcXXSpJLM2RW/u/5JHLxjLF206GNTJARib8XBBRqyMbaNrDzXROdoA==", + "version": "12.9.3", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-12.9.3.tgz", + "integrity": "sha512-Hpw2JWdrYY8xJSmhU05Idd5FPshQ1CZErH00WO+FK6fKxkBeqj+E+yFXSlERZLKtzWeQYFCMfl8U2TK9SvVbtQ==", "dev": true, "license": "MIT", "dependencies": { "@inquirer/core": "^10.1.15", - "@inquirer/prompts": "^7.8.2", + "@inquirer/prompts": "^7.8.3", "@inquirer/type": "^3.0.8", "ansi-escapes": "^4.3.2", "mute-stream": "^2.0.0", diff --git a/backend/src/config/dependency-injector.config.ts b/backend/src/config/dependency-injector.config.ts index 29dc8da..2641860 100644 --- a/backend/src/config/dependency-injector.config.ts +++ b/backend/src/config/dependency-injector.config.ts @@ -24,7 +24,8 @@ import { TaskSchedulerService, TokenService, UserService, - FrontendEventService + FrontendEventService, + ParticipantNameService } from '../services/index.js'; export const container: Container = new Container(); @@ -61,6 +62,7 @@ export const registerDependencies = () => { container.bind(FrontendEventService).toSelf().inSingletonScope(); container.bind(LiveKitService).toSelf().inSingletonScope(); container.bind(RoomService).toSelf().inSingletonScope(); + container.bind(ParticipantNameService).toSelf().inSingletonScope(); container.bind(ParticipantService).toSelf().inSingletonScope(); container.bind(RecordingService).toSelf().inSingletonScope(); container.bind(OpenViduWebhookService).toSelf().inSingletonScope(); diff --git a/backend/src/config/internal-config.ts b/backend/src/config/internal-config.ts index 6ff1915..6d1e0a5 100644 --- a/backend/src/config/internal-config.ts +++ b/backend/src/config/internal-config.ts @@ -17,6 +17,10 @@ const INTERNAL_CONFIG = { PARTICIPANT_TOKEN_EXPIRATION: '2h', RECORDING_TOKEN_EXPIRATION: '2h', + // Participant name reservations + PARTICIPANT_MAX_CONCURRENT_NAME_REQUESTS: '20', // Maximum number of request by the same name at the same time allowed + PARTICIPANT_NAME_RESERVATION_TTL: '12h' as StringValue, // Time-to-live for participant name reservations + // Headers for API requests API_KEY_HEADER: 'x-api-key', PARTICIPANT_ROLE_HEADER: 'x-participant-role', diff --git a/backend/src/controllers/livekit-webhook.controller.ts b/backend/src/controllers/livekit-webhook.controller.ts index 6db7bbd..68a24d7 100644 --- a/backend/src/controllers/livekit-webhook.controller.ts +++ b/backend/src/controllers/livekit-webhook.controller.ts @@ -44,6 +44,9 @@ export const lkWebhookHandler = async (req: Request, res: Response) => { case 'participant_joined': await lkWebhookService.handleParticipantJoined(room!, participant!); break; + case 'participant_left': + await lkWebhookService.handleParticipantLeft(room!, participant!); + break; case 'room_started': await lkWebhookService.handleRoomStarted(room!); break; diff --git a/backend/src/models/redis.model.ts b/backend/src/models/redis.model.ts index 3b4d7b3..f5dd2b7 100644 --- a/backend/src/models/redis.model.ts +++ b/backend/src/models/redis.model.ts @@ -10,6 +10,11 @@ export const enum RedisKeyName { ARCHIVED_ROOM = `${RedisKeyPrefix.BASE}archived_room:`, USER = `${RedisKeyPrefix.BASE}user:`, API_KEYS = `${RedisKeyPrefix.BASE}api_keys:`, + //Tracks all currently reserved participant names per room (with TTL for auto-expiration). + ROOM_PARTICIPANTS = `${RedisKeyPrefix.BASE}room_participants:`, + // Stores released numeric suffixes (per base name) in a sorted set, so that freed numbers + // can be reused efficiently instead of always incrementing to the next highest number. + PARTICIPANT_NAME_POOL = `${RedisKeyPrefix.BASE}participant_pool:` } export const enum RedisLockPrefix { diff --git a/backend/src/services/index.ts b/backend/src/services/index.ts index ed5a722..6821275 100644 --- a/backend/src/services/index.ts +++ b/backend/src/services/index.ts @@ -13,6 +13,7 @@ export * from './auth.service.js'; export * from './livekit.service.js'; export * from './frontend-event.service.js'; export * from './room.service.js'; +export * from './participant-name.service.js'; export * from './participant.service.js'; export * from './recording.service.js'; export * from './openvidu-webhook.service.js'; diff --git a/backend/src/services/livekit-webhook.service.ts b/backend/src/services/livekit-webhook.service.ts index ad25d9f..918c8a3 100644 --- a/backend/src/services/livekit-webhook.service.ts +++ b/backend/src/services/livekit-webhook.service.ts @@ -10,6 +10,7 @@ import { MeetStorageService, MutexService, OpenViduWebhookService, + ParticipantService, RecordingService, RoomService, DistributedEventService @@ -29,6 +30,7 @@ export class LivekitWebhookService { @inject(MutexService) protected mutexService: MutexService, @inject(DistributedEventService) protected distributedEventService: DistributedEventService, @inject(FrontendEventService) protected frontendEventService: FrontendEventService, + @inject(ParticipantService) protected participantService: ParticipantService, @inject(LoggerService) protected logger: LoggerService ) { this.webhookReceiver = new WebhookReceiver(LIVEKIT_API_KEY, LIVEKIT_API_SECRET); @@ -158,6 +160,25 @@ export class LivekitWebhookService { } } + /** + * Handles the 'participant_left' event by releasing the participant's reserved name + * to make it available for other participants. + * @param room - Information about the room where the participant left. + * @param participant - Information about the participant who left. + */ + async handleParticipantLeft(room: Room, participant: ParticipantInfo) { + // Skip if the participant is an egress participant + if (this.livekitService.isEgressParticipant(participant)) return; + + try { + // Release the participant's reserved name + await this.participantService.releaseParticipantName(room.name, participant.name); + this.logger.verbose(`Released name for participant '${participant.name}' in room '${room.name}'`); + } catch (error) { + this.logger.error('Error releasing participant name on participant left:', error); + } + } + /** * Handles a room started event from LiveKit. * @@ -210,7 +231,10 @@ export class LivekitWebhookService { tasks.push(this.roomService.bulkDeleteRooms([roomName], true)); } - tasks.push(this.recordingService.releaseRecordingLockIfNoEgress(roomName)); + tasks.push( + this.participantService.cleanupParticipantNames(roomName), + this.recordingService.releaseRecordingLockIfNoEgress(roomName) + ); await Promise.all(tasks); } catch (error) { this.logger.error(`Error handling room finished event: ${error}`); diff --git a/backend/src/services/participant-name.service.ts b/backend/src/services/participant-name.service.ts new file mode 100644 index 0000000..da90ee3 --- /dev/null +++ b/backend/src/services/participant-name.service.ts @@ -0,0 +1,314 @@ +import { inject, injectable } from 'inversify'; +import { RedisKeyName } from '../models/redis.model.js'; +import { LoggerService, RedisService } from './index.js'; +import ms from 'ms'; +import INTERNAL_CONFIG from '../config/internal-config.js'; + +@injectable() +export class ParticipantNameService { + private readonly MAX_CONCURRENT_NAME_REQUESTS = Number(INTERNAL_CONFIG.PARTICIPANT_MAX_CONCURRENT_NAME_REQUESTS); + private readonly PARTICIPANT_NAME_TTL = ms(INTERNAL_CONFIG.PARTICIPANT_NAME_RESERVATION_TTL); + + constructor( + @inject(RedisService) protected redisService: RedisService, + @inject(LoggerService) protected logger: LoggerService + ) {} + + /** + * Reserves a unique participant name for a room using atomic operations. + * If the requested name is taken, it generates alternatives with incremental suffixes. + * + * @param roomId - The room identifier + * @param requestedName - The desired participant name + * @returns Promise - The reserved unique name + * @throws Error if unable to reserve a unique name after max retries + */ + async reserveUniqueName(roomId: string, requestedName: string): Promise { + const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`; + + // Normalize the base name for case-insensitive comparisons + const normalizedBaseName = requestedName.toLowerCase(); + + // First, try to reserve the exact requested name + const reservedOriginal = await this.tryReserveName(participantsKey, normalizedBaseName); + + if (reservedOriginal) { + this.logger.verbose(`Reserved original name '${requestedName}' for room '${roomId}'`); + return requestedName; + } + + // If original name is taken, generate alternatives with atomic counter + for (let attempt = 1; attempt <= this.MAX_CONCURRENT_NAME_REQUESTS; attempt++) { + const alternativeName = await this.generateAlternativeName(roomId, normalizedBaseName, attempt); + const reserved = await this.tryReserveName(participantsKey, alternativeName); + + if (reserved) { + this.logger.verbose( + `Reserved alternative name '${alternativeName}' for room '${roomId}' (attempt ${attempt})` + ); + // Return alternative name with original case + const suffix = alternativeName.replace(`${normalizedBaseName}_`, ''); + return `${requestedName}_${suffix}`; + } + } + + throw new Error( + `Unable to reserve unique name for '${requestedName}' in room '${roomId}' after ${this.MAX_CONCURRENT_NAME_REQUESTS} attempts` + ); + } + + /** + * Releases a reserved participant name, making it available for others. + * + * @param roomId - The room identifier + * @param participantName - The name to release + */ + /** + * Releases a reserved participant name, making it available for others. + * Also returns the number suffix to the available pool for reuse. + * + * @param roomId - The room identifier + * @param participantName - The name to release + */ + async releaseName(roomId: string, participantName: string): Promise { + const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`; + // Normalize the name for case-insensitive checks + const normalizedName = participantName.toLowerCase(); + + try { + await this.redisService.delete(`${participantsKey}:${normalizedName}`); + + // If this is a numbered variant (e.g., "Alice_2"), return the number to the pool + const numberMatch = participantName.match(/^(.+)_(\d+)$/); + + if (numberMatch) { + const baseName = numberMatch[1]; + const number = parseInt(numberMatch[2], 10); + await this.returnNumberToPool(roomId, baseName, number); + } + + this.logger.verbose(`Released name '${participantName}' for room '${roomId}'`); + } catch (error) { + this.logger.warn(`Error releasing name '${participantName}' for room '${roomId}':`, error); + } + } + + /** + * Checks if a participant name is currently reserved in a room. + * + * @param roomId - The room identifier + * @param participantName - The name to check + * @returns Promise - True if the name is reserved + */ + async isNameReserved(roomId: string, participantName: string): Promise { + // Normalize the name for case-insensitive checks + const normalizedName = participantName.toLowerCase(); + const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`; + return await this.redisService.exists(`${participantsKey}:${normalizedName}`); + } + + /** + * Gets all currently reserved names in a room. + * + * @param roomId - The room identifier + * @returns Promise - Array of reserved participant names + */ + async getReservedNames(roomId: string): Promise { + const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`; + const pattern = `${participantsKey}:*`; + + try { + const keys = await this.redisService.getKeys(pattern); + return keys.map((key) => key.replace(`${participantsKey}:`, '')); + } catch (error) { + this.logger.error(`Error getting reserved names for room '${roomId}':`, error); + return []; + } + } + + /** + * Cleans up expired participant reservations for a room. + * This should be called periodically or when a room is cleaned up. + * + * @param roomId - The room identifier + */ + async cleanupExpiredReservations(roomId: string): Promise { + const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`; + const participantsPoolKey = `${RedisKeyName.PARTICIPANT_NAME_POOL}${roomId}`; + const pattern = `${participantsKey}:*`; + const poolPattern = `${participantsPoolKey}:*`; + + try { + const [participantKeys, poolKeys] = await Promise.all([ + this.redisService.getKeys(pattern), + this.redisService.getKeys(poolPattern) + ]); + this.logger.verbose( + `Found ${participantKeys.length} participant reservations to check for room '${roomId}'` + ); + + // Redis TTL will automatically clean up expired keys, but we can force cleanup if needed + const promises = participantKeys.map((key) => this.redisService.delete(key)); + await Promise.all(promises); + this.logger.verbose( + `Cleaned up ${participantKeys.length} expired participant names reservations for room '${roomId}'` + ); + + // Clean up expired participant name numbers from the pool + this.logger.verbose(`Found ${poolKeys.length} participant name numbers to check for room '${roomId}'`); + const poolPromises = poolKeys.map((key) => this.redisService.delete(key)); + await Promise.all(poolPromises); + this.logger.verbose(`Cleaned up ${poolKeys.length} expired participant name numbers for room '${roomId}'`); + } catch (error) { + this.logger.error(`Error cleaning up reservations for room '${roomId}':`, error); + } + } + + /** + * Attempts to atomically reserve a specific name using Redis SET with NX (not exists) option. + * + * @private + * @param participantsKey - The Redis key prefix for participants + * @param name - The name to reserve + * @returns Promise - True if reservation was successful + */ + private async tryReserveName(participantsKey: string, name: string): Promise { + // Normalize the name for case-insensitive checks + const normalizedName = name.toLowerCase(); + const nameKey = `${participantsKey}:${normalizedName}`; + const timestamp = Date.now().toString(); + + try { + return await this.redisService.setIfNotExists(nameKey, timestamp, this.PARTICIPANT_NAME_TTL); + } catch (error) { + this.logger.warn(`Error trying to reserve name '${name}':`, error); + return false; + } + } + + /** + * Generates an alternative name using a pool of available numbers. + * First tries to get a number from the available pool, then generates the next sequential number. + * + * @private + * @param roomId - The room identifier + * @param baseName - The base name to append number to + * @param fallbackSuffix - Fallback suffix if Redis fails + * @returns Promise - The generated alternative name + */ + private async generateAlternativeName(roomId: string, baseName: string, fallbackSuffix: number): Promise { + try { + // Normalize the base name for case-insensitive checks + const normalizedBaseName = baseName.toLowerCase(); + + // First try to get an available number from the pool + const availableNumber = await this.getNumberFromPool(roomId, normalizedBaseName); + + if (availableNumber !== null) { + return `${baseName}_${availableNumber}`; + } + + // If no number available in pool, find the next sequential number + const nextNumber = await this.findNextAvailableNumber(roomId, baseName); + return `${baseName}_${nextNumber}`; + } catch (error) { + this.logger.warn(`Error generating alternative name, using fallback:`, error); + // Fallback to simple incremental suffix if Redis fails + return `${baseName}_${fallbackSuffix}`; + } + } + + /** + * Gets the smallest available number from the pool for reuse. + * + * @private + * @param roomId - The room identifier + * @param baseName - The base name + * @returns Promise - Available number or null if pool is empty + */ + private async getNumberFromPool(roomId: string, baseName: string): Promise { + const poolKey = `${RedisKeyName.PARTICIPANT_NAME_POOL}${roomId}:${baseName}`; + + try { + // Get the smallest number from the sorted set and remove it atomically + const results = await this.redisService.popMinFromSortedSet(poolKey, 1); + + if (results.length > 0) { + const number = parseInt(results[0], 10); + this.logger.verbose(`Reusing number ${number} from pool for '${baseName}' in room '${roomId}'`); + return number; + } + + return null; + } catch (error) { + this.logger.warn(`Error getting number from pool:`, error); + return null; + } + } + + /** + * Finds the next available sequential number by checking existing participants. + * + * @private + * @param roomId - The room identifier + * @param baseName - The base name + * @returns Promise - The next available number + */ + private async findNextAvailableNumber(roomId: string, baseName: string): Promise { + const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`; + const pattern = `${participantsKey}:${baseName}_*`; + + try { + const existingKeys = await this.redisService.getKeys(pattern); + const usedNumbers = new Set(); + + // Extract all used numbers + for (const key of existingKeys) { + const name = key.replace(`${participantsKey}:`, ''); + const numberMatch = name.match(/^.+_(\d+)$/); + + if (numberMatch) { + usedNumbers.add(parseInt(numberMatch[1], 10)); + } + } + + // Find the smallest available number starting from 1 + let nextNumber = 1; + + while (usedNumbers.has(nextNumber)) { + nextNumber++; + } + + this.logger.verbose(`Generated new sequential number ${nextNumber} for '${baseName}' in room '${roomId}'`); + return nextNumber; + } catch (error) { + this.logger.warn(`Error finding next available number:`, error); + // Fallback to timestamp-based number if everything fails + return Date.now() % 10000; + } + } + + /** + * Returns a number to the available pool for reuse. + * + * @private + * @param roomId - The room identifier + * @param baseName - The base name + * @param number - The number to return to pool + */ + private async returnNumberToPool(roomId: string, baseName: string, number: number): Promise { + const poolKey = `${RedisKeyName.PARTICIPANT_NAME_POOL}${roomId}:${baseName}`; + + try { + // Add number to sorted set (score = number for natural ordering) + await this.redisService.addToSortedSet(poolKey, number, number.toString()); + + // Set TTL on pool key to prevent memory leaks + await this.redisService.setExpiration(poolKey, this.PARTICIPANT_NAME_TTL); + + this.logger.verbose(`Returned number ${number} to pool for '${baseName}' in room '${roomId}'`); + } catch (error) { + this.logger.warn(`Error returning number to pool:`, error); + } + } +} diff --git a/backend/src/services/participant.service.ts b/backend/src/services/participant.service.ts index 5b79c5f..c4e2b37 100644 --- a/backend/src/services/participant.service.ts +++ b/backend/src/services/participant.service.ts @@ -10,11 +10,17 @@ import { ParticipantInfo } from 'livekit-server-sdk'; import { MeetRoomHelper } from '../helpers/room.helper.js'; import { validateMeetTokenMetadata } from '../middlewares/index.js'; import { - errorParticipantAlreadyExists, errorParticipantIdentityNotProvided, errorParticipantNotFound } from '../models/error.model.js'; -import { FrontendEventService, LiveKitService, LoggerService, RoomService, TokenService } from './index.js'; +import { + FrontendEventService, + LiveKitService, + LoggerService, + ParticipantNameService, + RoomService, + TokenService +} from './index.js'; @injectable() export class ParticipantService { @@ -23,7 +29,8 @@ export class ParticipantService { @inject(RoomService) protected roomService: RoomService, @inject(LiveKitService) protected livekitService: LiveKitService, @inject(FrontendEventService) protected frontendEventService: FrontendEventService, - @inject(TokenService) protected tokenService: TokenService + @inject(TokenService) protected tokenService: TokenService, + @inject(ParticipantNameService) protected participantNameService: ParticipantNameService ) {} async generateOrRefreshParticipantToken( @@ -32,34 +39,49 @@ export class ParticipantService { refresh = false ): Promise { const { roomId, secret, participantName, participantIdentity } = participantOptions; + let finalParticipantName = participantName; + let finalParticipantOptions: ParticipantOptions = participantOptions; if (participantName) { - if (!refresh) { - // Check if participant with same participantName exists in the room - const participantExists = await this.participantExists(roomId, participantName, 'name'); - - if (participantExists) { - this.logger.verbose(`Participant '${participantName}' already exists in room '${roomId}'`); - throw errorParticipantAlreadyExists(participantName, roomId); - } - } else { + if (refresh) { if (!participantIdentity) { throw errorParticipantIdentityNotProvided(); } + this.logger.verbose(`Refreshing participant token for '${participantIdentity}' in room '${roomId}'`); // Check if participant with same participantIdentity exists in the room const participantExists = await this.participantExists(roomId, participantIdentity, 'identity'); if (!participantExists) { - this.logger.verbose(`Participant '${participantName}' does not exist in room '${roomId}'`); - throw errorParticipantNotFound(participantName, roomId); + this.logger.verbose(`Participant '${participantIdentity}' does not exist in room '${roomId}'`); + throw errorParticipantNotFound(participantIdentity, roomId); } + } else { + this.logger.verbose(`Generating participant token for '${participantName}' in room '${roomId}'`); + + try { + // Reserve a unique name for the participant + finalParticipantName = await this.participantNameService.reserveUniqueName(roomId, participantName); + this.logger.verbose(`Reserved unique name '${finalParticipantName}' for room '${roomId}'`); + } catch (error) { + this.logger.error( + `Failed to reserve unique name '${participantName}' for room '${roomId}':`, + error + ); + throw error; + } + + // Update participantOptions with the final participant name + finalParticipantOptions = { + ...participantOptions, + participantName: finalParticipantName + }; } } const role = await this.roomService.getRoomRoleBySecret(roomId, secret); - const token = await this.generateParticipantToken(participantOptions, role, currentRoles); - this.logger.verbose(`Participant token generated for room '${roomId}'`); + const token = await this.generateParticipantToken(finalParticipantOptions, role, currentRoles); + this.logger.verbose(`Participant token generated for room '${roomId}' with name '${finalParticipantName}'`); return token; } @@ -186,4 +208,41 @@ export class ParticipantService { } }; } + + /** + * Releases a participant's reserved name when they disconnect. + * This should be called when a participant leaves the room to free up the name. + * + * @param roomId - The room identifier + * @param participantName - The participant name to release + */ + async releaseParticipantName(roomId: string, participantName: string): Promise { + try { + await this.participantNameService.releaseName(roomId, participantName); + this.logger.verbose(`Released participant name '${participantName}' for room '${roomId}'`); + } catch (error) { + this.logger.warn(`Error releasing participant name '${participantName}' for room '${roomId}':`, error); + } + } + + /** + * Gets all currently reserved participant names in a room. + * Useful for debugging and monitoring. + * + * @param roomId - The room identifier + * @returns Promise - Array of reserved participant names + */ + async getReservedNames(roomId: string): Promise { + return await this.participantNameService.getReservedNames(roomId); + } + + /** + * Cleans up expired participant name reservations for a room. + * This can be called during room cleanup or periodically. + * + * @param roomId - The room identifier + */ + async cleanupParticipantNames(roomId: string): Promise { + await this.participantNameService.cleanupExpiredReservations(roomId); + } } diff --git a/backend/src/services/redis.service.ts b/backend/src/services/redis.service.ts index 7649850..2efb7fa 100644 --- a/backend/src/services/redis.service.ts +++ b/backend/src/services/redis.service.ts @@ -206,18 +206,26 @@ export class RedisService extends EventEmitter { * @returns {Promise} - A promise that resolves to 'OK' if the operation is successful. * @throws {Error} - Throws an error if the value type is invalid or if there is an issue setting the value in Redis. */ - async set(key: string, value: any, withTTL = true): Promise { + async set(key: string, value: string | number | boolean | object, withTTL = true): Promise { try { const valueType = typeof value; - if (valueType === 'string' || valueType === 'number' || valueType === 'boolean') { + if (valueType === 'string' || valueType === 'number') { if (withTTL) { - await this.redisPublisher.set(key, value, 'EX', this.DEFAULT_TTL); + await this.redisPublisher.set(key, value.toString(), 'EX', this.DEFAULT_TTL); } else { - await this.redisPublisher.set(key, value); + await this.redisPublisher.set(key, value.toString()); + } + } else if (valueType === 'boolean') { + const stringValue = value.toString(); + + if (withTTL) { + await this.redisPublisher.set(key, stringValue, 'EX', this.DEFAULT_TTL); + } else { + await this.redisPublisher.set(key, stringValue); } } else if (valueType === 'object') { - await this.redisPublisher.hmset(key, value); + await this.redisPublisher.hmset(key, value as Record); if (withTTL) await this.redisPublisher.expire(key, this.DEFAULT_TTL); } else { @@ -250,6 +258,81 @@ export class RedisService extends EventEmitter { } } + /** + * Atomically sets a value only if the key doesn't exist (SET with NX option). + * + * @param {string} key - The key to set + * @param {string} value - The value to set + * @param {number} [ttlSeconds] - Optional TTL in seconds + * @returns {Promise} - True if the key was set, false if it already existed + */ + async setIfNotExists(key: string, value: string, ttlSeconds?: number): Promise { + try { + let result: string | null; + + if (ttlSeconds) { + result = await this.redisPublisher.set(key, value, 'EX', ttlSeconds, 'NX'); + } else { + result = (await this.redisPublisher.setnx(key, value)) ? 'OK' : null; + } + + return result === 'OK'; + } catch (error) { + this.logger.error('Error setting value with NX option in Redis', error); + throw error; + } + } + + /** + * Sets an expiration time on an existing key. + * + * @param {string} key - The key to set expiration on + * @param {number} ttlSeconds - TTL in seconds + * @returns {Promise} - True if expiration was set successfully + */ + async setExpiration(key: string, ttlSeconds: number): Promise { + try { + const result = await this.redisPublisher.expire(key, ttlSeconds); + return result === 1; + } catch (error) { + this.logger.error('Error setting expiration in Redis', error); + throw error; + } + } + + /** + * Removes and returns the member with the lowest score from a sorted set. + * + * @param {string} key - The key of the sorted set + * @param {number} count - Number of members to pop (default: 1) + * @returns {Promise} - Array of popped members + */ + async popMinFromSortedSet(key: string, count = 1): Promise { + try { + return await this.redisPublisher.zpopmin(key, count); + } catch (error) { + this.logger.error('Error popping min from sorted set in Redis', error); + throw error; + } + } + + /** + * Adds one or more members to a sorted set. + * + * @param {string} key - The key of the sorted set + * @param {number} score - The score for the member + * @param {string} member - The member to add + * @returns {Promise} - Number of elements added + */ + async addToSortedSet(key: string, score: number, member: string): Promise { + try { + return await this.redisPublisher.zadd(key, score, member); + } catch (error) { + this.logger.error('Error adding to sorted set in Redis', error); + throw error; + } + } + cleanup() { this.logger.verbose('Cleaning up Redis connections'); this.redisPublisher.quit(); diff --git a/backend/src/services/token.service.ts b/backend/src/services/token.service.ts index 4be127c..eab5b66 100644 --- a/backend/src/services/token.service.ts +++ b/backend/src/services/token.service.ts @@ -57,7 +57,7 @@ export class TokenService { if (participantName && !participantIdentity) { // Generate participant identity based on name and unique ID const identityPrefix = participantName.replace(/\s+/g, ''); // Remove all spaces - participantIdentity = `${identityPrefix}-${uid(5)}`; + participantIdentity = `${identityPrefix}-${uid(8)}`; } const metadata: MeetTokenMetadata = { diff --git a/backend/tests/integration/api/participants/generate-token.test.ts b/backend/tests/integration/api/participants/generate-token.test.ts index 541608e..e488245 100644 --- a/backend/tests/integration/api/participants/generate-token.test.ts +++ b/backend/tests/integration/api/participants/generate-token.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; import { ParticipantRole } from '../../../../src/typings/ce/participant.js'; import { expectValidationError, expectValidParticipantTokenResponse } from '../../../helpers/assertion-helpers.js'; import { @@ -17,10 +17,14 @@ describe('Participant API Tests', () => { beforeAll(async () => { startTestServer(); + }); + + beforeEach(async () => { roomData = await setupSingleRoom(); }); - afterAll(async () => { + // Force to cleanup participant name reservations after each test + afterEach(async () => { await disconnectFakeParticipants(); await deleteAllRooms(); }); @@ -87,14 +91,35 @@ describe('Participant API Tests', () => { ); }); - it('should fail with 409 when participant already exists in the room', async () => { + it('should success when participant already exists in the room', async () => { roomData = await setupSingleRoom(true); - const response = await generateParticipantToken({ + let response = await generateParticipantToken({ roomId: roomData.room.roomId, secret: roomData.moderatorSecret, participantName }); - expect(response.status).toBe(409); + + // First participant using API. LK CLI participants can reuse the same name. + expectValidParticipantTokenResponse( + response, + roomData.room.roomId, + ParticipantRole.MODERATOR, + participantName + ); + + response = await generateParticipantToken({ + roomId: roomData.room.roomId, + secret: roomData.moderatorSecret, + participantName + }); + + // Second participant using API, the participant name should be unique + expectValidParticipantTokenResponse( + response, + roomData.room.roomId, + ParticipantRole.MODERATOR, + participantName + '_1' + ); // Recreate the room without the participant roomData = await setupSingleRoom(); diff --git a/backend/tests/unit/services/participant-name.service.test.ts b/backend/tests/unit/services/participant-name.service.test.ts new file mode 100644 index 0000000..db08159 --- /dev/null +++ b/backend/tests/unit/services/participant-name.service.test.ts @@ -0,0 +1,283 @@ +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[] = []; + 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[]; + 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 + }); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7a0a4e7..a53e2c1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,7 +21,7 @@ "core-js": "^3.38.1", "jwt-decode": "^4.0.0", "livekit-server-sdk": "^2.10.2", - "openvidu-components-angular": "^3.4.0-dev14", + "openvidu-components-angular": "^3.4.0-dev15", "rxjs": "7.8.1", "tslib": "^2.3.0", "unique-names-generator": "^4.7.1", @@ -13576,9 +13576,9 @@ } }, "node_modules/openvidu-components-angular": { - "version": "3.4.0-dev14", - "resolved": "https://registry.npmjs.org/openvidu-components-angular/-/openvidu-components-angular-3.4.0-dev14.tgz", - "integrity": "sha512-pvuQCgDqmweWdEn40x6DtN27A1pRBo4tVAFSAciM27EQtDXsV30k0uqC2khdrZfYeGcK1usvqcOmXEloQ4Aw9Q==", + "version": "3.4.0-dev15", + "resolved": "https://registry.npmjs.org/openvidu-components-angular/-/openvidu-components-angular-3.4.0-dev15.tgz", + "integrity": "sha512-rH/jPOx+ZgCIxbKW1+nvEh5qbJR5V6pbWs8SFWd1gV1V7wUJYoFI+iXxO0naaWHifq0rYNO0NoLpz2hjJqPQpQ==", "dependencies": { "tslib": "^2.3.0" }, diff --git a/frontend/package.json b/frontend/package.json index 6f02cd3..b4962b5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,7 +37,7 @@ "core-js": "^3.38.1", "jwt-decode": "^4.0.0", "livekit-server-sdk": "^2.10.2", - "openvidu-components-angular": "^3.4.0-dev14", + "openvidu-components-angular": "^3.4.0-dev15", "rxjs": "7.8.1", "tslib": "^2.3.0", "unique-names-generator": "^4.7.1", diff --git a/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.html b/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.html index 4f058b5..b34395a 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.html +++ b/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.html @@ -1,7 +1,7 @@ @if (showMeeting) {