From 988e5aa2564fd2948fe414e42b0b0b7233f032cc Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Tue, 1 Jul 2025 17:59:45 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20telephony=20service?= =?UTF-8?q?=20for=20automatic=20SIP=20dispatch=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented a service that automatically creates a SIP dispatch rule when the first WebRTC participant joins a room and removes it when the room becomes empty. Why? I don’t want a SIP participant to join an empty room. The PIN code could be easily leaked, and there is currently no lobby mechanism available for SIP participants. A WebRTC participant is still required to create a room. This behavior is inspired by a proprietary tool. The service uses LiveKit’s webhook notification system to react to room lifecycle events. This is a naive implementation that currently supports only a single SIP trunk and will require refactoring to support multiple trunks. When no trunk is specified, rules are created by default on a fallback trunk. @rouja wrote a minimal Helm chart for LiveKit SIP with Asterisk, which couldn’t be versioned yet due to embedded credentials. I deployed it locally and successfully tested the integration with a remote OVH SIP trunk. One point to note: LiveKit lacks advanced filtering capabilities when listing dispatch rules. Their recommendation is to fetch all rules and filter them within your backend logic. I’ve opened a feature request asking for at least the ability to filter dispatch rules by room, since filtering by trunk is already supported, room-based filtering feels like a natural addition. Until there's an update, I prefer to keep the implementation simple. It works well at our current scale, and can be refactored when higher load or multi-trunk support becomes necessary. While caching dispatch rule IDs could be a performance optimization, I feel it would be premature and potentially error-prone due to the complexity of invalidation. If performance becomes an issue, I’ll consider introducing caching at that point. To handle the edge case where multiple dispatch rules with different PIN codes are present, the service performs an extensive cleanup during room creation to ensure SIP routing remains clean and predictable. This edge case should not happen. In the 'delete_dispatch_rule' if deleting one rule fails, method would exit without deleting the other rules. It's okay IMO for a first iteration. If multiple dispatch rules are often found for room, I would enhance this part. --- src/backend/core/services/livekit_events.py | 57 +++- src/backend/core/services/telephony.py | 124 +++++++ .../tests/rooms/test_api_rooms_webhook.py | 2 +- .../tests/services/test_livekit_events.py | 131 +++++++- .../tests/services/test_telephony_service.py | 305 ++++++++++++++++++ 5 files changed, 604 insertions(+), 15 deletions(-) create mode 100644 src/backend/core/services/telephony.py create mode 100644 src/backend/core/tests/services/test_telephony_service.py 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()