🚧(backend) introduce a lobby system

Implement lobby service using cache as LiveKit doesn't natively support
secure lobby functionality. Their teams recommended to create our own
system in our app's backend.

The lobby system is totally independant of the DRF session IDs,
making the request_entry endpoint authentication agnostic.

This decoupling prevents future DRF changes from breaking lobby functionality
and makes participant tracking more explicit.

Security audit is needed as current LiveKit tokens have excessive privileges
for unprivileged users. I'll offer more option ASAP for the admin to control
participant privileges.

Race condition handling also requires improvements, but should not be critical
at this point.

A great enhancement, would be to add a webhook, notifying the backend when the
room is closed, to reset cache.

This commit makes redis a prerequesite to run the suite of tests. The readme
and CI will be updated in dedicated commits.
This commit is contained in:
lebaudantoine
2025-02-18 22:09:02 +01:00
committed by aleb_the_flash
parent 710d7964ee
commit 4d961ed162
10 changed files with 1928 additions and 15 deletions

View File

@@ -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")

View File

@@ -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."""

View File

@@ -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."""

View File

@@ -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()

View File

@@ -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": []}

View File

@@ -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
)

View File

@@ -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()

View File

@@ -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

View File

@@ -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
),
}

View File

@@ -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):