882 lines
28 KiB
Python
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"
|