611 lines
20 KiB
Python
611 lines
20 KiB
Python
"""Tests for Slack Channel.
|
|
|
|
Issue #40: [ALERT-39] Slack channel - webhooks
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
import json
|
|
import pytest
|
|
|
|
from tradingagents.alerts.slack_channel import (
|
|
# Enums
|
|
SlackMessageStyle,
|
|
# Data Classes
|
|
SlackConfig,
|
|
SlackMessageResult,
|
|
# Classes
|
|
SlackMessageFormatter,
|
|
SlackChannel,
|
|
# Factory Functions
|
|
create_slack_channel,
|
|
)
|
|
from tradingagents.alerts.alert_manager import (
|
|
Alert,
|
|
AlertPriority,
|
|
AlertCategory,
|
|
ChannelType,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Enum Tests
|
|
# ============================================================================
|
|
|
|
class TestSlackMessageStyle:
|
|
"""Tests for SlackMessageStyle enum."""
|
|
|
|
def test_all_styles_defined(self):
|
|
"""Verify all styles exist."""
|
|
assert SlackMessageStyle.SIMPLE
|
|
assert SlackMessageStyle.BLOCKS
|
|
assert SlackMessageStyle.ATTACHMENT
|
|
|
|
def test_style_values(self):
|
|
"""Verify style values."""
|
|
assert SlackMessageStyle.SIMPLE.value == "simple"
|
|
assert SlackMessageStyle.BLOCKS.value == "blocks"
|
|
|
|
|
|
# ============================================================================
|
|
# Data Class Tests
|
|
# ============================================================================
|
|
|
|
class TestSlackConfig:
|
|
"""Tests for SlackConfig dataclass."""
|
|
|
|
def test_default_creation(self):
|
|
"""Test creating config with defaults."""
|
|
config = SlackConfig()
|
|
assert config.webhook_url == ""
|
|
assert config.username == "TradingAgents Alert"
|
|
assert config.icon_emoji == ":chart_with_upwards_trend:"
|
|
assert config.style == SlackMessageStyle.BLOCKS
|
|
|
|
def test_custom_config(self):
|
|
"""Test creating custom config."""
|
|
config = SlackConfig(
|
|
webhook_url="https://hooks.slack.com/test",
|
|
channel="#alerts",
|
|
username="CustomBot",
|
|
)
|
|
assert config.webhook_url == "https://hooks.slack.com/test"
|
|
assert config.channel == "#alerts"
|
|
assert config.username == "CustomBot"
|
|
|
|
|
|
class TestSlackMessageResult:
|
|
"""Tests for SlackMessageResult dataclass."""
|
|
|
|
def test_default_creation(self):
|
|
"""Test creating result with defaults."""
|
|
result = SlackMessageResult()
|
|
assert result.success is False
|
|
assert result.status_code == 0
|
|
assert result.attempts == 0
|
|
|
|
|
|
# ============================================================================
|
|
# Formatter Tests
|
|
# ============================================================================
|
|
|
|
class TestSlackMessageFormatter:
|
|
"""Tests for SlackMessageFormatter."""
|
|
|
|
@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 SlackConfig(
|
|
webhook_url="https://hooks.slack.com/test",
|
|
username="TestBot",
|
|
)
|
|
|
|
def test_format_simple(self, alert, config):
|
|
"""Test simple format."""
|
|
config.style = SlackMessageStyle.SIMPLE
|
|
payload = SlackMessageFormatter.format_simple(alert, config)
|
|
|
|
assert "text" in payload
|
|
assert "Test Alert" in payload["text"]
|
|
assert "This is a test message" in payload["text"]
|
|
assert payload.get("username") == "TestBot"
|
|
|
|
def test_format_blocks(self, alert, config):
|
|
"""Test blocks format."""
|
|
config.style = SlackMessageStyle.BLOCKS
|
|
payload = SlackMessageFormatter.format_blocks(alert, config)
|
|
|
|
assert "attachments" in payload
|
|
assert len(payload["attachments"]) > 0
|
|
assert "blocks" in payload["attachments"][0]
|
|
|
|
def test_format_attachment(self, alert, config):
|
|
"""Test attachment format."""
|
|
config.style = SlackMessageStyle.ATTACHMENT
|
|
payload = SlackMessageFormatter.format_attachment(alert, config)
|
|
|
|
assert "attachments" in payload
|
|
assert len(payload["attachments"]) > 0
|
|
assert "fields" in payload["attachments"][0]
|
|
|
|
def test_format_dispatcher(self, alert, config):
|
|
"""Test format dispatcher."""
|
|
config.style = SlackMessageStyle.SIMPLE
|
|
payload_simple = SlackMessageFormatter.format(alert, config)
|
|
assert "text" in payload_simple
|
|
assert "attachments" not in payload_simple
|
|
|
|
config.style = SlackMessageStyle.BLOCKS
|
|
payload_blocks = SlackMessageFormatter.format(alert, config)
|
|
assert "attachments" in payload_blocks
|
|
|
|
def test_priority_colors(self, config):
|
|
"""Test priority color mapping."""
|
|
for priority in AlertPriority:
|
|
alert = Alert(
|
|
title="Test",
|
|
message="Test",
|
|
priority=priority,
|
|
)
|
|
config.style = SlackMessageStyle.BLOCKS
|
|
payload = SlackMessageFormatter.format_blocks(alert, config)
|
|
|
|
# Check attachment has color
|
|
assert "color" in payload["attachments"][0]
|
|
|
|
def test_priority_emojis(self, config):
|
|
"""Test priority emoji mapping."""
|
|
for priority in AlertPriority:
|
|
alert = Alert(
|
|
title="Test",
|
|
message="Test",
|
|
priority=priority,
|
|
)
|
|
config.style = SlackMessageStyle.SIMPLE
|
|
payload = SlackMessageFormatter.format_simple(alert, config)
|
|
|
|
# Should have some emoji
|
|
assert ":" in payload["text"]
|
|
|
|
def test_category_emojis(self, config):
|
|
"""Test category emoji mapping."""
|
|
for category in AlertCategory:
|
|
alert = Alert(
|
|
title="Test",
|
|
message="Test",
|
|
category=category,
|
|
)
|
|
config.style = SlackMessageStyle.BLOCKS
|
|
payload = SlackMessageFormatter.format_blocks(alert, config)
|
|
|
|
# Blocks should contain category
|
|
blocks = payload["attachments"][0]["blocks"]
|
|
assert any("elements" in block for block in blocks)
|
|
|
|
def test_critical_mention(self, config):
|
|
"""Test critical alert mentions."""
|
|
config.mention_on_critical = True
|
|
config.mention_users = ["U12345", "U67890"]
|
|
|
|
alert = Alert(
|
|
title="Critical Alert",
|
|
message="Critical issue",
|
|
priority=AlertPriority.CRITICAL,
|
|
)
|
|
|
|
payload = SlackMessageFormatter.format_simple(alert, config)
|
|
assert "<@U12345>" in payload["text"]
|
|
assert "<@U67890>" in payload["text"]
|
|
|
|
def test_include_timestamp(self, config):
|
|
"""Test timestamp inclusion."""
|
|
config.include_timestamp = True
|
|
|
|
alert = Alert(
|
|
title="Test",
|
|
message="Test",
|
|
)
|
|
|
|
payload = SlackMessageFormatter.format_simple(alert, config)
|
|
assert "Time:" in payload["text"]
|
|
|
|
def test_include_source(self, config):
|
|
"""Test source inclusion."""
|
|
config.include_source = True
|
|
|
|
alert = Alert(
|
|
title="Test",
|
|
message="Test",
|
|
source="test_source",
|
|
)
|
|
|
|
payload = SlackMessageFormatter.format_simple(alert, config)
|
|
assert "Source:" in payload["text"]
|
|
|
|
def test_data_fields(self, alert, config):
|
|
"""Test data fields in attachment format."""
|
|
config.style = SlackMessageStyle.ATTACHMENT
|
|
payload = SlackMessageFormatter.format_attachment(alert, config)
|
|
|
|
fields = payload["attachments"][0]["fields"]
|
|
field_titles = [f["title"] for f in fields]
|
|
|
|
assert "Category" in field_titles
|
|
assert "Priority" in field_titles
|
|
|
|
def test_channel_override(self, alert, config):
|
|
"""Test channel override."""
|
|
config.channel = "#custom-channel"
|
|
payload = SlackMessageFormatter.format_simple(alert, config)
|
|
|
|
assert payload.get("channel") == "#custom-channel"
|
|
|
|
|
|
# ============================================================================
|
|
# SlackChannel Tests
|
|
# ============================================================================
|
|
|
|
class TestSlackChannel:
|
|
"""Tests for SlackChannel class."""
|
|
|
|
@pytest.fixture
|
|
def channel(self):
|
|
"""Create test channel."""
|
|
return SlackChannel("https://hooks.slack.com/test")
|
|
|
|
@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_url(self):
|
|
"""Test initialization with URL."""
|
|
channel = SlackChannel("https://hooks.slack.com/test")
|
|
assert channel.config.webhook_url == "https://hooks.slack.com/test"
|
|
|
|
def test_initialization_with_config(self):
|
|
"""Test initialization with config."""
|
|
config = SlackConfig(
|
|
webhook_url="https://hooks.slack.com/test",
|
|
username="CustomBot",
|
|
)
|
|
channel = SlackChannel(config=config)
|
|
assert channel.config.username == "CustomBot"
|
|
|
|
def test_channel_type(self, channel):
|
|
"""Test channel type."""
|
|
assert channel.channel_type == ChannelType.SLACK
|
|
|
|
def test_is_available_with_url(self, channel):
|
|
"""Test availability with URL."""
|
|
assert channel.is_available is True
|
|
|
|
def test_is_available_without_url(self):
|
|
"""Test availability without URL."""
|
|
channel = SlackChannel("")
|
|
assert channel.is_available is False
|
|
|
|
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_url(self):
|
|
"""Test config validation with no URL."""
|
|
channel = SlackChannel("")
|
|
is_valid, error = channel.validate_config()
|
|
assert is_valid is False
|
|
assert "required" in error.lower()
|
|
|
|
def test_validate_config_invalid_url(self):
|
|
"""Test config validation with invalid URL."""
|
|
channel = SlackChannel("https://example.com/webhook")
|
|
is_valid, error = channel.validate_config()
|
|
assert is_valid is False
|
|
assert "invalid" in error.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_not_available(self, alert):
|
|
"""Test send when not available."""
|
|
channel = SlackChannel("")
|
|
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_webhook",
|
|
return_value={"success": True, "status_code": 200, "body": "ok"},
|
|
):
|
|
result = await channel.send_with_result(alert)
|
|
|
|
assert result.success is True
|
|
assert result.status_code == 200
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_failure(self, channel, alert):
|
|
"""Test failed send."""
|
|
with patch.object(
|
|
channel,
|
|
"_send_webhook",
|
|
return_value={"success": False, "status_code": 400, "body": "error"},
|
|
):
|
|
result = await channel.send_with_result(alert)
|
|
|
|
assert result.success is False
|
|
assert result.status_code == 400
|
|
|
|
@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(payload):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count < 3:
|
|
return {"success": False, "status_code": 500, "body": "error"}
|
|
return {"success": True, "status_code": 200, "body": "ok"}
|
|
|
|
with patch.object(channel, "_send_webhook", 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_webhook",
|
|
return_value={"success": False, "status_code": 400, "body": "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_rate_limited(self, channel, alert):
|
|
"""Test rate limit handling."""
|
|
channel.config.retry_count = 2
|
|
channel.config.retry_delay_seconds = 0.01
|
|
|
|
call_count = 0
|
|
|
|
async def mock_send(payload):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
return {"success": False, "status_code": 429, "body": "rate limited", "retry_after": 0.01}
|
|
return {"success": True, "status_code": 200, "body": "ok"}
|
|
|
|
with patch.object(channel, "_send_webhook", side_effect=mock_send):
|
|
result = await channel.send_with_result(alert)
|
|
|
|
assert result.success is True
|
|
assert result.attempts == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_latency_tracked(self, channel, alert):
|
|
"""Test latency tracking."""
|
|
with patch.object(
|
|
channel,
|
|
"_send_webhook",
|
|
return_value={"success": True, "status_code": 200, "body": "ok"},
|
|
):
|
|
result = await channel.send_with_result(alert)
|
|
|
|
assert result.latency_ms > 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_bool_return(self, channel, alert):
|
|
"""Test send returns bool."""
|
|
with patch.object(
|
|
channel,
|
|
"_send_webhook",
|
|
return_value={"success": True, "status_code": 200, "body": "ok"},
|
|
):
|
|
result = await channel.send(alert)
|
|
|
|
assert result is True
|
|
|
|
|
|
class TestSlackChannelIntegration:
|
|
"""Integration tests for Slack channel."""
|
|
|
|
def test_with_alert_manager(self):
|
|
"""Test integration with AlertManager."""
|
|
from tradingagents.alerts.alert_manager import AlertManager, RoutingRule
|
|
|
|
manager = AlertManager()
|
|
|
|
# Create and register Slack channel
|
|
slack = SlackChannel("https://hooks.slack.com/test")
|
|
manager.register_channel(slack)
|
|
|
|
# Add routing rule
|
|
manager.add_routing_rule(RoutingRule(
|
|
name="slack_alerts",
|
|
priority=AlertPriority.HIGH,
|
|
channels=[ChannelType.SLACK],
|
|
))
|
|
|
|
assert ChannelType.SLACK in manager.channels
|
|
|
|
def test_module_imports(self):
|
|
"""Test that all classes are exported from module."""
|
|
from tradingagents.alerts import (
|
|
SlackMessageStyle,
|
|
SlackConfig,
|
|
SlackMessageResult,
|
|
SlackMessageFormatter,
|
|
SlackChannel,
|
|
create_slack_channel,
|
|
)
|
|
|
|
# All imports successful
|
|
assert SlackMessageStyle.BLOCKS is not None
|
|
assert SlackChannel is not None
|
|
|
|
def test_create_slack_channel_factory(self):
|
|
"""Test factory function."""
|
|
channel = create_slack_channel(
|
|
webhook_url="https://hooks.slack.com/test",
|
|
channel="#alerts",
|
|
username="CustomBot",
|
|
style=SlackMessageStyle.SIMPLE,
|
|
mention_users=["U12345"],
|
|
)
|
|
|
|
assert channel.config.webhook_url == "https://hooks.slack.com/test"
|
|
assert channel.config.channel == "#alerts"
|
|
assert channel.config.username == "CustomBot"
|
|
assert channel.config.style == SlackMessageStyle.SIMPLE
|
|
assert "U12345" in channel.config.mention_users
|
|
|
|
def test_test_webhook_method(self):
|
|
"""Test the test_webhook method."""
|
|
channel = SlackChannel("https://hooks.slack.com/test")
|
|
|
|
with patch.object(
|
|
channel,
|
|
"_send_webhook_sync",
|
|
return_value={"success": True, "status_code": 200, "body": "ok"},
|
|
):
|
|
result = channel.test_webhook()
|
|
|
|
assert result.success is True
|
|
|
|
def test_message_formatting_all_styles(self):
|
|
"""Test all message formatting styles."""
|
|
alert = Alert(
|
|
title="Test",
|
|
message="Test message",
|
|
priority=AlertPriority.HIGH,
|
|
category=AlertCategory.RISK,
|
|
data={"key": "value"},
|
|
)
|
|
|
|
for style in SlackMessageStyle:
|
|
config = SlackConfig(
|
|
webhook_url="https://hooks.slack.com/test",
|
|
style=style,
|
|
)
|
|
channel = SlackChannel(config=config)
|
|
|
|
# Should not raise
|
|
payload = SlackMessageFormatter.format(alert, config)
|
|
assert payload is not None
|
|
assert "text" in payload or "attachments" in payload
|
|
|
|
|
|
class TestSlackChannelFormatting:
|
|
"""Tests for Slack message formatting edge cases."""
|
|
|
|
@pytest.fixture
|
|
def config(self):
|
|
"""Create test config."""
|
|
return SlackConfig(webhook_url="https://hooks.slack.com/test")
|
|
|
|
def test_empty_message(self, config):
|
|
"""Test formatting empty message."""
|
|
alert = Alert(
|
|
title="",
|
|
message="",
|
|
)
|
|
|
|
payload = SlackMessageFormatter.format_simple(alert, config)
|
|
assert "text" in payload
|
|
|
|
def test_long_message(self, config):
|
|
"""Test formatting long message."""
|
|
alert = Alert(
|
|
title="Long Alert",
|
|
message="x" * 5000,
|
|
)
|
|
|
|
payload = SlackMessageFormatter.format_blocks(alert, config)
|
|
# Should not truncate
|
|
assert "x" * 100 in str(payload)
|
|
|
|
def test_special_characters(self, config):
|
|
"""Test formatting with special characters."""
|
|
alert = Alert(
|
|
title="Alert <test> & \"quotes\"",
|
|
message="Message with `code` and *markdown*",
|
|
)
|
|
|
|
payload = SlackMessageFormatter.format_simple(alert, config)
|
|
assert "<test>" in payload["text"]
|
|
|
|
def test_unicode_characters(self, config):
|
|
"""Test formatting with unicode."""
|
|
alert = Alert(
|
|
title="Alert with emoji",
|
|
message="Price target reached",
|
|
)
|
|
|
|
payload = SlackMessageFormatter.format_blocks(alert, config)
|
|
assert payload is not None
|
|
|
|
def test_many_data_fields(self, config):
|
|
"""Test formatting with many data fields."""
|
|
data = {f"field_{i}": f"value_{i}" for i in range(20)}
|
|
|
|
alert = Alert(
|
|
title="Data Alert",
|
|
message="Many fields",
|
|
data=data,
|
|
)
|
|
|
|
# Blocks format limits fields
|
|
payload = SlackMessageFormatter.format_blocks(alert, config)
|
|
blocks = payload["attachments"][0]["blocks"]
|
|
|
|
# Should have some data fields
|
|
assert any("field_" in str(block) for block in blocks)
|
|
|
|
def test_no_mention_without_users(self, config):
|
|
"""Test critical alert without mention users."""
|
|
config.mention_on_critical = True
|
|
config.mention_users = []
|
|
|
|
alert = Alert(
|
|
title="Critical",
|
|
message="Critical issue",
|
|
priority=AlertPriority.CRITICAL,
|
|
)
|
|
|
|
payload = SlackMessageFormatter.format_simple(alert, config)
|
|
# Should not have @mentions
|
|
assert "<@" not in payload["text"]
|