Files
hold-slayer/tests/test_hold_slayer.py
Robert Helewka ecf37658ce feat: add initial Hold Slayer AI telephony gateway implementation
Complete project scaffolding and core implementation of an AI-powered
telephony system that calls companies, navigates IVR menus, waits on
hold, and transfers to the user when a human answers.

Key components:
- FastAPI server with REST API, WebSocket, and MCP (SSE) interfaces
- SIP/VoIP call management via PJSUA2 with RTP audio streaming
- LLM-powered IVR navigation using OpenAI/Anthropic with tool calling
- Hold detection service combining audio analysis and silence detection
- Real-time STT (Whisper/Deepgram) and TTS (OpenAI/Piper) pipelines
- Call recording with per-channel and mixed audio capture
- Event bus (asyncio pub/sub) for real-time client updates
- Web dashboard with live call monitoring
- SQLite persistence via SQLAlchemy with call history and analytics
- Notification support (email, SMS, webhook, desktop)
- Docker Compose deployment with Opal VoIP and Opal Media containers
- Comprehensive test suite with unit, integration, and E2E tests
- Simplified .gitignore and full project documentation in README
2026-03-21 19:23:26 +00:00

266 lines
9.0 KiB
Python

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