TradingAgents/tests/unit/alerts/test_alert_manager.py

758 lines
23 KiB
Python

"""Tests for Alert Manager.
Issue #38: [ALERT-37] Alert manager - orchestration and routing
"""
from datetime import datetime, timedelta
from decimal import Decimal
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from tradingagents.alerts.alert_manager import (
# Enums
AlertPriority,
AlertCategory,
AlertStatus,
ChannelType,
# Data Classes
AlertTemplate,
RateLimitConfig,
RoutingRule,
AlertConfig,
Alert,
DeliveryResult,
AlertStats,
# Channel Classes
LogChannel,
WebhookChannel,
# Main Class
AlertManager,
)
# ============================================================================
# Enum Tests
# ============================================================================
class TestAlertPriority:
"""Tests for AlertPriority enum."""
def test_all_priorities_defined(self):
"""Verify all priorities exist."""
assert AlertPriority.LOW
assert AlertPriority.MEDIUM
assert AlertPriority.HIGH
assert AlertPriority.CRITICAL
def test_priority_values(self):
"""Verify priority values."""
assert AlertPriority.LOW.value == "low"
assert AlertPriority.CRITICAL.value == "critical"
class TestAlertCategory:
"""Tests for AlertCategory enum."""
def test_all_categories_defined(self):
"""Verify all categories exist."""
assert AlertCategory.TRADE
assert AlertCategory.RISK
assert AlertCategory.SYSTEM
assert AlertCategory.MARKET
assert AlertCategory.PORTFOLIO
assert AlertCategory.EXECUTION
assert AlertCategory.COMPLIANCE
class TestAlertStatus:
"""Tests for AlertStatus enum."""
def test_all_statuses_defined(self):
"""Verify all statuses exist."""
assert AlertStatus.PENDING
assert AlertStatus.SENDING
assert AlertStatus.DELIVERED
assert AlertStatus.FAILED
assert AlertStatus.RATE_LIMITED
assert AlertStatus.SUPPRESSED
class TestChannelType:
"""Tests for ChannelType enum."""
def test_all_channels_defined(self):
"""Verify all channels exist."""
assert ChannelType.EMAIL
assert ChannelType.SLACK
assert ChannelType.SMS
assert ChannelType.WEBHOOK
assert ChannelType.PUSH
assert ChannelType.LOG
# ============================================================================
# Data Class Tests
# ============================================================================
class TestAlertTemplate:
"""Tests for AlertTemplate dataclass."""
def test_default_creation(self):
"""Test creating template with defaults."""
template = AlertTemplate()
assert template.template_id is not None
assert template.title_template == "{category}: {title}"
def test_render_template(self):
"""Test rendering template."""
template = AlertTemplate(
title_template="[{category}] {title}",
body_template="Message: {message}",
)
title, body = template.render({
"category": "TRADE",
"title": "Buy Signal",
"message": "AAPL buy detected",
})
assert title == "[TRADE] Buy Signal"
assert body == "Message: AAPL buy detected"
class TestRateLimitConfig:
"""Tests for RateLimitConfig dataclass."""
def test_default_creation(self):
"""Test creating config with defaults."""
config = RateLimitConfig()
assert config.max_alerts_per_minute == 10
assert config.max_alerts_per_hour == 100
assert config.enable_deduplication is True
class TestRoutingRule:
"""Tests for RoutingRule dataclass."""
def test_default_creation(self):
"""Test creating rule with defaults."""
rule = RoutingRule()
assert rule.rule_id is not None
assert rule.enabled is True
assert rule.priority == AlertPriority.LOW
def test_matches_priority(self):
"""Test priority matching."""
rule = RoutingRule(priority=AlertPriority.HIGH)
low_alert = Alert(priority=AlertPriority.LOW)
high_alert = Alert(priority=AlertPriority.HIGH)
critical_alert = Alert(priority=AlertPriority.CRITICAL)
assert not rule.matches(low_alert)
assert rule.matches(high_alert)
assert rule.matches(critical_alert)
def test_matches_category(self):
"""Test category matching."""
rule = RoutingRule(
categories=[AlertCategory.TRADE, AlertCategory.RISK],
)
trade_alert = Alert(category=AlertCategory.TRADE)
system_alert = Alert(category=AlertCategory.SYSTEM)
assert rule.matches(trade_alert)
assert not rule.matches(system_alert)
def test_disabled_rule(self):
"""Test disabled rule never matches."""
rule = RoutingRule(enabled=False)
alert = Alert()
assert not rule.matches(alert)
class TestAlertConfig:
"""Tests for AlertConfig dataclass."""
def test_default_creation(self):
"""Test creating config with defaults."""
config = AlertConfig()
assert config.log_all_alerts is True
assert config.store_history is True
assert config.max_history_size == 1000
class TestAlert:
"""Tests for Alert dataclass."""
def test_default_creation(self):
"""Test creating alert with defaults."""
alert = Alert()
assert alert.alert_id is not None
assert alert.status == AlertStatus.PENDING
assert alert.timestamp is not None
def test_content_hash(self):
"""Test content hash generation."""
alert1 = Alert(
title="Test",
message="Message",
category=AlertCategory.TRADE,
)
alert2 = Alert(
title="Test",
message="Message",
category=AlertCategory.TRADE,
)
alert3 = Alert(
title="Different",
message="Message",
category=AlertCategory.TRADE,
)
assert alert1.content_hash == alert2.content_hash
assert alert1.content_hash != alert3.content_hash
class TestDeliveryResult:
"""Tests for DeliveryResult dataclass."""
def test_default_creation(self):
"""Test creating result with defaults."""
result = DeliveryResult()
assert result.success is False
assert result.timestamp is not None
class TestAlertStats:
"""Tests for AlertStats dataclass."""
def test_default_creation(self):
"""Test creating stats with defaults."""
stats = AlertStats()
assert stats.total_sent == 0
assert stats.total_failed == 0
# ============================================================================
# Channel Tests
# ============================================================================
class TestLogChannel:
"""Tests for LogChannel."""
def test_channel_type(self):
"""Test channel type."""
channel = LogChannel()
assert channel.channel_type == ChannelType.LOG
def test_is_available(self):
"""Test availability."""
channel = LogChannel()
assert channel.is_available is True
@pytest.mark.asyncio
async def test_send(self):
"""Test sending alert."""
channel = LogChannel()
alert = Alert(title="Test", message="Test message")
result = await channel.send(alert)
assert result is True
class TestWebhookChannel:
"""Tests for WebhookChannel."""
def test_channel_type(self):
"""Test channel type."""
channel = WebhookChannel("https://example.com/webhook")
assert channel.channel_type == ChannelType.WEBHOOK
def test_availability_with_url(self):
"""Test availability with URL."""
channel = WebhookChannel("https://example.com/webhook")
assert channel.is_available is True
def test_availability_without_url(self):
"""Test availability without URL."""
channel = WebhookChannel("")
assert channel.is_available is False
@pytest.mark.asyncio
async def test_send_with_url(self):
"""Test sending with URL."""
channel = WebhookChannel("https://example.com/webhook")
alert = Alert(title="Test", message="Test message")
result = await channel.send(alert)
assert result is True
@pytest.mark.asyncio
async def test_send_without_url(self):
"""Test sending without URL fails."""
channel = WebhookChannel("")
alert = Alert(title="Test", message="Test message")
result = await channel.send(alert)
assert result is False
# ============================================================================
# AlertManager Tests
# ============================================================================
class TestAlertManager:
"""Tests for AlertManager class."""
@pytest.fixture
def manager(self):
"""Create default manager."""
return AlertManager()
def test_initialization(self, manager):
"""Test manager initialization."""
assert manager.config is not None
assert ChannelType.LOG in manager.channels
assert len(manager.routing_rules) > 0
def test_register_channel(self, manager):
"""Test registering a channel."""
webhook = WebhookChannel("https://example.com")
manager.register_channel(webhook)
assert ChannelType.WEBHOOK in manager.channels
def test_unregister_channel(self, manager):
"""Test unregistering a channel."""
manager.unregister_channel(ChannelType.LOG)
assert ChannelType.LOG not in manager.channels
def test_add_routing_rule(self, manager):
"""Test adding routing rule."""
initial_count = len(manager.routing_rules)
rule = RoutingRule(name="test_rule")
manager.add_routing_rule(rule)
assert len(manager.routing_rules) == initial_count + 1
def test_remove_routing_rule(self, manager):
"""Test removing routing rule."""
rule = RoutingRule(name="test_rule")
manager.add_routing_rule(rule)
result = manager.remove_routing_rule(rule.rule_id)
assert result is True
def test_remove_nonexistent_rule(self, manager):
"""Test removing nonexistent rule."""
result = manager.remove_routing_rule("nonexistent")
assert result is False
def test_register_template(self, manager):
"""Test registering template."""
template = AlertTemplate(name="custom_template")
manager.register_template(template)
assert "custom_template" in manager.templates
def test_create_alert(self, manager):
"""Test creating alert."""
alert = manager.create_alert(
title="Test Alert",
message="This is a test",
priority=AlertPriority.HIGH,
category=AlertCategory.RISK,
)
assert alert.title == "Test Alert"
assert alert.priority == AlertPriority.HIGH
assert alert.category == AlertCategory.RISK
def test_create_alert_from_template(self, manager):
"""Test creating alert from template."""
alert = manager.create_alert_from_template(
"trade_signal",
{
"symbol": "AAPL",
"action": "BUY",
"price": "150.00",
"reason": "Momentum signal",
},
)
assert alert is not None
assert "AAPL" in alert.title
assert "BUY" in alert.title
def test_create_alert_from_nonexistent_template(self, manager):
"""Test creating alert from nonexistent template."""
alert = manager.create_alert_from_template(
"nonexistent",
{},
)
assert alert is None
def test_send_alert(self, manager):
"""Test sending alert."""
alert = manager.create_alert(
title="Test",
message="Test message",
)
results = manager.send(alert)
assert len(results) > 0
assert alert.status == AlertStatus.DELIVERED
@pytest.mark.asyncio
async def test_send_alert_async(self, manager):
"""Test sending alert asynchronously."""
alert = manager.create_alert(
title="Test",
message="Test message",
)
results = await manager.send_async(alert)
assert len(results) > 0
assert alert.status == AlertStatus.DELIVERED
class TestRateLimiting:
"""Tests for rate limiting."""
@pytest.fixture
def limited_manager(self):
"""Create manager with tight rate limits."""
config = AlertConfig(
rate_limit_config=RateLimitConfig(
max_alerts_per_minute=2,
max_alerts_per_hour=5,
),
)
return AlertManager(config=config)
def test_rate_limit_per_minute(self, limited_manager):
"""Test rate limit per minute."""
# Send 2 alerts (should succeed) - each with unique message to avoid dedup
for i in range(2):
alert = limited_manager.create_alert(
title=f"Test {i}",
message=f"Test message {i}",
category=AlertCategory.TRADE,
)
limited_manager.send(alert)
# 3rd alert should be rate limited
alert = limited_manager.create_alert(
title="Test 3",
message="Test message 3",
category=AlertCategory.TRADE,
)
limited_manager.send(alert)
assert alert.status == AlertStatus.RATE_LIMITED
class TestDeduplication:
"""Tests for deduplication."""
@pytest.fixture
def manager(self):
"""Create manager with deduplication."""
config = AlertConfig(
rate_limit_config=RateLimitConfig(
enable_deduplication=True,
dedupe_window_seconds=60,
),
)
return AlertManager(config=config)
def test_duplicate_suppressed(self, manager):
"""Test duplicate alerts are suppressed."""
# First alert should succeed
alert1 = manager.create_alert(
title="Same Title",
message="Same Message",
category=AlertCategory.TRADE,
)
manager.send(alert1)
assert alert1.status == AlertStatus.DELIVERED
# Duplicate should be suppressed
alert2 = manager.create_alert(
title="Same Title",
message="Same Message",
category=AlertCategory.TRADE,
)
manager.send(alert2)
assert alert2.status == AlertStatus.SUPPRESSED
def test_different_not_suppressed(self, manager):
"""Test different alerts are not suppressed."""
alert1 = manager.create_alert(
title="Title 1",
message="Message 1",
)
manager.send(alert1)
alert2 = manager.create_alert(
title="Title 2",
message="Message 2",
)
manager.send(alert2)
assert alert2.status == AlertStatus.DELIVERED
class TestHistory:
"""Tests for alert history."""
@pytest.fixture
def manager(self):
"""Create manager with history."""
return AlertManager()
def test_history_stored(self, manager):
"""Test alerts are stored in history."""
alert = manager.create_alert(title="Test", message="Test")
manager.send(alert)
history = manager.get_history()
assert len(history) > 0
assert history[0].title == "Test"
def test_history_filter_category(self, manager):
"""Test filtering history by category."""
alert1 = manager.create_alert(
title="Trade",
message="Trade alert",
category=AlertCategory.TRADE,
)
alert2 = manager.create_alert(
title="Risk",
message="Risk alert",
category=AlertCategory.RISK,
)
manager.send(alert1)
manager.send(alert2)
trade_history = manager.get_history(category=AlertCategory.TRADE)
assert len(trade_history) == 1
assert trade_history[0].category == AlertCategory.TRADE
def test_history_filter_priority(self, manager):
"""Test filtering history by priority."""
alert1 = manager.create_alert(
title="Low",
message="Low priority",
priority=AlertPriority.LOW,
)
alert2 = manager.create_alert(
title="High",
message="High priority",
priority=AlertPriority.HIGH,
)
manager.send(alert1)
manager.send(alert2)
high_history = manager.get_history(priority=AlertPriority.HIGH)
assert len(high_history) == 1
assert high_history[0].priority == AlertPriority.HIGH
def test_clear_history(self, manager):
"""Test clearing history."""
alert = manager.create_alert(title="Test", message="Test")
manager.send(alert)
count = manager.clear_history()
assert count > 0
assert len(manager.get_history()) == 0
class TestAcknowledgement:
"""Tests for alert acknowledgement."""
@pytest.fixture
def manager(self):
"""Create manager."""
return AlertManager()
def test_acknowledge_alert(self, manager):
"""Test acknowledging alert."""
alert = manager.create_alert(title="Test", message="Test")
manager.send(alert)
result = manager.acknowledge_alert(alert.alert_id, "user@example.com")
assert result is True
history = manager.get_history()
assert history[0].acknowledged is True
assert history[0].acknowledged_by == "user@example.com"
def test_acknowledge_nonexistent(self, manager):
"""Test acknowledging nonexistent alert."""
result = manager.acknowledge_alert("nonexistent", "user@example.com")
assert result is False
def test_get_unacknowledged(self, manager):
"""Test getting unacknowledged alerts."""
alert1 = manager.create_alert(title="Test1", message="Test1")
alert2 = manager.create_alert(title="Test2", message="Test2")
manager.send(alert1)
manager.send(alert2)
manager.acknowledge_alert(alert1.alert_id, "user")
unacked = manager.get_unacknowledged()
assert len(unacked) == 1
assert unacked[0].alert_id == alert2.alert_id
class TestStats:
"""Tests for statistics."""
@pytest.fixture
def manager(self):
"""Create manager."""
return AlertManager()
def test_stats_updated(self, manager):
"""Test stats are updated."""
alert = manager.create_alert(title="Test", message="Test")
manager.send(alert)
stats = manager.get_stats()
assert stats.total_sent > 0
def test_reset_stats(self, manager):
"""Test resetting stats."""
alert = manager.create_alert(title="Test", message="Test")
manager.send(alert)
manager.reset_stats()
stats = manager.get_stats()
assert stats.total_sent == 0
class TestConvenienceMethods:
"""Tests for convenience methods."""
@pytest.fixture
def manager(self):
"""Create manager."""
return AlertManager()
def test_alert_trade(self, manager):
"""Test trade alert convenience method."""
alert = manager.alert_trade(
symbol="AAPL",
action="BUY",
price=Decimal("150.00"),
reason="Momentum",
)
assert alert is not None
assert alert.category == AlertCategory.TRADE
def test_alert_risk(self, manager):
"""Test risk alert convenience method."""
alert = manager.alert_risk(
risk_type="DrawdownLimit",
current_value="15%",
limit_value="10%",
)
assert alert is not None
assert alert.category == AlertCategory.RISK
def test_alert_execution(self, manager):
"""Test execution alert convenience method."""
alert = manager.alert_execution(
order_id="order-123",
symbol="AAPL",
status="FILLED",
quantity=Decimal("100"),
price=Decimal("150.00"),
)
assert alert is not None
assert alert.category == AlertCategory.EXECUTION
# ============================================================================
# Integration Tests
# ============================================================================
class TestAlertManagerIntegration:
"""Integration tests for alert manager."""
def test_full_workflow(self):
"""Test complete alert workflow."""
# Setup manager
manager = AlertManager()
# Add custom channel
webhook = WebhookChannel("https://example.com/webhook")
manager.register_channel(webhook)
# Add custom routing rule
manager.add_routing_rule(RoutingRule(
name="critical_webhook",
priority=AlertPriority.CRITICAL,
channels=[ChannelType.WEBHOOK],
))
# Send alerts of different priorities
low_alert = manager.create_alert(
title="Info",
message="Informational message",
priority=AlertPriority.LOW,
)
manager.send(low_alert)
critical_alert = manager.create_alert(
title="Critical Issue",
message="Immediate attention required",
priority=AlertPriority.CRITICAL,
)
manager.send(critical_alert)
# Verify stats
stats = manager.get_stats()
assert stats.total_sent == 2
assert stats.by_priority.get("low", 0) >= 1
assert stats.by_priority.get("critical", 0) >= 1
def test_module_imports(self):
"""Test that all classes are exported from module."""
from tradingagents.alerts import (
AlertPriority,
AlertCategory,
AlertStatus,
ChannelType,
AlertTemplate,
RateLimitConfig,
RoutingRule,
AlertConfig,
Alert,
DeliveryResult,
AlertStats,
LogChannel,
WebhookChannel,
AlertManager,
)
# All imports successful
assert AlertPriority.CRITICAL is not None
assert AlertManager is not None
def test_multi_channel_delivery(self):
"""Test delivery to multiple channels."""
manager = AlertManager()
# Register webhook channel
webhook = WebhookChannel("https://example.com/webhook")
manager.register_channel(webhook)
# Add rule for multi-channel delivery
manager.add_routing_rule(RoutingRule(
name="all_channels",
priority=AlertPriority.HIGH,
channels=[ChannelType.LOG, ChannelType.WEBHOOK],
))
alert = manager.create_alert(
title="Multi-Channel Test",
message="Should go to multiple channels",
priority=AlertPriority.HIGH,
)
results = manager.send(alert)
# Should have results for multiple channels
assert len(results) >= 2
assert ChannelType.LOG in alert.channels_sent
assert ChannelType.WEBHOOK in alert.channels_sent