TradingAgents/tests/unit/alerts/test_sms_channel.py

882 lines
28 KiB
Python

"""Tests for SMS Channel.
Issue #41: [ALERT-40] SMS channel - Twilio
"""
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import json
import pytest
from tradingagents.alerts.sms_channel import (
# Enums
SMSFormat,
SMSStatus,
# Data Classes
SMSConfig,
SMSMessageResult,
SMSBatchResult,
# Classes
SMSMessageFormatter,
SMSChannel,
# Factory Functions
create_sms_channel,
# Constants
SMS_STANDARD_LIMIT,
SMS_UNICODE_LIMIT,
E164_PATTERN,
)
from tradingagents.alerts.alert_manager import (
Alert,
AlertPriority,
AlertCategory,
ChannelType,
)
# ============================================================================
# Enum Tests
# ============================================================================
class TestSMSFormat:
"""Tests for SMSFormat enum."""
def test_all_formats_defined(self):
"""Verify all formats exist."""
assert SMSFormat.PLAIN
assert SMSFormat.COMPACT
assert SMSFormat.DETAILED
def test_format_values(self):
"""Verify format values."""
assert SMSFormat.PLAIN.value == "plain"
assert SMSFormat.COMPACT.value == "compact"
assert SMSFormat.DETAILED.value == "detailed"
class TestSMSStatus:
"""Tests for SMSStatus enum."""
def test_all_statuses_defined(self):
"""Verify all Twilio statuses exist."""
assert SMSStatus.QUEUED
assert SMSStatus.SENDING
assert SMSStatus.SENT
assert SMSStatus.DELIVERED
assert SMSStatus.UNDELIVERED
assert SMSStatus.FAILED
assert SMSStatus.CANCELED
# ============================================================================
# Data Class Tests
# ============================================================================
class TestSMSConfig:
"""Tests for SMSConfig dataclass."""
def test_default_creation(self):
"""Test creating config with defaults."""
config = SMSConfig()
assert config.account_sid == ""
assert config.auth_token == ""
assert config.from_number == ""
assert config.to_numbers == []
assert config.format == SMSFormat.COMPACT
assert config.retry_count == 2
def test_custom_config(self):
"""Test creating custom config."""
config = SMSConfig(
account_sid="ACtest123",
auth_token="token123",
from_number="+15551234567",
to_numbers=["+15559876543"],
)
assert config.account_sid == "ACtest123"
assert config.from_number == "+15551234567"
assert len(config.to_numbers) == 1
def test_messaging_service_sid(self):
"""Test messaging service SID config."""
config = SMSConfig(
account_sid="ACtest123",
auth_token="token123",
messaging_service_sid="MGtest456",
to_numbers=["+15559876543"],
)
assert config.messaging_service_sid == "MGtest456"
def test_priority_filter(self):
"""Test priority filter config."""
config = SMSConfig(
priority_filter=AlertPriority.HIGH,
)
assert config.priority_filter == AlertPriority.HIGH
class TestSMSMessageResult:
"""Tests for SMSMessageResult dataclass."""
def test_default_creation(self):
"""Test creating result with defaults."""
result = SMSMessageResult()
assert result.success is False
assert result.message_sid == ""
assert result.segments == 1
assert result.attempts == 0
def test_successful_result(self):
"""Test successful result."""
result = SMSMessageResult(
success=True,
message_sid="SM12345",
status="sent",
to_number="+15559876543",
segments=1,
)
assert result.success is True
assert result.message_sid == "SM12345"
class TestSMSBatchResult:
"""Tests for SMSBatchResult dataclass."""
def test_default_creation(self):
"""Test creating batch result with defaults."""
result = SMSBatchResult()
assert result.success is False
assert result.total_sent == 0
assert result.total_failed == 0
assert result.results == []
def test_batch_result_with_results(self):
"""Test batch result with individual results."""
individual_results = [
SMSMessageResult(success=True, to_number="+15551111111"),
SMSMessageResult(success=True, to_number="+15552222222"),
SMSMessageResult(success=False, to_number="+15553333333"),
]
result = SMSBatchResult(
success=False,
total_sent=2,
total_failed=1,
results=individual_results,
)
assert result.total_sent == 2
assert result.total_failed == 1
assert len(result.results) == 3
# ============================================================================
# Formatter Tests
# ============================================================================
class TestSMSMessageFormatter:
"""Tests for SMSMessageFormatter."""
@pytest.fixture
def alert(self):
"""Create test alert."""
return Alert(
title="Test Alert",
message="This is a test message",
priority=AlertPriority.MEDIUM,
category=AlertCategory.TRADE,
source="test",
data={"symbol": "AAPL", "price": "150.00"},
)
@pytest.fixture
def config(self):
"""Create test config."""
return SMSConfig(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
to_numbers=["+15559876543"],
)
def test_format_plain(self, alert, config):
"""Test plain format."""
config.format = SMSFormat.PLAIN
message = SMSMessageFormatter.format_plain(alert, config)
assert "Test Alert" in message
assert "This is a test message" in message
def test_format_compact(self, alert, config):
"""Test compact format."""
config.format = SMSFormat.COMPACT
message = SMSMessageFormatter.format_compact(alert, config)
assert "TRD" in message # Category prefix
assert "Test Alert" in message
assert len(message) <= SMS_STANDARD_LIMIT
def test_format_detailed(self, alert, config):
"""Test detailed format."""
config.format = SMSFormat.DETAILED
message = SMSMessageFormatter.format_detailed(alert, config)
assert "TRADE" in message # Category
assert "Test Alert" in message
assert "symbol" in message
assert "AAPL" in message
def test_format_dispatcher(self, alert, config):
"""Test format dispatcher."""
config.format = SMSFormat.PLAIN
plain_msg = SMSMessageFormatter.format(alert, config)
config.format = SMSFormat.COMPACT
compact_msg = SMSMessageFormatter.format(alert, config)
assert plain_msg != compact_msg
def test_priority_indicators(self, config):
"""Test priority indicators in message."""
alert_high = Alert(
title="High Alert",
message="High priority",
priority=AlertPriority.HIGH,
)
alert_critical = Alert(
title="Critical Alert",
message="Critical issue",
priority=AlertPriority.CRITICAL,
)
config.format = SMSFormat.PLAIN
high_msg = SMSMessageFormatter.format_plain(alert_high, config)
critical_msg = SMSMessageFormatter.format_plain(alert_critical, config)
assert "[!]" in high_msg
assert "[!!!]" in critical_msg
def test_category_prefixes(self, config):
"""Test category prefixes in compact format."""
for category in AlertCategory:
alert = Alert(
title="Test",
message="Test",
category=category,
)
config.format = SMSFormat.COMPACT
message = SMSMessageFormatter.format_compact(alert, config)
# Message should be created without error
assert message is not None
def test_include_timestamp(self, alert, config):
"""Test timestamp inclusion."""
config.include_timestamp = True
config.format = SMSFormat.PLAIN
message = SMSMessageFormatter.format_plain(alert, config)
# Should have time in HH:MM format
assert "(" in message
assert ")" in message
def test_max_length_truncation(self, config):
"""Test message truncation with max_length."""
alert = Alert(
title="Very Long Title That Should Be Truncated",
message="A" * 200,
)
config.max_length = 100
config.format = SMSFormat.PLAIN
message = SMSMessageFormatter.format_plain(alert, config)
assert len(message) <= 100
assert message.endswith("...")
def test_count_segments_standard(self):
"""Test segment counting for standard messages."""
# Single segment
short_msg = "Hello world"
assert SMSMessageFormatter.count_segments(short_msg) == 1
# Exactly 160 chars
exact_msg = "A" * SMS_STANDARD_LIMIT
assert SMSMessageFormatter.count_segments(exact_msg) == 1
# Two segments
two_segment_msg = "A" * 161
assert SMSMessageFormatter.count_segments(two_segment_msg) == 2
def test_count_segments_unicode(self):
"""Test segment counting for unicode messages."""
# Unicode requires different counting
unicode_msg = "Hello 😀" # Contains emoji
segments = SMSMessageFormatter.count_segments(unicode_msg)
assert segments >= 1
def test_data_fields_in_compact(self, alert, config):
"""Test data fields in compact format."""
config.format = SMSFormat.COMPACT
message = SMSMessageFormatter.format_compact(alert, config)
# Should include some data if space allows
# Since compact prioritizes brevity, data may be truncated
assert len(message) <= SMS_STANDARD_LIMIT
# ============================================================================
# SMSChannel Tests
# ============================================================================
class TestSMSChannel:
"""Tests for SMSChannel class."""
@pytest.fixture
def channel(self):
"""Create test channel."""
return SMSChannel(
account_sid="ACtest123",
auth_token="test_token",
from_number="+15551234567",
to_numbers=["+15559876543"],
)
@pytest.fixture
def alert(self):
"""Create test alert."""
return Alert(
title="Test Alert",
message="Test message",
priority=AlertPriority.MEDIUM,
category=AlertCategory.TRADE,
)
def test_initialization_with_args(self):
"""Test initialization with arguments."""
channel = SMSChannel(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
to_numbers=["+15559876543"],
)
assert channel.config.account_sid == "ACtest"
assert channel.config.from_number == "+15551234567"
def test_initialization_with_config(self):
"""Test initialization with config object."""
config = SMSConfig(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
to_numbers=["+15559876543", "+15551111111"],
)
channel = SMSChannel(config=config)
assert len(channel.config.to_numbers) == 2
def test_channel_type(self, channel):
"""Test channel type."""
assert channel.channel_type == ChannelType.SMS
def test_is_available_with_config(self, channel):
"""Test availability with config."""
assert channel.is_available is True
def test_is_available_without_config(self):
"""Test availability without config."""
channel = SMSChannel()
assert channel.is_available is False
def test_is_available_with_messaging_service(self):
"""Test availability with messaging service SID."""
channel = SMSChannel(
account_sid="ACtest",
auth_token="token",
config=SMSConfig(
account_sid="ACtest",
auth_token="token",
messaging_service_sid="MGtest",
to_numbers=["+15559876543"],
),
)
assert channel.is_available is True
def test_validate_config_valid(self, channel):
"""Test config validation with valid config."""
is_valid, error = channel.validate_config()
assert is_valid is True
assert error == ""
def test_validate_config_no_account_sid(self):
"""Test config validation without account SID."""
channel = SMSChannel(
auth_token="token",
from_number="+15551234567",
to_numbers=["+15559876543"],
)
is_valid, error = channel.validate_config()
assert is_valid is False
assert "Account SID" in error
def test_validate_config_no_auth_token(self):
"""Test config validation without auth token."""
channel = SMSChannel(
account_sid="ACtest",
from_number="+15551234567",
to_numbers=["+15559876543"],
)
is_valid, error = channel.validate_config()
assert is_valid is False
assert "Auth token" in error
def test_validate_config_no_from_number(self):
"""Test config validation without from number."""
channel = SMSChannel(
account_sid="ACtest",
auth_token="token",
to_numbers=["+15559876543"],
)
is_valid, error = channel.validate_config()
assert is_valid is False
assert "From number" in error
def test_validate_config_invalid_from_number(self):
"""Test config validation with invalid from number."""
channel = SMSChannel(
account_sid="ACtest",
auth_token="token",
from_number="5551234567", # Missing +
to_numbers=["+15559876543"],
)
is_valid, error = channel.validate_config()
assert is_valid is False
assert "Invalid from number" in error
def test_validate_config_invalid_to_number(self):
"""Test config validation with invalid to number."""
channel = SMSChannel(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
to_numbers=["5559876543"], # Missing +
)
is_valid, error = channel.validate_config()
assert is_valid is False
assert "Invalid phone number" in error
def test_validate_config_no_recipients(self):
"""Test config validation without recipients."""
channel = SMSChannel(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
)
is_valid, error = channel.validate_config()
assert is_valid is False
assert "recipient" in error.lower()
@pytest.mark.asyncio
async def test_send_not_available(self, alert):
"""Test send when not available."""
channel = SMSChannel()
result = await channel.send_with_result(alert)
assert result.success is False
assert "not configured" in result.error_message
@pytest.mark.asyncio
async def test_send_success(self, channel, alert):
"""Test successful send."""
with patch.object(
channel,
"_send_twilio_message",
return_value={
"success": True,
"sid": "SM12345",
"status": "queued",
},
):
result = await channel.send_with_result(alert)
assert result.success is True
assert result.message_sid == "SM12345"
@pytest.mark.asyncio
async def test_send_failure(self, channel, alert):
"""Test failed send."""
with patch.object(
channel,
"_send_twilio_message",
return_value={
"success": False,
"error_code": 400,
"error_message": "Invalid number",
},
):
result = await channel.send_with_result(alert)
assert result.success is False
assert "Invalid number" in result.error_message
@pytest.mark.asyncio
async def test_send_retry_on_server_error(self, channel, alert):
"""Test retry on server error."""
channel.config.retry_count = 3
channel.config.retry_delay_seconds = 0.01
call_count = 0
async def mock_send(to, msg):
nonlocal call_count
call_count += 1
if call_count < 3:
return {"success": False, "error_code": 500, "error_message": "Server error"}
return {"success": True, "sid": "SM12345", "status": "queued"}
with patch.object(channel, "_send_twilio_message", side_effect=mock_send):
result = await channel.send_with_result(alert)
assert result.success is True
assert result.attempts == 3
@pytest.mark.asyncio
async def test_send_no_retry_on_client_error(self, channel, alert):
"""Test no retry on client error."""
channel.config.retry_count = 3
with patch.object(
channel,
"_send_twilio_message",
return_value={
"success": False,
"error_code": 400,
"error_message": "Bad request",
},
):
result = await channel.send_with_result(alert)
assert result.success is False
assert result.attempts == 1
@pytest.mark.asyncio
async def test_send_batch(self, alert):
"""Test batch send to multiple recipients."""
channel = SMSChannel(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
to_numbers=["+15551111111", "+15552222222", "+15553333333"],
)
with patch.object(
channel,
"_send_twilio_message",
return_value={
"success": True,
"sid": "SM12345",
"status": "queued",
},
):
result = await channel.send_batch(alert)
assert result.success is True
assert result.total_sent == 3
assert result.total_failed == 0
assert len(result.results) == 3
@pytest.mark.asyncio
async def test_send_batch_partial_failure(self, alert):
"""Test batch send with partial failure."""
channel = SMSChannel(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
to_numbers=["+15551111111", "+15552222222"],
)
call_count = 0
async def mock_send(to, msg):
nonlocal call_count
call_count += 1
if call_count == 1:
return {"success": True, "sid": "SM1", "status": "queued"}
return {"success": False, "error_code": 400, "error_message": "Invalid"}
with patch.object(channel, "_send_twilio_message", side_effect=mock_send):
result = await channel.send_batch(alert)
assert result.success is False
assert result.total_sent == 1
assert result.total_failed == 1
@pytest.mark.asyncio
async def test_send_bool_return(self, channel, alert):
"""Test send returns bool."""
with patch.object(
channel,
"_send_twilio_message",
return_value={
"success": True,
"sid": "SM12345",
"status": "queued",
},
):
result = await channel.send(alert)
assert result is True
@pytest.mark.asyncio
async def test_priority_filter(self, channel, alert):
"""Test priority filter."""
channel.config.priority_filter = AlertPriority.HIGH
# LOW priority should be filtered
alert.priority = AlertPriority.LOW
result = await channel.send_with_result(alert)
assert result.success is False
assert "priority" in result.error_message.lower()
# HIGH priority should pass
alert.priority = AlertPriority.HIGH
with patch.object(
channel,
"_send_twilio_message",
return_value={"success": True, "sid": "SM1", "status": "queued"},
):
result = await channel.send_with_result(alert)
assert result.success is True
@pytest.mark.asyncio
async def test_latency_tracked(self, channel, alert):
"""Test latency tracking."""
with patch.object(
channel,
"_send_twilio_message",
return_value={"success": True, "sid": "SM1", "status": "queued"},
):
result = await channel.send_with_result(alert)
assert result.latency_ms > 0
class TestSMSChannelIntegration:
"""Integration tests for SMS channel."""
def test_with_alert_manager(self):
"""Test integration with AlertManager."""
from tradingagents.alerts.alert_manager import AlertManager, RoutingRule
manager = AlertManager()
# Create and register SMS channel
sms = SMSChannel(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
to_numbers=["+15559876543"],
)
manager.register_channel(sms)
# Add routing rule
manager.add_routing_rule(RoutingRule(
name="sms_alerts",
priority=AlertPriority.CRITICAL,
channels=[ChannelType.SMS],
))
assert ChannelType.SMS in manager.channels
def test_module_imports(self):
"""Test that all classes are exported from module."""
from tradingagents.alerts import (
SMSFormat,
SMSStatus,
SMSConfig,
SMSMessageResult,
SMSBatchResult,
SMSMessageFormatter,
SMSChannel,
create_sms_channel,
)
# All imports successful
assert SMSFormat.COMPACT is not None
assert SMSChannel is not None
def test_create_sms_channel_factory(self):
"""Test factory function."""
channel = create_sms_channel(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
to_numbers=["+15559876543", "+15551111111"],
format=SMSFormat.DETAILED,
include_priority=True,
priority_filter=AlertPriority.HIGH,
)
assert channel.config.account_sid == "ACtest"
assert channel.config.from_number == "+15551234567"
assert len(channel.config.to_numbers) == 2
assert channel.config.format == SMSFormat.DETAILED
assert channel.config.priority_filter == AlertPriority.HIGH
def test_e164_pattern_valid(self):
"""Test E.164 pattern validation."""
valid_numbers = [
"+15551234567",
"+14155551234",
"+441onal2341234",
"+61412345678",
]
for number in valid_numbers:
if E164_PATTERN.match(number):
assert True
# Some may not match due to length
def test_e164_pattern_invalid(self):
"""Test E.164 pattern rejects invalid numbers."""
invalid_numbers = [
"5551234567", # No +
"+0551234567", # Leading 0 after +
"+(555)1234567", # Parentheses
"+1-555-123-4567", # Dashes
"+1 555 123 4567", # Spaces
]
for number in invalid_numbers:
assert not E164_PATTERN.match(number)
class TestSMSChannelFormatting:
"""Tests for SMS message formatting edge cases."""
@pytest.fixture
def config(self):
"""Create test config."""
return SMSConfig(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
to_numbers=["+15559876543"],
)
def test_empty_message(self, config):
"""Test formatting empty message."""
alert = Alert(
title="",
message="",
)
message = SMSMessageFormatter.format_compact(alert, config)
# Should still produce some output
assert message is not None
def test_long_title(self, config):
"""Test formatting with long title."""
alert = Alert(
title="A" * 100,
message="Short message",
)
config.format = SMSFormat.COMPACT
message = SMSMessageFormatter.format_compact(alert, config)
assert len(message) <= SMS_STANDARD_LIMIT
def test_long_message_detailed(self, config):
"""Test formatting long message in detailed mode."""
alert = Alert(
title="Alert",
message="A" * 500,
)
config.format = SMSFormat.DETAILED
config.max_length = SMS_STANDARD_LIMIT * 4 # Limit to 4 segments
message = SMSMessageFormatter.format_detailed(alert, config)
# Should not exceed max length
assert len(message) <= SMS_STANDARD_LIMIT * 4
def test_special_characters(self, config):
"""Test formatting with special characters."""
alert = Alert(
title="Price Alert: $AAPL",
message="Target price: $150.00 (>5% gain)",
)
message = SMSMessageFormatter.format_plain(alert, config)
assert "$AAPL" in message
assert "$150.00" in message
def test_unicode_emoji(self, config):
"""Test formatting with emoji."""
alert = Alert(
title="Alert",
message="Success!",
)
message = SMSMessageFormatter.format_plain(alert, config)
segments = SMSMessageFormatter.count_segments(message)
# Should be able to count segments
assert segments >= 1
def test_many_data_fields(self, config):
"""Test formatting with many data fields."""
data = {f"field_{i}": f"value_{i}" for i in range(10)}
alert = Alert(
title="Data Alert",
message="Multiple fields",
data=data,
)
config.format = SMSFormat.COMPACT
message = SMSMessageFormatter.format_compact(alert, config)
# Should be constrained to SMS limit
assert len(message) <= SMS_STANDARD_LIMIT
def test_no_priority_indicator(self, config):
"""Test without priority indicator."""
config.include_priority = False
alert = Alert(
title="Alert",
message="Message",
priority=AlertPriority.CRITICAL,
)
message = SMSMessageFormatter.format_plain(alert, config)
assert "[!!!]" not in message
class TestSMSChannelConnection:
"""Tests for SMS channel connection testing."""
def test_test_connection_no_credentials(self):
"""Test connection test without credentials."""
channel = SMSChannel()
result = channel.test_connection()
assert result.success is False
assert "Account SID" in result.error_message or "Auth Token" in result.error_message
def test_test_connection_with_mock(self):
"""Test connection test with mocked response."""
channel = SMSChannel(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
to_numbers=["+15559876543"],
)
with patch("urllib.request.urlopen") as mock_urlopen:
mock_response = MagicMock()
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.read.return_value = json.dumps({
"status": "active",
"friendly_name": "Test Account",
}).encode()
mock_urlopen.return_value = mock_response
result = channel.test_connection()
assert result.success is True
assert result.status == "active"