diff --git a/src/backend/core/services/livekit_events.py b/src/backend/core/services/livekit_events.py index b4cf9866..ece436cd 100644 --- a/src/backend/core/services/livekit_events.py +++ b/src/backend/core/services/livekit_events.py @@ -2,12 +2,18 @@ import uuid from enum import Enum +from logging import getLogger from django.conf import settings from livekit import api +from core import models + from .lobby import LobbyService +from .telephony import TelephonyException, TelephonyService + +logger = getLogger(__name__) class LiveKitWebhookError(Exception): @@ -77,6 +83,7 @@ class LiveKitEventsService: ) self.webhook_receiver = api.WebhookReceiver(token_verifier) self.lobby_service = LobbyService() + self.telephony_service = TelephonyService() def receive(self, request): """Process webhook and route to appropriate handler.""" @@ -108,10 +115,54 @@ class LiveKitEventsService: # pylint: disable=not-callable handler(data) - def _handle_room_finished(self, data): - """Handle 'room_finished' event.""" + def _handle_room_started(self, data): + """Handle 'room_started' event.""" + try: room_id = uuid.UUID(data.room.name) + except ValueError as e: + logger.warning( + "Ignoring room event: room name '%s' is not a valid UUID format.", + data.room.name, + ) + raise ActionFailedError("Failed to process room started event") from e + + try: + room = models.Room.objects.get(id=room_id) + except models.Room.DoesNotExist as err: + raise ActionFailedError(f"Room with ID {room_id} does not exist") from err + + if settings.ROOM_TELEPHONY_ENABLED: + try: + self.telephony_service.create_dispatch_rule(room) + except TelephonyException as e: + raise ActionFailedError( + f"Failed to create telephony dispatch rule for room {room_id}" + ) from e + + def _handle_room_finished(self, data): + """Handle 'room_finished' event.""" + + try: + room_id = uuid.UUID(data.room.name) + except ValueError as e: + logger.warning( + "Ignoring room event: room name '%s' is not a valid UUID format.", + data.room.name, + ) + raise ActionFailedError("Failed to process room finished event") from e + + if settings.ROOM_TELEPHONY_ENABLED: + try: + self.telephony_service.delete_dispatch_rule(room_id) + except TelephonyException as e: + raise ActionFailedError( + f"Failed to delete telephony dispatch rule for room {room_id}" + ) from e + + try: self.lobby_service.clear_room_cache(room_id) except Exception as e: - raise ActionFailedError("Failed to process room finished event") from e + raise ActionFailedError( + f"Failed to clear room cache for room {room_id}" + ) from e diff --git a/src/backend/core/services/telephony.py b/src/backend/core/services/telephony.py new file mode 100644 index 00000000..efdd43a1 --- /dev/null +++ b/src/backend/core/services/telephony.py @@ -0,0 +1,124 @@ +"""Telephony service for managing SIP dispatch rules for room access.""" + +from logging import getLogger + +from asgiref.sync import async_to_sync +from livekit.api import TwirpError +from livekit.protocol.sip import ( + CreateSIPDispatchRuleRequest, + DeleteSIPDispatchRuleRequest, + ListSIPDispatchRuleRequest, + SIPDispatchRule, + SIPDispatchRuleDirect, +) + +from core import utils + +logger = getLogger(__name__) + + +class TelephonyException(Exception): + """Exception raised when telephony operations fail.""" + + +class TelephonyService: + """Service for managing participant access through the telephony system (SIP).""" + + def _rule_name(self, room_id): + """Generate the rule name for a room based on its ID.""" + return f"SIP_{str(room_id)}" + + @async_to_sync + async def create_dispatch_rule(self, room): + """Create a SIP inbound dispatch rule for direct room routing. + + Configures telephony to route incoming SIP calls directly to the specified room + using the room's ID and PIN code for authentication. + """ + + direct_rule = SIPDispatchRule( + dispatch_rule_direct=SIPDispatchRuleDirect( + room_name=str(room.id), pin=str(room.pin_code) + ) + ) + + request = CreateSIPDispatchRuleRequest( + rule=direct_rule, name=self._rule_name(room.id) + ) + + lkapi = utils.create_livekit_client() + + try: + await lkapi.sip.create_sip_dispatch_rule(create=request) + except TwirpError as e: + logger.exception( + "Unexpected error creating dispatch rule for room %s", room.id + ) + raise TelephonyException("Could not create dispatch rule") from e + + finally: + await lkapi.aclose() + + async def _list_dispatch_rules_ids(self, room_id): + """List SIP dispatch rule IDs for a specific room. + + Fetches all existing SIP dispatch rules and filters them by room name + since LiveKit API doesn't support server-side filtering by 'room_name'. + This approach is acceptable for moderate scale but may need refactoring + for high-volume scenarios. + + Note: + Feature request for server-side filtering: livekit/sip#405 + """ + + lkapi = utils.create_livekit_client() + + try: + existing_rules = await lkapi.sip.list_sip_dispatch_rule( + list=ListSIPDispatchRuleRequest() + ) + except TwirpError as e: + logger.exception("Failed to list dispatch rules for room %s", room_id) + raise TelephonyException("Could not list dispatch rules") from e + finally: + await lkapi.aclose() + + if not existing_rules or not existing_rules.items: + return [] + + rule_name = self._rule_name(room_id) + + return [ + existing_rule.sip_dispatch_rule_id + for existing_rule in existing_rules.items + if existing_rule.name == rule_name + ] + + @async_to_sync + async def delete_dispatch_rule(self, room_id): + """Delete all SIP inbound dispatch rules associated with a specific room.""" + + rules_ids = await self._list_dispatch_rules_ids(room_id) + + if not rules_ids: + logger.info("No dispatch rules found for room %s", room_id) + return False + + if len(rules_ids) > 1: + logger.error("Multiple dispatch rules found for room %s", room_id) + + lkapi = utils.create_livekit_client() + try: + for rule_id in rules_ids: + await lkapi.sip.delete_sip_dispatch_rule( + delete=DeleteSIPDispatchRuleRequest(sip_dispatch_rule_id=rule_id) + ) + + return True + + except TwirpError as e: + logger.exception("Failed to delete dispatch rules for room %s", room_id) + raise TelephonyException("Could not delete dispatch rules") from e + + finally: + await lkapi.aclose() diff --git a/src/backend/core/tests/rooms/test_api_rooms_webhook.py b/src/backend/core/tests/rooms/test_api_rooms_webhook.py index 197fd7ab..5c97686f 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_webhook.py +++ b/src/backend/core/tests/rooms/test_api_rooms_webhook.py @@ -143,7 +143,7 @@ def test_handled_event_type( def test_unhandled_event_type(client, mock_livekit_config): """Should return 200 for event types that have no handler.""" - event_data = json.dumps({"event": "room_started"}) + event_data = json.dumps({"event": "participant_joined"}) hash64 = base64.b64encode(hashlib.sha256(event_data.encode()).digest()).decode() token = api.AccessToken( diff --git a/src/backend/core/tests/services/test_livekit_events.py b/src/backend/core/tests/services/test_livekit_events.py index 0a78d0a7..91b2dea2 100644 --- a/src/backend/core/tests/services/test_livekit_events.py +++ b/src/backend/core/tests/services/test_livekit_events.py @@ -8,6 +8,7 @@ from unittest import mock import pytest +from core.factories import RoomFactory from core.services.livekit_events import ( ActionFailedError, AuthenticationError, @@ -17,6 +18,7 @@ from core.services.livekit_events import ( api, ) from core.services.lobby import LobbyService +from core.services.telephony import TelephonyException, TelephonyService pytestmark = pytest.mark.django_db @@ -56,42 +58,149 @@ def test_initialization( @mock.patch.object(LobbyService, "clear_room_cache") -def test_handle_room_finished(mock_clear_cache, service): - """Should clear lobby cache when room is finished.""" - +@mock.patch.object(TelephonyService, "delete_dispatch_rule") +def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule( + mock_delete_dispatch_rule, mock_clear_cache, service, settings +): + """Should clear lobby cache and delete telephony dispatch rule when room finishes.""" + settings.ROOM_TELEPHONY_ENABLED = True mock_room_name = uuid.uuid4() - mock_data = mock.MagicMock() mock_data.room.name = str(mock_room_name) service._handle_room_finished(mock_data) + mock_delete_dispatch_rule.assert_called_once_with(mock_room_name) + mock_clear_cache.assert_called_once_with(mock_room_name) + + +@mock.patch.object(LobbyService, "clear_room_cache") +@mock.patch.object(TelephonyService, "delete_dispatch_rule") +def test_handle_room_finished_skips_telephony_when_disabled( + mock_delete_dispatch_rule, mock_clear_cache, service, settings +): + """Should clear lobby cache but skip dispatch rule deletion when telephony is disabled.""" + settings.ROOM_TELEPHONY_ENABLED = False + mock_room_name = uuid.uuid4() + mock_data = mock.MagicMock() + mock_data.room.name = str(mock_room_name) + + service._handle_room_finished(mock_data) + + mock_delete_dispatch_rule.assert_not_called() mock_clear_cache.assert_called_once_with(mock_room_name) @mock.patch.object( LobbyService, "clear_room_cache", side_effect=Exception("Test error") ) -def test_handle_room_finished_error(mock_clear_cache, service): - """Should raise ActionFailedError when processing fails.""" +@mock.patch.object(TelephonyService, "delete_dispatch_rule") +def test_handle_room_finished_raises_error_when_cache_clearing_fails( + mock_delete_dispatch_rule, mock_clear_cache, service, settings +): + """Should raise ActionFailedError when lobby cache clearing fails when room finishes.""" + settings.ROOM_TELEPHONY_ENABLED = True mock_data = mock.MagicMock() mock_data.room.name = "00000000-0000-0000-0000-000000000000" - with pytest.raises( - ActionFailedError, match="Failed to process room finished event" - ): + + expected_error = ( + "Failed to clear room cache for room 00000000-0000-0000-0000-000000000000" + ) + + with pytest.raises(ActionFailedError, match=expected_error): service._handle_room_finished(mock_data) + mock_delete_dispatch_rule.assert_called_once_with( + uuid.UUID("00000000-0000-0000-0000-000000000000") + ) -def test_handle_room_finished_invalid_room_name(service): - """Should raise ActionFailedError when processing fails.""" + +@mock.patch.object(LobbyService, "clear_room_cache") +@mock.patch.object( + TelephonyService, + "delete_dispatch_rule", + side_effect=TelephonyException("Test error"), +) +def test_handle_room_finished_raises_error_when_telephony_deletion_fails( + mock_delete_dispatch_rule, mock_clear_cache, service, settings +): + """Should raise ActionFailedError when dispatch rule deletion fails when room finishes.""" + settings.ROOM_TELEPHONY_ENABLED = True + mock_data = mock.MagicMock() + mock_data.room.name = "00000000-0000-0000-0000-000000000000" + + expected_error = ( + "Failed to delete telephony dispatch rule for room " + "00000000-0000-0000-0000-000000000000" + ) + + with pytest.raises(ActionFailedError, match=expected_error): + service._handle_room_finished(mock_data) + + mock_clear_cache.assert_not_called() + + +def test_handle_room_finished_raises_error_for_invalid_room_name(service): + """Should raise ActionFailedError when room name format is invalid when room finishes.""" mock_data = mock.MagicMock() mock_data.room.name = "invalid" + with pytest.raises( ActionFailedError, match="Failed to process room finished event" ): service._handle_room_finished(mock_data) +@mock.patch.object(TelephonyService, "create_dispatch_rule") +def test_handle_room_started_creates_dispatch_rule_successfully( + mock_create_dispatch_rule, service, settings +): + """Should create telephony dispatch rule when room starts successfully.""" + settings.ROOM_TELEPHONY_ENABLED = True + room = RoomFactory() + mock_data = mock.MagicMock() + mock_data.room.name = str(room.id) + + service._handle_room_started(mock_data) + + mock_create_dispatch_rule.assert_called_once_with(room) + + +@mock.patch.object(TelephonyService, "create_dispatch_rule") +def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled( + mock_create_dispatch_rule, service, settings +): + """Should skip creating telephony dispatch rule when telephony is disabled during room start.""" + settings.ROOM_TELEPHONY_ENABLED = False + room = RoomFactory() + mock_data = mock.MagicMock() + mock_data.room.name = str(room.id) + + service._handle_room_started(mock_data) + + mock_create_dispatch_rule.assert_not_called() + + +def test_handle_room_started_raises_error_for_invalid_room_name(service): + """Should raise ActionFailedError when room name format is invalid when room starts.""" + mock_data = mock.MagicMock() + mock_data.room.name = "invalid" + + with pytest.raises(ActionFailedError, match="Failed to process room started event"): + service._handle_room_started(mock_data) + + +def test_handle_room_started_raises_error_for_nonexistent_room(service): + """Should raise ActionFailedError when a room starts that doesn't exist in the database.""" + mock_data = mock.MagicMock() + mock_data.room.name = str(uuid.uuid4()) + + expected_error = f"Room with ID {mock_data.room.name} does not exist" + + with pytest.raises(ActionFailedError, match=expected_error): + service._handle_room_started(mock_data) + + @mock.patch.object( api.WebhookReceiver, "receive", side_effect=Exception("Invalid payload") ) diff --git a/src/backend/core/tests/services/test_telephony_service.py b/src/backend/core/tests/services/test_telephony_service.py new file mode 100644 index 00000000..96551252 --- /dev/null +++ b/src/backend/core/tests/services/test_telephony_service.py @@ -0,0 +1,305 @@ +""" +Test telephony service. +""" + +# pylint: disable=W0212 + +from unittest import mock + +import pytest +from asgiref.sync import async_to_sync +from livekit.api import TwirpError +from livekit.protocol.sip import ( + CreateSIPDispatchRuleRequest, + DeleteSIPDispatchRuleRequest, + ListSIPDispatchRuleRequest, + ListSIPDispatchRuleResponse, + SIPDispatchRule, + SIPDispatchRuleInfo, +) + +from core.factories import RoomFactory +from core.models import RoomAccessLevel +from core.services.telephony import TelephonyException, TelephonyService + +pytestmark = pytest.mark.django_db + + +def create_mock_livekit_client(): + """Factory for creating LiveKit client mock.""" + mock_api = mock.Mock() + mock_api.sip = mock.Mock() + mock_api.aclose = mock.AsyncMock() + return mock_api + + +def test_rule_name(): + """Test rule name generation.""" + telephony_service = TelephonyService() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") + rule_name = telephony_service._rule_name(room.id) + + assert rule_name == f"SIP_{str(room.id)}" + + +@mock.patch("core.utils.create_livekit_client") +def test_create_dispatch_rule_success(mock_client_factory): + """Test successful dispatch rule creation.""" + telephony_service = TelephonyService() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") + + mock_api = create_mock_livekit_client() + mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock() + mock_client_factory.return_value = mock_api + + telephony_service.create_dispatch_rule(room) + + mock_api.sip.create_sip_dispatch_rule.assert_called_once() + create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"] + + assert isinstance(create_request, CreateSIPDispatchRuleRequest) + assert create_request.name == f"SIP_{str(room.id)}" + assert create_request.rule.dispatch_rule_direct.room_name == str(room.id) + assert create_request.rule.dispatch_rule_direct.pin == str(room.pin_code) + mock_api.aclose.assert_called_once() + + +@mock.patch("core.utils.create_livekit_client") +def test_create_dispatch_rule_api_failure(mock_client_factory): + """Test dispatch rule creation when API fails.""" + telephony_service = TelephonyService() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") + + mock_api = create_mock_livekit_client() + mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock( + side_effect=TwirpError(msg="Internal server error", code=500, status=500) + ) + mock_client_factory.return_value = mock_api + + with pytest.raises(TelephonyException, match="Could not create dispatch rule"): + telephony_service.create_dispatch_rule(room) + + mock_api.sip.create_sip_dispatch_rule.assert_called_once() + mock_api.aclose.assert_called_once() + + +@mock.patch("core.utils.create_livekit_client") +def test_list_dispatch_rules_ids_success(mock_client_factory): + """Test successful listing of dispatch rule IDs.""" + telephony_service = TelephonyService() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") + + mock_rules = [ + SIPDispatchRuleInfo( + sip_dispatch_rule_id="rule-1", + name=f"SIP_{str(room.id)}", + rule=SIPDispatchRule(), + ), + SIPDispatchRuleInfo( + sip_dispatch_rule_id="rule-2", name="OTHER_RULE", rule=SIPDispatchRule() + ), + SIPDispatchRuleInfo( + sip_dispatch_rule_id="rule-3", + name=f"SIP_{str(room.id)}", + rule=SIPDispatchRule(), + ), + ] + + mock_api = create_mock_livekit_client() + mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock( + return_value=ListSIPDispatchRuleResponse(items=mock_rules) + ) + mock_client_factory.return_value = mock_api + + result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id) + + assert len(result) == 2 + assert "rule-1" in result + assert "rule-3" in result + assert "rule-2" not in result + + mock_api.sip.list_sip_dispatch_rule.assert_called_once() + list_request = mock_api.sip.list_sip_dispatch_rule.call_args[1]["list"] + assert isinstance(list_request, ListSIPDispatchRuleRequest) + mock_api.aclose.assert_called_once() + + +@mock.patch("core.utils.create_livekit_client") +def test_list_dispatch_rules_ids_empty_response(mock_client_factory): + """Test listing dispatch rule IDs when no rules exist.""" + telephony_service = TelephonyService() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") + + mock_api = create_mock_livekit_client() + mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock( + return_value=ListSIPDispatchRuleResponse(items=[]) + ) + mock_client_factory.return_value = mock_api + + result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id) + + assert result == [] + mock_api.aclose.assert_called_once() + + +@mock.patch("core.utils.create_livekit_client") +def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory): + """Test listing dispatch rule IDs when no rules match the room.""" + telephony_service = TelephonyService() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") + + mock_rules = [ + SIPDispatchRuleInfo( + sip_dispatch_rule_id="rule-1", name="OTHER_RULE_1", rule=SIPDispatchRule() + ), + SIPDispatchRuleInfo( + sip_dispatch_rule_id="rule-2", name="OTHER_RULE_2", rule=SIPDispatchRule() + ), + ] + + mock_api = create_mock_livekit_client() + mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock( + return_value=ListSIPDispatchRuleResponse(items=mock_rules) + ) + mock_client_factory.return_value = mock_api + + result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id) + + assert result == [] + mock_api.aclose.assert_called_once() + + +@mock.patch("core.utils.create_livekit_client") +def test_list_dispatch_rules_ids_api_failure(mock_client_factory): + """Test listing dispatch rule IDs when API fails.""" + telephony_service = TelephonyService() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") + + mock_api = create_mock_livekit_client() + mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock( + side_effect=TwirpError(msg="Internal server error", code=500, status=500) + ) + mock_client_factory.return_value = mock_api + + with pytest.raises(TelephonyException, match="Could not list dispatch rules"): + async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id) + + mock_api.sip.list_sip_dispatch_rule.assert_called_once() + mock_api.aclose.assert_called_once() + + +@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids") +@mock.patch("core.utils.create_livekit_client") +def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules): + """Test deleting dispatch rules when no rules exist.""" + telephony_service = TelephonyService() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") + + mock_list_rules.return_value = [] + + result = telephony_service.delete_dispatch_rule(room.id) + + assert result is False + mock_list_rules.assert_called_once_with(room.id) + mock_client_factory.assert_not_called() + + +@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids") +@mock.patch("core.utils.create_livekit_client") +def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules): + """Test deleting a single dispatch rule.""" + telephony_service = TelephonyService() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") + + mock_list_rules.return_value = ["rule-1"] + mock_api = create_mock_livekit_client() + mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock() + mock_client_factory.return_value = mock_api + + result = telephony_service.delete_dispatch_rule(room.id) + + assert result is True + mock_api.sip.delete_sip_dispatch_rule.assert_called_once() + delete_request = mock_api.sip.delete_sip_dispatch_rule.call_args[1]["delete"] + assert isinstance(delete_request, DeleteSIPDispatchRuleRequest) + assert delete_request.sip_dispatch_rule_id == "rule-1" + mock_api.aclose.assert_called_once() + + +@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids") +@mock.patch("core.utils.create_livekit_client") +def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules): + """Test deleting multiple dispatch rules.""" + telephony_service = TelephonyService() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") + + mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"] + mock_api = create_mock_livekit_client() + mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock() + mock_client_factory.return_value = mock_api + + result = telephony_service.delete_dispatch_rule(room.id) + + assert result is True + assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3 + + deleted_rule_ids = [ + call_args[1]["delete"].sip_dispatch_rule_id + for call_args in mock_api.sip.delete_sip_dispatch_rule.call_args_list + ] + assert all( + rule_id in deleted_rule_ids for rule_id in ["rule-1", "rule-2", "rule-3"] + ) + mock_api.aclose.assert_called_once() + + +@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids") +@mock.patch("core.utils.create_livekit_client") +def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules): + """Test deleting multiple dispatch rules when one deletion fails.""" + telephony_service = TelephonyService() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") + + mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"] + mock_api = create_mock_livekit_client() + + call_count = 0 + + def delete_side_effect(*args, **kwargs): + nonlocal call_count + if call_count == 0: + call_count += 1 + return None + raise TwirpError(msg="Deletion failed", code=500, status=500) + + mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock( + side_effect=delete_side_effect + ) + mock_client_factory.return_value = mock_api + + with pytest.raises(TelephonyException, match="Could not delete dispatch rules"): + telephony_service.delete_dispatch_rule(room.id) + + assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2 + mock_api.aclose.assert_called_once() + + +@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids") +@mock.patch("core.utils.create_livekit_client") +def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules): + """Test deleting dispatch rules when API fails immediately.""" + telephony_service = TelephonyService() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") + + mock_list_rules.return_value = ["rule-1"] + mock_api = create_mock_livekit_client() + mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock( + side_effect=TwirpError(msg="Internal server error", code=500, status=500) + ) + mock_client_factory.return_value = mock_api + + with pytest.raises(TelephonyException, match="Could not delete dispatch rules"): + telephony_service.delete_dispatch_rule(room.id) + + mock_api.sip.delete_sip_dispatch_rule.assert_called_once() + mock_api.aclose.assert_called_once()