diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 82d04cbe..b09079ba 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -128,7 +128,7 @@ class RoomSerializer(serializers.ModelSerializer): if not is_admin: del output["configuration"] - if role is not None or instance.access_level == models.RoomAccessLevel.PUBLIC: + if role is not None or instance.is_public: room_id = f"{instance.id!s}" username = request.query_params.get("username", None) output["livekit"] = utils.generate_livekit_config( @@ -171,3 +171,32 @@ class StartRecordingSerializer(serializers.Serializer): def update(self, instance, validated_data): """Not implemented as this is a validation-only serializer.""" raise NotImplementedError("StartRecordingSerializer is validation-only") + + +class RequestEntrySerializer(serializers.Serializer): + """Validate request entry data.""" + + username = serializers.CharField(required=True) + + def create(self, validated_data): + """Not implemented as this is a validation-only serializer.""" + raise NotImplementedError("StartRecordingSerializer is validation-only") + + def update(self, instance, validated_data): + """Not implemented as this is a validation-only serializer.""" + raise NotImplementedError("StartRecordingSerializer is validation-only") + + +class ParticipantEntrySerializer(serializers.Serializer): + """Validate participant entry decision data.""" + + participant_id = serializers.CharField(required=True) + allow_entry = serializers.BooleanField(required=True) + + def create(self, validated_data): + """Not implemented as this is a validation-only serializer.""" + raise NotImplementedError("StartRecordingSerializer is validation-only") + + def update(self, instance, validated_data): + """Not implemented as this is a validation-only serializer.""" + raise NotImplementedError("StartRecordingSerializer is validation-only") diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 9ee02528..a7ebbc4d 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -9,12 +9,7 @@ from django.http import Http404 from django.shortcuts import get_object_or_404 from django.utils.text import slugify -from rest_framework import ( - decorators, - mixins, - pagination, - viewsets, -) +from rest_framework import decorators, mixins, pagination, throttling, viewsets from rest_framework import ( exceptions as drf_exceptions, ) @@ -44,6 +39,10 @@ from core.recording.worker.factories import ( from core.recording.worker.mediator import ( WorkerServiceMediator, ) +from core.services.lobby_service import ( + LobbyParticipantNotFound, + LobbyService, +) from . import permissions, serializers @@ -178,6 +177,12 @@ class UserViewSet( ) +class RequestEntryAnonRateThrottle(throttling.AnonRateThrottle): + """Throttle Anonymous user requesting room entry""" + + scope = "request_entry" + + class RoomViewSet( mixins.CreateModelMixin, mixins.DestroyModelMixin, @@ -343,6 +348,89 @@ class RoomViewSet( {"message": f"Recording stopped for room {room.slug}."} ) + @decorators.action( + detail=True, + methods=["POST"], + url_path="request-entry", + permission_classes=[], + throttle_classes=[RequestEntryAnonRateThrottle], + ) + def request_entry(self, request, pk=None): # pylint: disable=unused-argument + """Request entry to a room""" + + serializer = serializers.RequestEntrySerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + room = self.get_object() + lobby_service = LobbyService() + + participant, livekit = lobby_service.request_entry( + room=room, + request=request, + **serializer.validated_data, + ) + response = drf_response.Response({**participant.to_dict(), "livekit": livekit}) + lobby_service.prepare_response(response, participant.id) + + return response + + @decorators.action( + detail=True, + methods=["post"], + url_path="enter", + permission_classes=[ + permissions.HasPrivilegesOnRoom, + ], + ) + def allow_participant_to_enter(self, request, pk=None): # pylint: disable=unused-argument + """Accept or deny a participant's entry request.""" + + serializer = serializers.ParticipantEntrySerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + room = self.get_object() + + if room.is_public: + return drf_response.Response( + {"message": "Room has no lobby system."}, + status=drf_status.HTTP_404_NOT_FOUND, + ) + + lobby_service = LobbyService() + + try: + lobby_service.handle_participant_entry( + room_id=room.id, + **serializer.validated_data, + ) + return drf_response.Response({"message": "Participant was updated."}) + + except LobbyParticipantNotFound: + return drf_response.Response( + {"message": "Participant not found."}, + status=drf_status.HTTP_404_NOT_FOUND, + ) + + @decorators.action( + detail=True, + methods=["GET"], + url_path="waiting-participants", + permission_classes=[ + permissions.HasPrivilegesOnRoom, + ], + ) + def list_waiting_participants(self, request, pk=None): # pylint: disable=unused-argument + """List waiting participants.""" + room = self.get_object() + + if room.is_public: + return drf_response.Response({"participants": []}) + + lobby_service = LobbyService() + + participants = lobby_service.list_waiting_participants(room.id) + return drf_response.Response({"participants": participants}) + class ResourceAccessListModelMixin: """List mixin for resource access API.""" diff --git a/src/backend/core/models.py b/src/backend/core/models.py index d6add13d..17de9756 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -404,6 +404,11 @@ class Room(Resource): raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'}) super().clean_fields(exclude=exclude) + @property + def is_public(self): + """Check if a room is public""" + return self.access_level == RoomAccessLevel.PUBLIC + class BaseAccessManager(models.Manager): """Base manager for handling resource access control.""" diff --git a/src/backend/core/services/lobby_service.py b/src/backend/core/services/lobby_service.py new file mode 100644 index 00000000..5d9f7470 --- /dev/null +++ b/src/backend/core/services/lobby_service.py @@ -0,0 +1,340 @@ +"""Lobby Service""" + +import json +import logging +import uuid +from dataclasses import dataclass +from enum import Enum +from typing import Dict, List, Optional, Tuple +from uuid import UUID + +from django.conf import settings +from django.core.cache import cache + +from asgiref.sync import async_to_sync +from livekit.api import LiveKitAPI, SendDataRequest, TwirpError # pylint: disable=E0611 + +from core import utils + +logger = logging.getLogger(__name__) + + +class LobbyParticipantStatus(Enum): + """Possible states of a participant in the lobby system. + Values are lowercase strings for consistent serialization and API responses. + """ + + UNKNOWN = "unknown" + WAITING = "waiting" + ACCEPTED = "accepted" + DENIED = "denied" + + +class LobbyError(Exception): + """Base exception for lobby-related errors.""" + + +class LobbyParticipantParsingError(LobbyError): + """Raised when participant data parsing fails.""" + + +class LobbyParticipantNotFound(LobbyError): + """Raised when participant is not found.""" + + +class LobbyNotificationError(LobbyError): + """Raised when LiveKit notification fails.""" + + +@dataclass +class LobbyParticipant: + """Participant in a lobby system.""" + + status: LobbyParticipantStatus + username: str + color: str + id: str + + def to_dict(self) -> Dict[str, str]: + """Serialize the participant object to a dict representation.""" + return { + "status": self.status.value, + "username": self.username, + "id": self.id, + "color": self.color, + } + + @classmethod + def from_dict(cls, data: dict) -> "LobbyParticipant": + """Create a LobbyParticipant instance from a dictionary.""" + try: + status = LobbyParticipantStatus( + data.get("status", LobbyParticipantStatus.UNKNOWN.value) + ) + return cls( + status=status, + username=data["username"], + id=data["id"], + color=data["color"], + ) + except (KeyError, ValueError) as e: + logger.exception("Error creating Participant from dict:") + raise LobbyParticipantParsingError("Invalid participant data") from e + + +class LobbyService: + """Service for managing participant access through a lobby system. + + Handles participant entry requests, status management, and notifications + using cache for state management and LiveKit for real-time updates. + """ + + @staticmethod + def _get_cache_key(room_id: UUID, participant_id: str) -> str: + """Generate cache key for participant(s) data.""" + return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}" + + @staticmethod + def _get_or_create_participant_id(request) -> str: + """Extract unique participant identifier from the request.""" + return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, uuid.uuid4().hex) + + @staticmethod + def prepare_response(response, participant_id): + """Set participant cookie if needed.""" + if not response.cookies.get(settings.LOBBY_COOKIE_NAME): + response.set_cookie( + key=settings.LOBBY_COOKIE_NAME, + value=participant_id, + httponly=True, + secure=True, + samesite="Lax", + ) + + def request_entry( + self, + room, + request, + username: str, + ) -> Tuple[LobbyParticipant, Optional[Dict]]: + """Request entry to a room for a participant. + + This usual status transitions is: + UNKNOWN -> WAITING -> (ACCEPTED | DENIED) + + Flow: + 1. Check current status + 2. If waiting, refresh timeout to maintain position + 3. If unknown, add to waiting list + 4. If accepted, generate LiveKit config + 5. If denied, do nothing. + """ + + participant_id = self._get_or_create_participant_id(request) + participant = self._get_participant(room.id, participant_id) + + if room.is_public: + if participant is None: + participant = LobbyParticipant( + status=LobbyParticipantStatus.ACCEPTED, + username=username, + id=participant_id, + color=utils.generate_color(participant_id), + ) + else: + participant.status = LobbyParticipantStatus.ACCEPTED + + livekit_config = utils.generate_livekit_config( + room_id=str(room.id), + user=request.user, + username=username, + color=participant.color, + ) + return participant, livekit_config + + livekit_config = None + + if participant is None: + participant = self.enter(room.id, participant_id, username) + + elif participant.status == LobbyParticipantStatus.WAITING: + self.refresh_waiting_status(room.id, participant_id) + + elif participant.status == LobbyParticipantStatus.ACCEPTED: + # wrongly named, contains access token to join a room + livekit_config = utils.generate_livekit_config( + room_id=str(room.id), + user=request.user, + username=username, + color=participant.color, + ) + + return participant, livekit_config + + def refresh_waiting_status(self, room_id: UUID, participant_id: str): + """Refresh timeout for waiting participant. + + Extends the waiting period for a participant to maintain their position + in the lobby queue. Automatic removal if the participant is not + actively checking their status. + """ + cache.touch( + self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT + ) + + def enter( + self, room_id: UUID, participant_id: str, username: str + ) -> LobbyParticipant: + """Add participant to waiting lobby. + + Create a new participant entry in waiting status and notify room + participants of the new entry request. + + Raises: + LobbyNotificationError: If room notification fails + """ + + color = utils.generate_color(participant_id) + + participant = LobbyParticipant( + status=LobbyParticipantStatus.WAITING, + username=username, + id=participant_id, + color=color, + ) + + cache_key = self._get_cache_key(room_id, participant_id) + cache.set( + cache_key, + participant.to_dict(), + timeout=settings.LOBBY_WAITING_TIMEOUT, + ) + + try: + self.notify_participants(room_id=room_id) + except LobbyNotificationError: + # If room not created yet, there is no participants to notify + pass + + return participant + + def _get_participant( + self, room_id: UUID, participant_id: str + ) -> Optional[LobbyParticipant]: + """Check participant's current status in the lobby.""" + + cache_key = self._get_cache_key(room_id, participant_id) + data = cache.get(cache_key) + + if not data: + return None + + try: + return LobbyParticipant.from_dict(data) + except LobbyParticipantParsingError: + logger.error("Corrupted participant data found and removed: %s", cache_key) + cache.delete(cache_key) + return None + + def list_waiting_participants(self, room_id: UUID) -> List[dict]: + """List all waiting participants for a room.""" + + pattern = self._get_cache_key(room_id, "*") + keys = cache.keys(pattern) + + if not keys: + return [] + + data = cache.get_many(keys) + + waiting_participants = [] + for cache_key, raw_participant in data.items(): + try: + participant = LobbyParticipant.from_dict(raw_participant) + except LobbyParticipantParsingError: + cache.delete(cache_key) + continue + if participant.status == LobbyParticipantStatus.WAITING: + waiting_participants.append(participant.to_dict()) + + return waiting_participants + + def handle_participant_entry( + self, + room_id: UUID, + participant_id: str, + allow_entry: bool, + ) -> None: + """Handle decision on participant entry. + + Updates participant status based on allow_entry: + - If accepted: ACCEPTED status with extended timeout matching LiveKit token + - If denied: DENIED status with short timeout allowing status check and retry + """ + if allow_entry: + decision = { + "status": LobbyParticipantStatus.ACCEPTED, + "timeout": settings.LOBBY_ACCEPTED_TIMEOUT, + } + else: + decision = { + "status": LobbyParticipantStatus.DENIED, + "timeout": settings.LOBBY_DENIED_TIMEOUT, + } + + self._update_participant_status(room_id, participant_id, **decision) + + def _update_participant_status( + self, + room_id: UUID, + participant_id: str, + status: LobbyParticipantStatus, + timeout: int, + ) -> None: + """Update participant status with appropriate timeout.""" + + cache_key = self._get_cache_key(room_id, participant_id) + + data = cache.get(cache_key) + if not data: + logger.error("Participant %s not found", participant_id) + raise LobbyParticipantNotFound("Participant not found") + + try: + participant = LobbyParticipant.from_dict(data) + except LobbyParticipantParsingError: + logger.exception( + "Removed corrupted data for participant %s:", participant_id + ) + cache.delete(cache_key) + raise + + participant.status = status + cache.set(cache_key, participant.to_dict(), timeout=timeout) + + @async_to_sync + async def notify_participants(self, room_id: UUID): + """Notify room participants about a new waiting participant using LiveKit. + + Raises: + LobbyNotificationError: If notification fails to send + """ + + notification_data = { + "type": settings.LOBBY_NOTIFICATION_TYPE, + } + + lkapi = LiveKitAPI(**settings.LIVEKIT_CONFIGURATION) + try: + await lkapi.room.send_data( + SendDataRequest( + room=str(room_id), + data=json.dumps(notification_data).encode("utf-8"), + kind="RELIABLE", + ) + ) + except TwirpError as e: + logger.exception("Failed to notify room participants") + raise LobbyNotificationError("Failed to notify room participants") from e + finally: + await lkapi.aclose() diff --git a/src/backend/core/tests/rooms/test_api_rooms_lobby.py b/src/backend/core/tests/rooms/test_api_rooms_lobby.py new file mode 100644 index 00000000..d7442734 --- /dev/null +++ b/src/backend/core/tests/rooms/test_api_rooms_lobby.py @@ -0,0 +1,624 @@ +""" +Test rooms API endpoints in the Meet core app: lobby functionality. +""" + +# pylint: disable=W0621,W0613,W0212 +import uuid +from unittest import mock + +from django.core.cache import cache + +import pytest +from rest_framework.test import APIClient + +from ... import utils +from ...factories import RoomFactory, UserFactory +from ...models import RoomAccessLevel +from ...services.lobby_service import ( + LobbyService, +) + +pytestmark = pytest.mark.django_db + + +# Tests for request_entry endpoint + + +def test_request_entry_anonymous(settings): + """Anonymous users should be allowed to request entry to a room.""" + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) + client = APIClient() + + settings.LOBBY_COOKIE_NAME = "mocked-cookie" + settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" + + # Lobby cache should be empty before the request + lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*") + assert not lobby_keys + + with ( + mock.patch.object(LobbyService, "notify_participants", return_value=None), + mock.patch.object(utils, "generate_color", return_value="mocked-color"), + ): + response = client.post( + f"/api/v1.0/rooms/{room.id}/request-entry/", + {"username": "test_user"}, + ) + + assert response.status_code == 200 + + # Verify the lobby cookie was properly set + cookie = response.cookies.get("mocked-cookie") + assert cookie is not None + + participant_id = cookie.value + + # Verify response content matches expected structure and values + assert response.json() == { + "id": participant_id, + "username": "test_user", + "status": "waiting", + "color": "mocked-color", + "livekit": None, + } + + # Verify a participant was stored in cache + lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*") + assert len(lobby_keys) == 1 + + # Verify participant data was correctly stored in cache + participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}") + assert participant_data.get("username") == "test_user" + + +def test_request_entry_authenticated_user(settings): + """Authenticated users should be allowed to request entry.""" + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) + user = UserFactory() + client = APIClient() + client.force_login(user) + + settings.LOBBY_COOKIE_NAME = "mocked-cookie" + settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" + + # Lobby cache should be empty before the request + lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*") + assert not lobby_keys + + with ( + mock.patch.object(LobbyService, "notify_participants", return_value=None), + mock.patch.object(utils, "generate_color", return_value="mocked-color"), + ): + response = client.post( + f"/api/v1.0/rooms/{room.id}/request-entry/", + {"username": "test_user"}, + ) + + assert response.status_code == 200 + + # Verify the lobby cookie was properly set + cookie = response.cookies.get("mocked-cookie") + assert cookie is not None + + participant_id = cookie.value + + # Verify response content matches expected structure and values + assert response.json() == { + "id": participant_id, + "username": "test_user", + "status": "waiting", + "color": "mocked-color", + "livekit": None, + } + + # Verify a participant was stored in cache + lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*") + assert len(lobby_keys) == 1 + + # Verify participant data was correctly stored in cache + participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}") + assert participant_data.get("username") == "test_user" + + +def test_request_entry_with_existing_participants(settings): + """Anonymous users should be allowed to request entry to a room with existing participants.""" + # Create a restricted access room + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) + client = APIClient() + + # Configure test settings for cookies and cache + settings.LOBBY_COOKIE_NAME = "mocked-cookie" + settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" + + # Add two participants already waiting in the lobby + cache.set( + f"mocked-cache-prefix_{room.id}_participant1", + { + "id": "participant1", + "username": "user1", + "status": "waiting", + "color": "#123456", + }, + ) + cache.set( + f"mocked-cache-prefix_{room.id}_participant2", + { + "id": "participant2", + "username": "user2", + "status": "accepted", + "color": "#654321", + }, + ) + + # Verify two participants are in the lobby before the request + lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*") + assert len(lobby_keys) == 2 + + # Mock external service calls to isolate the test + with ( + mock.patch.object(LobbyService, "notify_participants", return_value=None), + mock.patch.object(utils, "generate_color", return_value="mocked-color"), + ): + # Make request as a new anonymous user + response = client.post( + f"/api/v1.0/rooms/{room.id}/request-entry/", + {"username": "test_user"}, + ) + + # Verify successful response + assert response.status_code == 200 + + # Verify the lobby cookie was properly set for the new participant + cookie = response.cookies.get("mocked-cookie") + assert cookie is not None + + participant_id = cookie.value + + # Verify response content matches expected structure and values + assert response.json() == { + "id": participant_id, + "username": "test_user", + "status": "waiting", + "color": "mocked-color", + "livekit": None, + } + + # Verify now three participants are in the lobby cache + lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*") + assert len(lobby_keys) == 3 + + # Verify the new participant data was correctly stored in cache + participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}") + assert participant_data.get("username") == "test_user" + + +def test_request_entry_public_room(settings): + """Entry requests to public rooms should return ACCEPTED status with LiveKit config.""" + room = RoomFactory(access_level=RoomAccessLevel.PUBLIC) + client = APIClient() + + settings.LOBBY_COOKIE_NAME = "mocked-cookie" + settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" + + # Lobby cache should be empty before the request + lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*") + assert not lobby_keys + + with ( + mock.patch.object(LobbyService, "notify_participants", return_value=None), + mock.patch.object( + LobbyService, "_get_or_create_participant_id", return_value="123" + ), + mock.patch.object( + utils, "generate_livekit_config", return_value={"token": "test-token"} + ), + mock.patch.object(utils, "generate_color", return_value="mocked-color"), + ): + response = client.post( + f"/api/v1.0/rooms/{room.id}/request-entry/", + {"username": "test_user"}, + ) + + assert response.status_code == 200 + + # Verify the lobby cookie was set + cookie = response.cookies.get("mocked-cookie") + assert cookie is not None + assert cookie.value == "123" + + # Verify response content matches expected structure and values + assert response.json() == { + "id": "123", + "username": "test_user", + "status": "accepted", + "color": "mocked-color", + "livekit": {"token": "test-token"}, + } + + # Verify lobby cache is still empty after the request + lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*") + assert not lobby_keys + + +def test_request_entry_authenticated_user_public_room(settings): + """While authenticated, entry request to public rooms should get accepted.""" + room = RoomFactory(access_level=RoomAccessLevel.PUBLIC) + user = UserFactory() + client = APIClient() + client.force_login(user) + + settings.LOBBY_COOKIE_NAME = "mocked-cookie" + settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" + + # Lobby cache should be empty before the request + lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*") + assert not lobby_keys + + with ( + mock.patch.object(LobbyService, "notify_participants", return_value=None), + mock.patch.object( + LobbyService, "_get_or_create_participant_id", return_value="123" + ), + mock.patch.object( + utils, "generate_livekit_config", return_value={"token": "test-token"} + ), + mock.patch.object(utils, "generate_color", return_value="mocked-color"), + ): + response = client.post( + f"/api/v1.0/rooms/{room.id}/request-entry/", + {"username": "test_user"}, + ) + + assert response.status_code == 200 + + # Verify the lobby cookie was set + cookie = response.cookies.get("mocked-cookie") + assert cookie is not None + assert cookie.value == "123" + + # Verify response content matches expected structure and values + assert response.json() == { + "id": "123", + "username": "test_user", + "status": "accepted", + "color": "mocked-color", + "livekit": {"token": "test-token"}, + } + + # Verify lobby cache is still empty after the request + lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*") + assert not lobby_keys + + +def test_request_entry_waiting_participant_public_room(settings): + """While waiting, entry request to public rooms should get accepted.""" + room = RoomFactory(access_level=RoomAccessLevel.PUBLIC) + client = APIClient() + + settings.LOBBY_COOKIE_NAME = "mocked-cookie" + settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" + + # Add a waiting participant to the room's lobby cache + cache.set( + f"mocked-cache-prefix_{room.id}_participant1", + { + "id": "participant1", + "username": "user1", + "status": "waiting", + "color": "#123456", + }, + ) + + # Simulate a browser with existing participant cookie + client.cookies.load({"mocked-cookie": "participant1"}) + + with ( + mock.patch.object(LobbyService, "notify_participants", return_value=None), + mock.patch.object( + utils, "generate_livekit_config", return_value={"token": "test-token"} + ), + ): + response = client.post( + f"/api/v1.0/rooms/{room.id}/request-entry/", + {"username": "user1"}, + ) + + assert response.status_code == 200 + + # Verify the lobby cookie was set + cookie = response.cookies.get("mocked-cookie") + assert cookie is not None + assert cookie.value == "participant1" + + # Verify response content matches expected structure and values + assert response.json() == { + "id": "participant1", + "username": "user1", + "status": "accepted", + "color": "#123456", + "livekit": {"token": "test-token"}, + } + + # Verify participant remains in the lobby cache after acceptance + lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*") + assert len(lobby_keys) == 1 + + +def test_request_entry_invalid_data(): + """Should return 400 for invalid request data.""" + room = RoomFactory() + client = APIClient() + + response = client.post( + f"/api/v1.0/rooms/{room.id}/request-entry/", + {}, # Missing required username field + ) + + assert response.status_code == 400 + + +def test_request_entry_room_not_found(): + """Should return 404 for non-existent room.""" + client = APIClient() + + response = client.post( + f"/api/v1.0/rooms/{uuid.uuid4()!s}/request-entry/", + {"username": "anonymous"}, + ) + + assert response.status_code == 404 + + +# Tests for allow_participant_to_enter endpoint + + +def test_allow_participant_to_enter_anonymous(): + """Anonymous users should not be allowed to manage entry requests.""" + room = RoomFactory() + client = APIClient() + + response = client.post( + f"/api/v1.0/rooms/{room.id}/enter/", + {"participant_id": "test-id", "allow_entry": True}, + ) + + assert response.status_code == 401 + + +def test_allow_participant_to_enter_non_owner(): + """Non-privileged users should not be allowed to manage entry requests.""" + room = RoomFactory() + user = UserFactory() + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/rooms/{room.id}/enter/", + {"participant_id": "test-id", "allow_entry": True}, + ) + + assert response.status_code == 403 + + +def test_allow_participant_to_enter_public_room(): + """Should return 404 for public rooms that don't use the lobby system.""" + room = RoomFactory(access_level=RoomAccessLevel.PUBLIC) + user = UserFactory() + # Make user the room owner + room.accesses.create(user=user, role="owner") + + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/rooms/{room.id}/enter/", + {"participant_id": "test-id", "allow_entry": True}, + ) + + assert response.status_code == 404 + assert response.json() == {"message": "Room has no lobby system."} + + +@pytest.mark.parametrize( + "allow_entry, updated_status", [(True, "accepted"), (False, "denied")] +) +def test_allow_participant_to_enter_success(settings, allow_entry, updated_status): + """Should successfully update participant status when everything is correct.""" + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) + user = UserFactory() + # Make user the room owner + room.accesses.create(user=user, role="owner") + + client = APIClient() + client.force_login(user) + + settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" + + cache.set( + f"mocked-cache-prefix_{room.id!s}_participant1", + { + "id": "test-id", + "status": "waiting", + "username": "foo", + "color": "123", + }, + ) + + response = client.post( + f"/api/v1.0/rooms/{room.id}/enter/", + {"participant_id": "participant1", "allow_entry": allow_entry}, + ) + + assert response.status_code == 200 + assert response.json() == {"message": "Participant was updated."} + + participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_participant1") + assert participant_data.get("status") == updated_status + + +def test_allow_participant_to_enter_participant_not_found(settings): + """Should handle case when participant is not found.""" + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) + user = UserFactory() + # Make user the room owner + room.accesses.create(user=user, role="owner") + + client = APIClient() + client.force_login(user) + + settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" + + participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_test-id") + assert participant_data is None + + response = client.post( + f"/api/v1.0/rooms/{room.id}/enter/", + {"participant_id": "test-id", "allow_entry": True}, + ) + + assert response.status_code == 404 + assert response.json() == {"message": "Participant not found."} + + +def test_allow_participant_to_enter_invalid_data(): + """Should return 400 for invalid request data.""" + room = RoomFactory() + user = UserFactory() + # Make user the room owner + room.accesses.create(user=user, role="owner") + + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/rooms/{room.id}/enter/", + {}, # Missing required fields + ) + + assert response.status_code == 400 + + +# Tests for list_waiting_participants endpoint + + +def test_list_waiting_participants_anonymous(): + """Anonymous users should not be allowed to list waiting participants.""" + room = RoomFactory() + client = APIClient() + + response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/") + + assert response.status_code == 401 + + +def test_list_waiting_participants_non_owner(): + """Non-privileged users should not be allowed to list waiting participants.""" + room = RoomFactory() + user = UserFactory() + client = APIClient() + client.force_login(user) + + response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/") + + assert response.status_code == 403 + + +def test_list_waiting_participants_public_room(): + """Should return empty list for public rooms.""" + room = RoomFactory(access_level=RoomAccessLevel.PUBLIC) + user = UserFactory() + # Make user the room owner + room.accesses.create(user=user, role="owner") + + client = APIClient() + client.force_login(user) + + # Lobby cache should be empty before the request + lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*") + assert not lobby_keys + + with mock.patch( + "core.api.viewsets.LobbyService", autospec=True + ) as mocked_lobby_service: + response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/") + + # Verify lobby service was not instantiated + mocked_lobby_service.assert_not_called() + + assert response.status_code == 200 + assert response.json() == {"participants": []} + + +def test_list_waiting_participants_success(settings): + """Should successfully return list of waiting participants.""" + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) + user = UserFactory() + # Make user the room owner + room.accesses.create(user=user, role="owner") + + client = APIClient() + client.force_login(user) + + settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" + + # Add participants in the lobby + cache.set( + f"mocked-cache-prefix_{room.id}_participant1", + { + "id": "participant1", + "username": "user1", + "status": "waiting", + "color": "#123456", + }, + ) + cache.set( + f"mocked-cache-prefix_{room.id}_participant2", + { + "id": "participant2", + "username": "user2", + "status": "waiting", + "color": "#654321", + }, + ) + + response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/") + + assert response.status_code == 200 + + participants = response.json().get("participants") + assert sorted(participants, key=lambda p: p["id"]) == [ + { + "id": "participant1", + "username": "user1", + "status": "waiting", + "color": "#123456", + }, + { + "id": "participant2", + "username": "user2", + "status": "waiting", + "color": "#654321", + }, + ] + + +def test_list_waiting_participants_empty(settings): + """Should handle case when there are no waiting participants.""" + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) + user = UserFactory() + # Make user the room owner + room.accesses.create(user=user, role="owner") + + client = APIClient() + client.force_login(user) + + settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" + + # Lobby cache should be empty before the request + lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*") + assert not lobby_keys + + response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/") + + assert response.status_code == 200 + assert response.json() == {"participants": []} diff --git a/src/backend/core/tests/rooms/test_api_rooms_retrieve.py b/src/backend/core/tests/rooms/test_api_rooms_retrieve.py index 28aa9a61..7d3a8c28 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_retrieve.py +++ b/src/backend/core/tests/rooms/test_api_rooms_retrieve.py @@ -235,7 +235,9 @@ def test_api_rooms_retrieve_authenticated_public(mock_token): "slug": room.slug, } - mock_token.assert_called_once_with(room=expected_name, user=user, username=None) + mock_token.assert_called_once_with( + room=expected_name, user=user, username=None, color=None + ) def test_api_rooms_retrieve_authenticated(): @@ -337,7 +339,9 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries): "slug": room.slug, } - mock_token.assert_called_once_with(room=expected_name, user=user, username=None) + mock_token.assert_called_once_with( + room=expected_name, user=user, username=None, color=None + ) @mock.patch("core.utils.generate_token", return_value="foo") @@ -414,4 +418,6 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries "slug": room.slug, } - mock_token.assert_called_once_with(room=expected_name, user=user, username=None) + mock_token.assert_called_once_with( + room=expected_name, user=user, username=None, color=None + ) diff --git a/src/backend/core/tests/services/test_lobby_service.py b/src/backend/core/tests/services/test_lobby_service.py new file mode 100644 index 00000000..0fd66386 --- /dev/null +++ b/src/backend/core/tests/services/test_lobby_service.py @@ -0,0 +1,756 @@ +""" +Test lobby service. +""" + +# pylint: disable=W0621,W0613, W0212, R0913 +# ruff: noqa: PLR0913 + +import json +from unittest import mock +from uuid import UUID + +from django.conf import settings +from django.http import HttpResponse + +import pytest +from livekit.api import TwirpError + +from core.services.lobby_service import ( + LobbyNotificationError, + LobbyParticipant, + LobbyParticipantNotFound, + LobbyParticipantParsingError, + LobbyParticipantStatus, + LobbyService, +) + + +@pytest.fixture +def lobby_service(): + """Return a LobbyService instance.""" + return LobbyService() + + +@pytest.fixture +def room_id(): + """Return a UUID for test room.""" + return UUID("12345678-1234-5678-1234-567812345678") + + +@pytest.fixture +def participant_id(): + """Return a string ID for test participant.""" + return "test-participant-id" + + +@pytest.fixture +def username(): + """Return a username for test participant.""" + return "test-username" + + +@pytest.fixture +def participant_dict(): + """Return a valid participant dictionary.""" + return { + "status": "waiting", + "username": "test-username", + "id": "test-participant-id", + "color": "#123456", + } + + +@pytest.fixture +def participant_data(): + """Return a valid LobbyParticipant instance.""" + return LobbyParticipant( + status=LobbyParticipantStatus.WAITING, + username="test-username", + id="test-participant-id", + color="#123456", + ) + + +def test_lobby_participant_to_dict(participant_data): + """Test LobbyParticipant serialization to dict.""" + result = participant_data.to_dict() + + assert result["status"] == "waiting" + assert result["username"] == "test-username" + assert result["id"] == "test-participant-id" + assert result["color"] == "#123456" + + +def test_lobby_participant_from_dict_success(participant_dict): + """Test successful LobbyParticipant creation from dict.""" + participant = LobbyParticipant.from_dict(participant_dict) + + assert participant.status == LobbyParticipantStatus.WAITING + assert participant.username == "test-username" + assert participant.id == "test-participant-id" + assert participant.color == "#123456" + + +def test_lobby_participant_from_dict_default_status(): + """Test LobbyParticipant creation with missing status defaults to UNKNOWN.""" + data_without_status = { + "username": "test-username", + "id": "test-participant-id", + "color": "#123456", + } + + participant = LobbyParticipant.from_dict(data_without_status) + + assert participant.status == LobbyParticipantStatus.UNKNOWN + assert participant.username == "test-username" + assert participant.id == "test-participant-id" + assert participant.color == "#123456" + + +def test_lobby_participant_from_dict_missing_fields(): + """Test LobbyParticipant creation with missing fields.""" + invalid_data = {"username": "test-username"} + + with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"): + LobbyParticipant.from_dict(invalid_data) + + +def test_lobby_participant_from_dict_invalid_status(): + """Test LobbyParticipant creation with invalid status.""" + invalid_data = { + "status": "invalid_status", + "username": "test-username", + "id": "test-participant-id", + "color": "#123456", + } + + with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"): + LobbyParticipant.from_dict(invalid_data) + + +def test_get_cache_key(lobby_service, room_id, participant_id): + """Test cache key generation.""" + cache_key = lobby_service._get_cache_key(room_id, participant_id) + + expected_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}" + assert cache_key == expected_key + + +def test_get_or_create_participant_id_from_cookie(lobby_service): + """Test extracting participant ID from cookie.""" + request = mock.Mock() + request.COOKIES = {settings.LOBBY_COOKIE_NAME: "existing-id"} + + participant_id = lobby_service._get_or_create_participant_id(request) + + assert participant_id == "existing-id" + + +@mock.patch("uuid.uuid4") +def test_get_or_create_participant_id_new(mock_uuid4, lobby_service): + """Test creating new participant ID when cookie is missing.""" + mock_uuid4.return_value = mock.Mock(hex="generated-id") + request = mock.Mock() + request.COOKIES = {} + + participant_id = lobby_service._get_or_create_participant_id(request) + + assert participant_id == "generated-id" + mock_uuid4.assert_called_once() + + +def test_prepare_response_existing_cookie(lobby_service, participant_id): + """Test response preparation with existing cookie.""" + response = HttpResponse() + response.cookies[settings.LOBBY_COOKIE_NAME] = "existing-cookie" + + lobby_service.prepare_response(response, participant_id) + + # Verify cookie wasn't set again + cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME) + assert cookie.value == "existing-cookie" + assert cookie.value != participant_id + + +def test_prepare_response_new_cookie(lobby_service, participant_id): + """Test response preparation with new cookie.""" + response = HttpResponse() + + lobby_service.prepare_response(response, participant_id) + + # Verify cookie was set + cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME) + assert cookie is not None + assert cookie.value == participant_id + assert cookie["httponly"] is True + assert cookie["secure"] is True + assert cookie["samesite"] == "Lax" + + # It's a session cookies (no max_age specified): + assert not cookie["max-age"] + + +@mock.patch("core.utils.generate_livekit_config") +def test_request_entry_public_room( + mock_generate_config, lobby_service, room_id, participant_id, username +): + """Test requesting entry to a public room.""" + request = mock.Mock() + request.user = mock.Mock() + + room = mock.Mock() + room.id = room_id + room.is_public = True + + mocked_participant = LobbyParticipant( + status=LobbyParticipantStatus.UNKNOWN, + username=username, + id=participant_id, + color="#123456", + ) + + lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id) + lobby_service._get_participant = mock.Mock(return_value=mocked_participant) + mock_generate_config.return_value = {"token": "test-token"} + + participant, livekit_config = lobby_service.request_entry(room, request, username) + + assert participant.status == LobbyParticipantStatus.ACCEPTED + assert livekit_config == {"token": "test-token"} + mock_generate_config.assert_called_once_with( + room_id=str(room_id), + user=request.user, + username=username, + color=participant.color, + ) + + lobby_service._get_participant.assert_called_once_with(room_id, participant_id) + + +@mock.patch("core.services.lobby_service.LobbyService.enter") +def test_request_entry_new_participant( + mock_enter, lobby_service, room_id, participant_id, username +): + """Test requesting entry for a new participant.""" + request = mock.Mock() + request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id} + + room = mock.Mock() + room.id = room_id + room.is_public = False + + lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id) + lobby_service._get_participant = mock.Mock(return_value=None) + + participant_data = LobbyParticipant( + status=LobbyParticipantStatus.WAITING, + username=username, + id=participant_id, + color="#123456", + ) + mock_enter.return_value = participant_data + + participant, livekit_config = lobby_service.request_entry(room, request, username) + + assert participant == participant_data + assert livekit_config is None + mock_enter.assert_called_once_with(room_id, participant_id, username) + lobby_service._get_participant.assert_called_once_with(room_id, participant_id) + + +@mock.patch("core.services.lobby_service.LobbyService.refresh_waiting_status") +def test_request_entry_waiting_participant( + mock_refresh, lobby_service, room_id, participant_id, username +): + """Test requesting entry for a waiting participant.""" + request = mock.Mock() + request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id} + + room = mock.Mock() + room.id = room_id + room.is_public = False + + mocked_participant = LobbyParticipant( + status=LobbyParticipantStatus.WAITING, + username=username, + id=participant_id, + color="#123456", + ) + lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id) + lobby_service._get_participant = mock.Mock(return_value=mocked_participant) + + participant, livekit_config = lobby_service.request_entry(room, request, username) + + assert participant.status == LobbyParticipantStatus.WAITING + assert livekit_config is None + mock_refresh.assert_called_once_with(room_id, participant_id) + lobby_service._get_participant.assert_called_once_with(room_id, participant_id) + + +@mock.patch("core.utils.generate_livekit_config") +def test_request_entry_accepted_participant( + mock_generate_config, lobby_service, room_id, participant_id, username +): + """Test requesting entry for an accepted participant.""" + request = mock.Mock() + request.user = mock.Mock() + request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id} + + room = mock.Mock() + room.id = room_id + room.is_public = False + + mocked_participant = LobbyParticipant( + status=LobbyParticipantStatus.ACCEPTED, + username=username, + id=participant_id, + color="#123456", + ) + lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id) + lobby_service._get_participant = mock.Mock(return_value=mocked_participant) + + mock_generate_config.return_value = {"token": "test-token"} + + participant, livekit_config = lobby_service.request_entry(room, request, username) + + assert participant.status == LobbyParticipantStatus.ACCEPTED + assert livekit_config == {"token": "test-token"} + mock_generate_config.assert_called_once_with( + room_id=str(room_id), + user=request.user, + username=username, + color="#123456", + ) + lobby_service._get_participant.assert_called_once_with(room_id, participant_id) + + +@mock.patch("core.services.lobby_service.cache") +def test_refresh_waiting_status(mock_cache, lobby_service, room_id, participant_id): + """Test refreshing waiting status for a participant.""" + lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key") + lobby_service.refresh_waiting_status(room_id, participant_id) + mock_cache.touch.assert_called_once_with( + "mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT + ) + + +# pylint: disable=R0917 +@mock.patch("core.services.lobby_service.cache") +@mock.patch("core.utils.generate_color") +@mock.patch("core.services.lobby_service.LobbyService.notify_participants") +def test_enter_success( + mock_notify, + mock_generate_color, + mock_cache, + lobby_service, + room_id, + participant_id, + username, +): + """Test successful participant entry.""" + mock_generate_color.return_value = "#123456" + lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key") + + participant = lobby_service.enter(room_id, participant_id, username) + + mock_generate_color.assert_called_once_with(participant_id) + assert participant.status == LobbyParticipantStatus.WAITING + assert participant.username == username + assert participant.id == participant_id + assert participant.color == "#123456" + + lobby_service._get_cache_key.assert_called_once_with(room_id, participant_id) + + mock_cache.set.assert_called_once_with( + "mocked_cache_key", + participant.to_dict(), + timeout=settings.LOBBY_WAITING_TIMEOUT, + ) + mock_notify.assert_called_once_with(room_id=room_id) + + +# pylint: disable=R0917 +@mock.patch("core.services.lobby_service.cache") +@mock.patch("core.utils.generate_color") +@mock.patch("core.services.lobby_service.LobbyService.notify_participants") +def test_enter_with_notification_error( + mock_notify, + mock_generate_color, + mock_cache, + lobby_service, + room_id, + participant_id, + username, +): + """Test participant entry with notification error.""" + mock_generate_color.return_value = "#123456" + mock_notify.side_effect = LobbyNotificationError("Error notifying") + lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key") + + participant = lobby_service.enter(room_id, participant_id, username) + + mock_generate_color.assert_called_once_with(participant_id) + assert participant.status == LobbyParticipantStatus.WAITING + assert participant.username == username + + lobby_service._get_cache_key.assert_called_once_with(room_id, participant_id) + + mock_cache.set.assert_called_once_with( + "mocked_cache_key", + participant.to_dict(), + timeout=settings.LOBBY_WAITING_TIMEOUT, + ) + + +@mock.patch("core.services.lobby_service.cache") +def test_get_participant_not_found(mock_cache, lobby_service, room_id, participant_id): + """Test getting a participant that doesn't exist.""" + mock_cache.get.return_value = None + lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key") + + result = lobby_service._get_participant(room_id, participant_id) + + assert result is None + + lobby_service._get_cache_key.assert_called_once_with(room_id, participant_id) + mock_cache.get.assert_called_once_with("mocked_cache_key") + + +@mock.patch("core.services.lobby_service.cache") +@mock.patch("core.services.lobby_service.LobbyParticipant.from_dict") +def test_get_participant_parsing_error( + mock_from_dict, mock_cache, lobby_service, room_id, participant_id +): + """Test handling corrupted participant data.""" + mock_cache.get.return_value = {"some": "data"} + lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key") + mock_from_dict.side_effect = LobbyParticipantParsingError("Invalid data") + + result = lobby_service._get_participant(room_id, participant_id) + + assert result is None + lobby_service._get_cache_key.assert_called_once_with(room_id, participant_id) + mock_cache.delete.assert_called_once_with("mocked_cache_key") + + +@mock.patch("core.services.lobby_service.cache") +def test_list_waiting_participants_empty(mock_cache, lobby_service, room_id): + """Test listing waiting participants when none exist.""" + mock_cache.keys.return_value = [] + + result = lobby_service.list_waiting_participants(room_id) + + assert result == [] + pattern = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_*" + mock_cache.keys.assert_called_once_with(pattern) + mock_cache.get_many.assert_not_called() + + +@mock.patch("core.services.lobby_service.cache") +def test_list_waiting_participants( + mock_cache, lobby_service, room_id, participant_dict +): + """Test listing waiting participants with valid data.""" + cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_participant1" + mock_cache.keys.return_value = [cache_key] + mock_cache.get_many.return_value = {cache_key: participant_dict} + + result = lobby_service.list_waiting_participants(room_id) + + assert len(result) == 1 + assert result[0]["status"] == "waiting" + assert result[0]["username"] == "test-username" + pattern = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_*" + mock_cache.keys.assert_called_once_with(pattern) + mock_cache.get_many.assert_called_once_with([cache_key]) + + +@mock.patch("core.services.lobby_service.cache") +def test_list_waiting_participants_multiple(mock_cache, lobby_service, room_id): + """Test listing multiple waiting participants with valid data.""" + cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_participant1" + cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_participant2" + + participant1 = { + "status": "waiting", + "username": "user1", + "id": "participant1", + "color": "#123456", + } + + participant2 = { + "status": "waiting", + "username": "user2", + "id": "participant2", + "color": "#654321", + } + + mock_cache.keys.return_value = [cache_key1, cache_key2] + mock_cache.get_many.return_value = { + cache_key1: participant1, + cache_key2: participant2, + } + + result = lobby_service.list_waiting_participants(room_id) + + assert len(result) == 2 + + # Verify both participants are in the result + assert any(p["id"] == "participant1" and p["username"] == "user1" for p in result) + assert any(p["id"] == "participant2" and p["username"] == "user2" for p in result) + + # Verify all participants have waiting status + assert all(p["status"] == "waiting" for p in result) + + pattern = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_*" + mock_cache.keys.assert_called_once_with(pattern) + mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2]) + + +@mock.patch("core.services.lobby_service.cache") +def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service, room_id): + """Test listing waiting participants with corrupted data.""" + cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_participant1" + mock_cache.keys.return_value = [cache_key] + mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}} + + result = lobby_service.list_waiting_participants(room_id) + + assert result == [] + mock_cache.delete.assert_called_once_with(cache_key) + + +@mock.patch("core.services.lobby_service.cache") +def test_list_waiting_participants_partially_corrupted( + mock_cache, lobby_service, room_id +): + """Test listing waiting participants with one valid and one corrupted entry.""" + cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_participant1" + cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_participant2" + + valid_participant = { + "status": "waiting", + "username": "user2", + "id": "participant2", + "color": "#654321", + } + + corrupted_participant = {"invalid": "data"} + + mock_cache.keys.return_value = [cache_key1, cache_key2] + mock_cache.get_many.return_value = { + cache_key1: corrupted_participant, + cache_key2: valid_participant, + } + + result = lobby_service.list_waiting_participants(room_id) + + # Check that only the valid participant is returned + assert len(result) == 1 + assert result[0]["id"] == "participant2" + assert result[0]["status"] == "waiting" + assert result[0]["username"] == "user2" + + # Verify corrupted entry was deleted + mock_cache.delete.assert_called_once_with(cache_key1) + + # Verify both cache keys were queried + pattern = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_*" + mock_cache.keys.assert_called_once_with(pattern) + mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2]) + + +@mock.patch("core.services.lobby_service.cache") +def test_list_waiting_participants_non_waiting(mock_cache, lobby_service, room_id): + """Test listing only waiting participants (not accepted/denied).""" + cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_participant1" + cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_participant2" + + participant1 = { + "status": "waiting", + "username": "user1", + "id": "participant1", + "color": "#123456", + } + participant2 = { + "status": "accepted", + "username": "user2", + "id": "participant2", + "color": "#654321", + } + + mock_cache.keys.return_value = [cache_key1, cache_key2] + mock_cache.get_many.return_value = { + cache_key1: participant1, + cache_key2: participant2, + } + + result = lobby_service.list_waiting_participants(room_id) + + assert len(result) == 1 + assert result[0]["id"] == "participant1" + assert result[0]["status"] == "waiting" + + +@mock.patch("core.services.lobby_service.LobbyService._update_participant_status") +def test_handle_participant_entry_allow( + mock_update, lobby_service, room_id, participant_id +): + """Test handling allowed participant entry.""" + lobby_service.handle_participant_entry(room_id, participant_id, allow_entry=True) + + mock_update.assert_called_once_with( + room_id, + participant_id, + status=LobbyParticipantStatus.ACCEPTED, + timeout=settings.LOBBY_ACCEPTED_TIMEOUT, + ) + + +@mock.patch("core.services.lobby_service.LobbyService._update_participant_status") +def test_handle_participant_entry_deny( + mock_update, lobby_service, room_id, participant_id +): + """Test handling denied participant entry.""" + lobby_service.handle_participant_entry(room_id, participant_id, allow_entry=False) + + mock_update.assert_called_once_with( + room_id, + participant_id, + status=LobbyParticipantStatus.DENIED, + timeout=settings.LOBBY_DENIED_TIMEOUT, + ) + + +@mock.patch("core.services.lobby_service.cache") +def test_update_participant_status_not_found( + mock_cache, lobby_service, room_id, participant_id +): + """Test updating status for non-existent participant.""" + mock_cache.get.return_value = None + lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key") + + with pytest.raises(LobbyParticipantNotFound, match="Participant not found"): + lobby_service._update_participant_status( + room_id, + participant_id, + status=LobbyParticipantStatus.ACCEPTED, + timeout=60, + ) + + lobby_service._get_cache_key.assert_called_once_with(room_id, participant_id) + mock_cache.get.assert_called_once_with("mocked_cache_key") + + +@mock.patch("core.services.lobby_service.cache") +@mock.patch("core.services.lobby_service.LobbyParticipant.from_dict") +def test_update_participant_status_corrupted_data( + mock_from_dict, mock_cache, lobby_service, room_id, participant_id +): + """Test updating status with corrupted participant data.""" + mock_cache.get.return_value = {"some": "data"} + mock_from_dict.side_effect = LobbyParticipantParsingError("Invalid data") + lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key") + + with pytest.raises(LobbyParticipantParsingError): + lobby_service._update_participant_status( + room_id, + participant_id, + status=LobbyParticipantStatus.ACCEPTED, + timeout=60, + ) + + mock_cache.delete.assert_called_once_with("mocked_cache_key") + lobby_service._get_cache_key.assert_called_once_with(room_id, participant_id) + + +@mock.patch("core.services.lobby_service.cache") +def test_update_participant_status_success( + mock_cache, lobby_service, room_id, participant_id +): + """Test successful participant status update.""" + participant_dict = { + "status": "waiting", + "username": "test-username", + "id": participant_id, + "color": "#123456", + } + + mock_cache.get.return_value = participant_dict + lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key") + + lobby_service._update_participant_status( + room_id, + participant_id, + status=LobbyParticipantStatus.ACCEPTED, + timeout=60, + ) + + expected_data = { + "status": "accepted", + "username": "test-username", + "id": participant_id, + "color": "#123456", + } + mock_cache.set.assert_called_once_with( + "mocked_cache_key", expected_data, timeout=60 + ) + lobby_service._get_cache_key.assert_called_once_with(room_id, participant_id) + + +@mock.patch("core.services.lobby_service.LiveKitAPI") +def test_notify_participants_success(mock_livekit_api, lobby_service, room_id): + """Test successful participant notification.""" + # Set up the mock LiveKitAPI and its behavior + mock_api_instance = mock.Mock() + mock_api_instance.room = mock.Mock() + mock_api_instance.room.send_data = mock.AsyncMock() + mock_api_instance.aclose = mock.AsyncMock() + mock_livekit_api.return_value = mock_api_instance + + # Call the function + lobby_service.notify_participants(room_id) + + # Verify the API was called correctly + mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION) + + # Verify the send_data method was called + mock_api_instance.room.send_data.assert_called_once() + send_data_request = mock_api_instance.room.send_data.call_args[0][0] + assert send_data_request.room == str(room_id) + assert ( + json.loads(send_data_request.data.decode("utf-8"))["type"] + == settings.LOBBY_NOTIFICATION_TYPE + ) + assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol + + # Verify aclose was called + mock_api_instance.aclose.assert_called_once() + + +@mock.patch("core.services.lobby_service.LiveKitAPI") +def test_notify_participants_error(mock_livekit_api, lobby_service, room_id): + """Test participant notification with API error.""" + # Set up the mock LiveKitAPI and its behavior + mock_api_instance = mock.Mock() + mock_api_instance.room = mock.Mock() + mock_api_instance.room.send_data = mock.AsyncMock( + side_effect=TwirpError(msg="test error", code=123) + ) + mock_api_instance.aclose = mock.AsyncMock() + mock_livekit_api.return_value = mock_api_instance + + # Call the function and expect an exception + with pytest.raises( + LobbyNotificationError, match="Failed to notify room participants" + ): + lobby_service.notify_participants(room_id) + + # Verify the API was called correctly + mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION) + + # Verify send_data was called + mock_api_instance.room.send_data.assert_called_once() + + # Verify aclose was still called after the exception + mock_api_instance.aclose.assert_called_once() diff --git a/src/backend/core/tests/test_models_rooms.py b/src/backend/core/tests/test_models_rooms.py index ad736eae..c282316f 100644 --- a/src/backend/core/tests/test_models_rooms.py +++ b/src/backend/core/tests/test_models_rooms.py @@ -164,3 +164,14 @@ def test_models_rooms_access_rights_owner_direct(django_assert_num_queries): assert room.is_administrator(user) is True with django_assert_num_queries(1): assert room.is_owner(user) is True + + +def test_models_rooms_is_public_property(): + """Test the is_public property returns correctly based on access_level.""" + # Test public room + public_room = RoomFactory(access_level=RoomAccessLevel.PUBLIC) + assert public_room.is_public is True + + # Test non-public room + private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) + assert private_room.is_public is False diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index 55eab530..4d1d3806 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -37,7 +37,9 @@ def generate_color(identity: str) -> str: return f"hsl({hue}, {saturation}%, {lightness}%)" -def generate_token(room: str, user, username: Optional[str] = None) -> str: +def generate_token( + room: str, user, username: Optional[str] = None, color: Optional[str] = None +) -> str: """Generate a LiveKit access token for a user in a specific room. Args: @@ -45,6 +47,8 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str: user (User): The user which request the access token. username (Optional[str]): The username to be displayed in the room. If none, a default value will be used. + color (Optional[str]): The color to be displayed in the room. + If none, a value will be generated Returns: str: The LiveKit JWT access token. @@ -69,6 +73,9 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str: identity = str(user.sub) default_username = str(user) + if color is None: + color = generate_color(identity) + token = ( AccessToken( api_key=settings.LIVEKIT_CONFIGURATION["api_key"], @@ -77,13 +84,15 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str: .with_grants(video_grants) .with_identity(identity) .with_name(username or default_username) - .with_metadata(json.dumps({"color": generate_color(identity)})) + .with_metadata(json.dumps({"color": color})) ) return token.to_jwt() -def generate_livekit_config(room_id: str, user, username: str) -> dict: +def generate_livekit_config( + room_id: str, user, username: str, color: Optional[str] = None +) -> dict: """Generate LiveKit configuration for room access. Args: @@ -97,5 +106,7 @@ def generate_livekit_config(room_id: str, user, username: str) -> dict: return { "url": settings.LIVEKIT_CONFIGURATION["url"], "room": room_id, - "token": generate_token(room=room_id, user=user, username=username), + "token": generate_token( + room=room_id, user=user, username=username, color=color + ), } diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index fbadeba2..f86eddf2 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -235,7 +235,17 @@ class Base(Configuration): # Cache CACHES = { - "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}, + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": values.Value( + "redis://redis:6379/1", + environ_name="REDIS_URL", + environ_prefix=None, + ), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, + }, } REST_FRAMEWORK = { @@ -252,6 +262,13 @@ class Base(Configuration): "PAGE_SIZE": 20, "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning", "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_THROTTLE_RATES": { + "request_entry": values.Value( + default="150/hour", + environ_name="REQUEST_ENTRY_THROTTLE_RATES", + environ_prefix=None, + ), + }, } SPECTACULAR_SETTINGS = { @@ -479,6 +496,32 @@ class Base(Configuration): ) BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True}) + # Lobby configurations + LOBBY_KEY_PREFIX = values.Value( + "room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None + ) + LOBBY_WAITING_TIMEOUT = values.PositiveIntegerValue( + 3, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None + ) + LOBBY_DENIED_TIMEOUT = values.PositiveIntegerValue( + 5, environ_name="LOBBY_DENIED_TIMEOUT", environ_prefix=None + ) + LOBBY_ACCEPTED_TIMEOUT = values.PositiveIntegerValue( + 21600, # 6hrs + environ_name="LOBBY_ACCEPTED_TIMEOUT", + environ_prefix=None, + ) + LOBBY_NOTIFICATION_TYPE = values.Value( + "participantWaiting", + environ_name="LOBBY_NOTIFICATION_TYPE", + environ_prefix=None, + ) + LOBBY_COOKIE_NAME = values.Value( + "lobbyParticipantId", + environ_name="LOBBY_COOKIE_NAME", + environ_prefix=None, + ) + # pylint: disable=invalid-name @property def ENVIRONMENT(self):