Files
meet/src/backend/core/tests/test_utils.py
T
davd-gzl da3ebc3564 🐛(backend) raise service errors when LiveKit times out
A timed-out call raised a bare TimeoutError past every
service's TwirpError handler, so callers that recover from
a failed LiveKit call, the permission checks and the
webhook handlers among them, failed instead.
2026-10-20 12:30:38 +02:00

423 lines
14 KiB
Python

"""
Test utils functions
"""
# pylint: disable=W0621
import json
import socket
import threading
from unittest import mock
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
import jwt
import pytest
from asgiref.sync import async_to_sync
from livekit.api import TwirpError
from core.factories import UserFactory
from core.services.participants_management import (
ParticipantsManagement,
ParticipantsManagementException,
)
from core.services.room_management import RoomManagement, RoomManagementException
from core.services.sip_management import SIPException, SIPManagement
from core.utils import (
NotificationError,
create_livekit_client,
generate_token,
notify_participants,
)
pytestmark = pytest.mark.django_db
def decode_token(token: str) -> dict:
"""Decode a LiveKit JWT access token for inspection."""
return jwt.decode(
token,
settings.LIVEKIT_CONFIGURATION["api_secret"],
algorithms=["HS256"],
)
def test_generate_token_authenticated_uses_full_name():
"""The token's display name should default to the user's full name."""
user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user)
claims = decode_token(token)
assert claims["name"] == "Jane Doe"
assert claims["sub"] == str(user.sub)
def test_generate_token_authenticated_fallback_user_representation():
"""
When the user has no full name, the token's display name should fall back
to the user's string representation.
"""
user = UserFactory(full_name=None)
token = generate_token(room="my-room", user=user)
claims = decode_token(token)
assert claims["name"] == str(user)
def test_generate_token_explicit_username_overrides_default():
"""An explicitly provided username should take precedence over the full name."""
user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user, username="Custom Name")
claims = decode_token(token)
assert claims["name"] == "Custom Name"
def test_authenticated_username_ignored_when_editing_disabled(settings):
"""With editing disabled, an authenticated user's username is ignored."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user, username="Custom Name")
claims = decode_token(token)
assert claims["name"] == "Jane Doe"
def test_authenticated_default_name_unaffected_when_editing_disabled(settings):
"""Disabling editing doesn't disturb the default full-name path."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user)
claims = decode_token(token)
assert claims["name"] == "Jane Doe"
def test_anonymous_uses_username_when_provided():
"""An anonymous user's provided username is used as the display name."""
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
claims = decode_token(token)
assert claims["name"] == "Guest42"
def test_anonymous_username_used_even_when_editing_disabled(settings):
"""The setting governs authenticated users only; anonymous can still set a name."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
claims = decode_token(token)
assert claims["name"] == "Guest42"
def test_anonymous_falls_back_to_anonymous_label():
"""With no username, an anonymous user is labelled 'Anonymous'."""
token = generate_token(room="my-room", user=AnonymousUser())
claims = decode_token(token)
assert claims["name"] == "Anonymous"
@pytest.fixture
def fake_livekit(request):
"""A LiveKit address that takes the connection, sends `request.param`, then waits."""
server = socket.create_server(("127.0.0.1", 0))
held = []
def accept():
while True:
try:
connection, _ = server.accept()
except OSError:
return
held.append(connection)
connection.sendall(request.param)
threading.Thread(target=accept, daemon=True).start()
yield f"http://127.0.0.1:{server.getsockname()[1]:d}"
server.close()
for connection in held:
connection.close()
@pytest.mark.parametrize(
"fake_livekit",
[b"", b"HTTP/1.1 200 OK\r\nContent-Length: 64\r\n\r\n"],
ids=["no answer", "answer cut short"],
indirect=True,
)
def test_create_livekit_client_gives_up_on_a_quiet_livekit(fake_livekit, settings):
"""A LiveKit that stops answering fails the call after the timeout."""
settings.LIVEKIT_CONFIGURATION = {
**settings.LIVEKIT_CONFIGURATION,
"url": fake_livekit,
}
settings.LIVEKIT_API_TIMEOUT_SECONDS = 1
raised = []
def delete_room():
try:
RoomManagement.delete_room("room-abc")
except RoomManagementException as error:
raised.append(error)
# A thread, so a call that never gives up fails the test rather than hangs it.
call = threading.Thread(target=delete_room, daemon=True)
call.start()
call.join(5)
assert not call.is_alive()
assert len(raised) == 1
assert isinstance(raised[0].__cause__, TimeoutError)
@pytest.mark.parametrize(
"fake_livekit",
[b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"],
ids=["answer"],
indirect=True,
)
def test_create_livekit_client_lets_an_answer_through(fake_livekit, settings):
"""A LiveKit that answers within the timeout is not cut off."""
settings.LIVEKIT_CONFIGURATION = {
**settings.LIVEKIT_CONFIGURATION,
"url": fake_livekit,
}
settings.LIVEKIT_API_TIMEOUT_SECONDS = 1
RoomManagement.delete_room("room-abc")
class TimingOutClient:
"""A LiveKit client whose every call times out."""
def __getattr__(self, name):
return self
async def __call__(self, *args, **kwargs):
raise TimeoutError
async def aclose(self):
"""Nothing to close."""
def delete_dispatch_rule():
"""Reach the delete call, past the listing that comes first."""
with mock.patch.object(
SIPManagement, "_list_dispatch_rules_ids", mock.AsyncMock(return_value=["r"])
):
SIPManagement().delete_dispatch_rule("room")
@pytest.mark.parametrize(
("call", "expected"),
[
pytest.param(
lambda: ParticipantsManagement().mute("room", "id", "track"),
ParticipantsManagementException,
id="mute",
),
pytest.param(
lambda: ParticipantsManagement().remove("room", "id"),
ParticipantsManagementException,
id="remove",
),
pytest.param(
lambda: ParticipantsManagement().update("room", "id", name="n"),
ParticipantsManagementException,
id="update",
),
pytest.param(
lambda: ParticipantsManagement().check_if_in_meeting("room", "id"),
ParticipantsManagementException,
id="check_if_in_meeting",
),
pytest.param(
lambda: RoomManagement.update_metadata("room", {}),
RoomManagementException,
id="update_metadata",
),
pytest.param(
lambda: RoomManagement.delete_room("room"),
RoomManagementException,
id="delete_room",
),
pytest.param(
lambda: SIPManagement().create_dispatch_rule(
mock.Mock(pk="room", pin_code="1")
),
SIPException,
id="create_dispatch_rule",
),
pytest.param(
lambda: SIPManagement().has_dispatch_rule("room"),
SIPException,
id="has_dispatch_rule",
),
pytest.param(delete_dispatch_rule, SIPException, id="delete_dispatch_rule"),
pytest.param(
lambda: notify_participants("room", {}),
NotificationError,
id="notify_participants",
),
],
)
def test_a_livekit_timeout_raises_the_service_exception(call, expected):
"""A timed-out call fails the way a LiveKit error does, so callers handle it."""
with (
mock.patch("core.utils.create_livekit_client", return_value=TimingOutClient()),
pytest.raises(expected) as error,
):
call()
assert error.type is expected
assert isinstance(error.value.__cause__, TimeoutError)
@pytest.mark.parametrize("verify_ssl", [True, False])
def test_create_livekit_client_closes_its_session(verify_ssl, settings):
"""Closing the client closes its session, whether SSL is verified or not."""
settings.LIVEKIT_VERIFY_SSL = verify_ssl
@async_to_sync
async def open_and_close():
client = create_livekit_client()
await client.aclose()
return client._session # pylint: disable=protected-access
session = open_and_close()
assert session.closed
assert session.connector is None
@pytest.mark.parametrize("verify_ssl", [True, False])
def test_create_livekit_client_ssl(verify_ssl, settings):
"""SSL is verified unless the setting turns it off."""
settings.LIVEKIT_VERIFY_SSL = verify_ssl
@async_to_sync
async def ssl_of_new_client():
client = create_livekit_client()
ssl = client._session.connector._ssl # pylint: disable=protected-access
await client.aclose()
return ssl
assert (ssl_of_new_client() is not False) is verify_ssl
def test_create_livekit_client_custom_configuration():
"""A custom configuration picks the LiveKit the client talks to."""
@async_to_sync
async def url_of_new_client():
client = create_livekit_client(
{"api_key": "key", "api_secret": "secret", "url": "http://mock-url.com"}
)
url = client.room._client.host # pylint: disable=protected-access
await client.aclose()
return url
assert url_of_new_client().startswith("http://mock-url.com")
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_error(mock_create_livekit_client):
"""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, status=123)
)
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Call the function and expect an exception
with pytest.raises(NotificationError, match="Failed to notify room participants"):
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# 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()
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success_no_room(mock_create_livekit_client):
"""Test the notify_participants function when the LiveKit room doesn't exist."""
# 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()
# Create a proper response object with an empty rooms list
class MockResponse:
"""LiveKit API response mock with empty rooms list."""
rooms = []
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was not called since no room exists
mock_api_instance.room.send_data.assert_not_called()
# Verify the connection was properly closed
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success(mock_create_livekit_client):
"""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()
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Call the function
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# 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 == "room-number-1"
assert json.loads(send_data_request.data.decode("utf-8")) == {"foo": "foo"}
assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol
# Verify aclose was called
mock_api_instance.aclose.assert_called_once()