""" Tests for the Hold Slayer service. Uses MockSIPEngine to test the state machine without real SIP. """ import asyncio import pytest from config import Settings from core.call_manager import CallManager from core.event_bus import EventBus from core.sip_engine import MockSIPEngine from models.call import ActiveCall, AudioClassification, CallMode, CallStatus from models.call_flow import ActionType, CallFlow, CallFlowStep from services.hold_slayer import HoldSlayerService class TestMenuNavigation: """Test the IVR menu navigation logic.""" @pytest.fixture def hold_slayer(self): """Create a HoldSlayerService with mock dependencies.""" from config import ClassifierSettings, SpeachesSettings from services.audio_classifier import AudioClassifier from services.transcription import TranscriptionService settings = Settings() event_bus = EventBus() call_manager = CallManager(event_bus) sip_engine = MockSIPEngine() classifier = AudioClassifier(ClassifierSettings()) transcription = TranscriptionService(SpeachesSettings()) return HoldSlayerService( gateway=None, # Not needed for menu tests call_manager=call_manager, sip_engine=sip_engine, classifier=classifier, transcription=transcription, settings=settings, ) def test_decide_cancel_card(self, hold_slayer): """Should match 'cancel' intent to card cancellation option.""" transcript = ( "Press 1 for account balance, press 2 for recent transactions, " "press 3 to report a lost or stolen card, press 4 to cancel your card, " "press 0 to speak with a representative." ) result = hold_slayer._decide_menu_option( transcript, "cancel my credit card", None ) assert result == "4" def test_decide_dispute_charge(self, hold_slayer): """Should match 'dispute' intent to billing option.""" transcript = ( "Press 1 for account balance, press 2 for billing and disputes, " "press 3 for payments, press 0 for agent." ) result = hold_slayer._decide_menu_option( transcript, "dispute a charge on my statement", None ) assert result == "2" def test_decide_agent_fallback(self, hold_slayer): """Should fall back to agent option when no match.""" transcript = ( "Press 1 for mortgage, press 2 for auto loans, " "press 3 for investments, press 0 to speak with a representative." ) result = hold_slayer._decide_menu_option( transcript, "cancel my credit card", None ) # Should choose representative since no direct match assert result == "0" def test_decide_no_options_found(self, hold_slayer): """Return None when transcript has no recognizable menu.""" transcript = "Please hold while we transfer your call." result = hold_slayer._decide_menu_option( transcript, "cancel my card", None ) assert result is None def test_decide_alternate_pattern(self, hold_slayer): """Handle 'for X, press N' pattern.""" transcript = ( "For account balance, press 1. For billing inquiries, press 2. " "For card cancellation, press 3." ) result = hold_slayer._decide_menu_option( transcript, "cancel my card", None ) # Should match card cancellation assert result == "3" def test_decide_fraud_intent(self, hold_slayer): """Match fraud-related intent.""" transcript = ( "Press 1 for balance, press 2 for payments, " "press 3 to report fraud or unauthorized transactions, " "press 0 for an agent." ) result = hold_slayer._decide_menu_option( transcript, "report unauthorized charge on my card", None ) assert result == "3" class TestEventBus: """Test the event bus pub/sub system.""" @pytest.fixture def event_bus(self): return EventBus() def test_subscribe(self, event_bus): sub = event_bus.subscribe() assert event_bus.subscriber_count == 1 sub.close() assert event_bus.subscriber_count == 0 @pytest.mark.asyncio async def test_publish_receive(self, event_bus): from models.events import EventType, GatewayEvent sub = event_bus.subscribe() event = GatewayEvent( type=EventType.CALL_INITIATED, call_id="test_123", message="Test event", ) await event_bus.publish(event) received = await asyncio.wait_for(sub.__anext__(), timeout=1.0) assert received.type == EventType.CALL_INITIATED assert received.call_id == "test_123" sub.close() def test_history(self, event_bus): assert len(event_bus.recent_events) == 0 class TestCallManager: """Test call manager state tracking.""" @pytest.fixture def call_manager(self): event_bus = EventBus() return CallManager(event_bus) @pytest.mark.asyncio async def test_create_call(self, call_manager): call = await call_manager.create_call( remote_number="+18005551234", mode=CallMode.HOLD_SLAYER, intent="cancel my card", ) assert call.id.startswith("call_") assert call.remote_number == "+18005551234" assert call.mode == CallMode.HOLD_SLAYER assert call.intent == "cancel my card" assert call.status == CallStatus.INITIATING @pytest.mark.asyncio async def test_update_status(self, call_manager): call = await call_manager.create_call( remote_number="+18005551234", mode=CallMode.DIRECT, ) await call_manager.update_status(call.id, CallStatus.RINGING) updated = call_manager.get_call(call.id) assert updated.status == CallStatus.RINGING @pytest.mark.asyncio async def test_end_call(self, call_manager): call = await call_manager.create_call( remote_number="+18005551234", mode=CallMode.DIRECT, ) ended = await call_manager.end_call(call.id) assert ended is not None assert ended.status == CallStatus.COMPLETED assert call_manager.get_call(call.id) is None @pytest.mark.asyncio async def test_active_call_count(self, call_manager): assert call_manager.active_call_count == 0 await call_manager.create_call("+18005551234", CallMode.DIRECT) assert call_manager.active_call_count == 1 await call_manager.create_call("+18005559999", CallMode.HOLD_SLAYER) assert call_manager.active_call_count == 2 @pytest.mark.asyncio async def test_add_transcript(self, call_manager): call = await call_manager.create_call("+18005551234", CallMode.HOLD_SLAYER) await call_manager.add_transcript(call.id, "Press 1 for English") await call_manager.add_transcript(call.id, "Press 2 for French") updated = call_manager.get_call(call.id) assert "Press 1 for English" in updated.transcript assert "Press 2 for French" in updated.transcript class TestMockSIPEngine: """Test the mock SIP engine.""" @pytest.fixture def engine(self): return MockSIPEngine() @pytest.mark.asyncio async def test_lifecycle(self, engine): assert not await engine.is_ready() await engine.start() assert await engine.is_ready() await engine.stop() assert not await engine.is_ready() @pytest.mark.asyncio async def test_make_call(self, engine): await engine.start() leg_id = await engine.make_call("+18005551234") assert leg_id.startswith("mock_leg_") assert leg_id in engine._active_legs @pytest.mark.asyncio async def test_hangup(self, engine): await engine.start() leg_id = await engine.make_call("+18005551234") await engine.hangup(leg_id) assert leg_id not in engine._active_legs @pytest.mark.asyncio async def test_send_dtmf(self, engine): await engine.start() leg_id = await engine.make_call("+18005551234") await engine.send_dtmf(leg_id, "1") await engine.send_dtmf(leg_id, "0") assert engine._active_legs[leg_id]["dtmf_sent"] == ["1", "0"] @pytest.mark.asyncio async def test_bridge(self, engine): await engine.start() leg_a = await engine.make_call("+18005551234") leg_b = await engine.make_call("+18005559999") bridge_id = await engine.bridge_calls(leg_a, leg_b) assert bridge_id in engine._bridges await engine.unbridge(bridge_id) assert bridge_id not in engine._bridges @pytest.mark.asyncio async def test_trunk_status(self, engine): status = await engine.get_trunk_status() assert status["registered"] is False await engine.start() status = await engine.get_trunk_status() assert status["registered"] is True