- Use contextlib.suppress instead of try/except/pass (SIM105) - Prefix unused fixture arguments with underscore (ARG002) - Replace asyncio.TimeoutError with TimeoutError (UP041) - Apply ruff formatting to all files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
493 lines
16 KiB
Python
493 lines
16 KiB
Python
"""Tests for alert dispatcher and channels."""
|
|
|
|
from datetime import UTC, datetime
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from polymarket_insider_tracker.alerter.channels.discord import DiscordChannel
|
|
from polymarket_insider_tracker.alerter.channels.telegram import TelegramChannel
|
|
from polymarket_insider_tracker.alerter.dispatcher import (
|
|
AlertDispatcher,
|
|
CircuitBreakerState,
|
|
DispatchResult,
|
|
)
|
|
from polymarket_insider_tracker.alerter.models import FormattedAlert
|
|
|
|
# ============================================================================
|
|
# Fixtures
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_alert() -> FormattedAlert:
|
|
"""Create a sample formatted alert."""
|
|
return FormattedAlert(
|
|
title="Test Alert",
|
|
body="Test body",
|
|
discord_embed={
|
|
"title": "Test",
|
|
"color": 15158332,
|
|
"fields": [],
|
|
},
|
|
telegram_markdown="*Test Alert*\nTest body",
|
|
plain_text="TEST ALERT\nTest body",
|
|
links={"market": "https://polymarket.com/test"},
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_discord_channel() -> MagicMock:
|
|
"""Create a mock Discord channel."""
|
|
channel = MagicMock()
|
|
channel.name = "discord"
|
|
channel.send = AsyncMock(return_value=True)
|
|
return channel
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_telegram_channel() -> MagicMock:
|
|
"""Create a mock Telegram channel."""
|
|
channel = MagicMock()
|
|
channel.name = "telegram"
|
|
channel.send = AsyncMock(return_value=True)
|
|
return channel
|
|
|
|
|
|
# ============================================================================
|
|
# DiscordChannel Tests
|
|
# ============================================================================
|
|
|
|
|
|
class TestDiscordChannel:
|
|
"""Tests for Discord channel."""
|
|
|
|
def test_init(self) -> None:
|
|
"""Test channel initialization."""
|
|
channel = DiscordChannel(
|
|
webhook_url="https://discord.com/api/webhooks/123/abc",
|
|
rate_limit_per_minute=30,
|
|
)
|
|
assert channel.webhook_url == "https://discord.com/api/webhooks/123/abc"
|
|
assert channel.rate_limit_per_minute == 30
|
|
assert channel.name == "discord"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_success(self, sample_alert: FormattedAlert) -> None:
|
|
"""Test successful Discord message send."""
|
|
channel = DiscordChannel(webhook_url="https://discord.com/api/webhooks/123/abc")
|
|
|
|
with patch("httpx.AsyncClient") as mock_client_class:
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 204
|
|
mock_client = AsyncMock()
|
|
mock_client.post.return_value = mock_response
|
|
mock_client.__aenter__.return_value = mock_client
|
|
mock_client.__aexit__.return_value = None
|
|
mock_client_class.return_value = mock_client
|
|
|
|
result = await channel.send(sample_alert)
|
|
|
|
assert result is True
|
|
mock_client.post.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_rate_limited(self, sample_alert: FormattedAlert) -> None:
|
|
"""Test Discord rate limit handling."""
|
|
channel = DiscordChannel(
|
|
webhook_url="https://discord.com/api/webhooks/123/abc",
|
|
max_retries=2,
|
|
retry_delay=0.01,
|
|
)
|
|
|
|
with patch("httpx.AsyncClient") as mock_client_class:
|
|
mock_response_429 = MagicMock()
|
|
mock_response_429.status_code = 429
|
|
mock_response_429.json.return_value = {"retry_after": 0.01}
|
|
|
|
mock_response_success = MagicMock()
|
|
mock_response_success.status_code = 204
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post.side_effect = [mock_response_429, mock_response_success]
|
|
mock_client.__aenter__.return_value = mock_client
|
|
mock_client.__aexit__.return_value = None
|
|
mock_client_class.return_value = mock_client
|
|
|
|
result = await channel.send(sample_alert)
|
|
|
|
assert result is True
|
|
assert mock_client.post.call_count == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_failure(self, sample_alert: FormattedAlert) -> None:
|
|
"""Test Discord send failure after retries."""
|
|
channel = DiscordChannel(
|
|
webhook_url="https://discord.com/api/webhooks/123/abc",
|
|
max_retries=2,
|
|
retry_delay=0.01,
|
|
)
|
|
|
|
with patch("httpx.AsyncClient") as mock_client_class:
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 500
|
|
mock_response.text = "Internal Server Error"
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post.return_value = mock_response
|
|
mock_client.__aenter__.return_value = mock_client
|
|
mock_client.__aexit__.return_value = None
|
|
mock_client_class.return_value = mock_client
|
|
|
|
result = await channel.send(sample_alert)
|
|
|
|
assert result is False
|
|
|
|
|
|
# ============================================================================
|
|
# TelegramChannel Tests
|
|
# ============================================================================
|
|
|
|
|
|
class TestTelegramChannel:
|
|
"""Tests for Telegram channel."""
|
|
|
|
def test_init(self) -> None:
|
|
"""Test channel initialization."""
|
|
channel = TelegramChannel(
|
|
bot_token="123456:ABC-DEF",
|
|
chat_id="-1001234567890",
|
|
rate_limit_per_minute=20,
|
|
)
|
|
assert channel.bot_token == "123456:ABC-DEF"
|
|
assert channel.chat_id == "-1001234567890"
|
|
assert channel.name == "telegram"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_success(self, sample_alert: FormattedAlert) -> None:
|
|
"""Test successful Telegram message send."""
|
|
channel = TelegramChannel(
|
|
bot_token="123456:ABC-DEF",
|
|
chat_id="-1001234567890",
|
|
)
|
|
|
|
with patch("httpx.AsyncClient") as mock_client_class:
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"ok": True}
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post.return_value = mock_response
|
|
mock_client.__aenter__.return_value = mock_client
|
|
mock_client.__aexit__.return_value = None
|
|
mock_client_class.return_value = mock_client
|
|
|
|
result = await channel.send(sample_alert)
|
|
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_rate_limited(self, sample_alert: FormattedAlert) -> None:
|
|
"""Test Telegram rate limit handling."""
|
|
channel = TelegramChannel(
|
|
bot_token="123456:ABC-DEF",
|
|
chat_id="-1001234567890",
|
|
max_retries=2,
|
|
retry_delay=0.01,
|
|
)
|
|
|
|
with patch("httpx.AsyncClient") as mock_client_class:
|
|
mock_response_429 = MagicMock()
|
|
mock_response_429.json.return_value = {
|
|
"ok": False,
|
|
"error_code": 429,
|
|
"parameters": {"retry_after": 0.01},
|
|
}
|
|
|
|
mock_response_success = MagicMock()
|
|
mock_response_success.json.return_value = {"ok": True}
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post.side_effect = [mock_response_429, mock_response_success]
|
|
mock_client.__aenter__.return_value = mock_client
|
|
mock_client.__aexit__.return_value = None
|
|
mock_client_class.return_value = mock_client
|
|
|
|
result = await channel.send(sample_alert)
|
|
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_failure(self, sample_alert: FormattedAlert) -> None:
|
|
"""Test Telegram send failure."""
|
|
channel = TelegramChannel(
|
|
bot_token="123456:ABC-DEF",
|
|
chat_id="-1001234567890",
|
|
max_retries=2,
|
|
retry_delay=0.01,
|
|
)
|
|
|
|
with patch("httpx.AsyncClient") as mock_client_class:
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"ok": False,
|
|
"error_code": 400,
|
|
"description": "Bad Request",
|
|
}
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post.return_value = mock_response
|
|
mock_client.__aenter__.return_value = mock_client
|
|
mock_client.__aexit__.return_value = None
|
|
mock_client_class.return_value = mock_client
|
|
|
|
result = await channel.send(sample_alert)
|
|
|
|
assert result is False
|
|
|
|
|
|
# ============================================================================
|
|
# CircuitBreakerState Tests
|
|
# ============================================================================
|
|
|
|
|
|
class TestCircuitBreakerState:
|
|
"""Tests for circuit breaker state."""
|
|
|
|
def test_default_state(self) -> None:
|
|
"""Test default circuit breaker state."""
|
|
state = CircuitBreakerState()
|
|
assert state.failure_count == 0
|
|
assert state.is_open is False
|
|
assert state.half_open_attempts == 0
|
|
assert state.last_failure_time is None
|
|
|
|
|
|
# ============================================================================
|
|
# DispatchResult Tests
|
|
# ============================================================================
|
|
|
|
|
|
class TestDispatchResult:
|
|
"""Tests for dispatch result."""
|
|
|
|
def test_all_succeeded(self) -> None:
|
|
"""Test all_succeeded property."""
|
|
result = DispatchResult(
|
|
success_count=2,
|
|
failure_count=0,
|
|
channel_results={"discord": True, "telegram": True},
|
|
)
|
|
assert result.all_succeeded is True
|
|
|
|
def test_partial_success(self) -> None:
|
|
"""Test partial success."""
|
|
result = DispatchResult(
|
|
success_count=1,
|
|
failure_count=1,
|
|
channel_results={"discord": True, "telegram": False},
|
|
)
|
|
assert result.all_succeeded is False
|
|
|
|
def test_empty_channels(self) -> None:
|
|
"""Test with no channels."""
|
|
result = DispatchResult(success_count=0, failure_count=0)
|
|
assert result.all_succeeded is False
|
|
|
|
|
|
# ============================================================================
|
|
# AlertDispatcher Tests
|
|
# ============================================================================
|
|
|
|
|
|
class TestAlertDispatcher:
|
|
"""Tests for alert dispatcher."""
|
|
|
|
def test_init(
|
|
self,
|
|
mock_discord_channel: MagicMock,
|
|
mock_telegram_channel: MagicMock,
|
|
) -> None:
|
|
"""Test dispatcher initialization."""
|
|
dispatcher = AlertDispatcher(channels=[mock_discord_channel, mock_telegram_channel])
|
|
assert len(dispatcher.channels) == 2
|
|
assert "discord" in dispatcher._circuit_state
|
|
assert "telegram" in dispatcher._circuit_state
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_all_success(
|
|
self,
|
|
sample_alert: FormattedAlert,
|
|
mock_discord_channel: MagicMock,
|
|
mock_telegram_channel: MagicMock,
|
|
) -> None:
|
|
"""Test successful dispatch to all channels."""
|
|
dispatcher = AlertDispatcher(channels=[mock_discord_channel, mock_telegram_channel])
|
|
|
|
result = await dispatcher.dispatch(sample_alert)
|
|
|
|
assert result.success_count == 2
|
|
assert result.failure_count == 0
|
|
assert result.all_succeeded is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_partial_failure(
|
|
self,
|
|
sample_alert: FormattedAlert,
|
|
mock_discord_channel: MagicMock,
|
|
mock_telegram_channel: MagicMock,
|
|
) -> None:
|
|
"""Test dispatch with one channel failing."""
|
|
mock_telegram_channel.send.return_value = False
|
|
|
|
dispatcher = AlertDispatcher(channels=[mock_discord_channel, mock_telegram_channel])
|
|
|
|
result = await dispatcher.dispatch(sample_alert)
|
|
|
|
assert result.success_count == 1
|
|
assert result.failure_count == 1
|
|
assert result.channel_results["discord"] is True
|
|
assert result.channel_results["telegram"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_no_channels(self, sample_alert: FormattedAlert) -> None:
|
|
"""Test dispatch with no channels configured."""
|
|
dispatcher = AlertDispatcher(channels=[])
|
|
|
|
result = await dispatcher.dispatch(sample_alert)
|
|
|
|
assert result.success_count == 0
|
|
assert result.failure_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_circuit_opens_after_failures(
|
|
self,
|
|
sample_alert: FormattedAlert,
|
|
mock_discord_channel: MagicMock,
|
|
) -> None:
|
|
"""Test circuit breaker opens after threshold failures."""
|
|
mock_discord_channel.send.return_value = False
|
|
|
|
dispatcher = AlertDispatcher(
|
|
channels=[mock_discord_channel],
|
|
failure_threshold=3,
|
|
)
|
|
|
|
# First 3 failures
|
|
for _ in range(3):
|
|
await dispatcher.dispatch(sample_alert)
|
|
|
|
# Circuit should be open now
|
|
assert dispatcher._circuit_state["discord"].is_open is True
|
|
assert dispatcher._circuit_state["discord"].failure_count == 3
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_circuit_skips_when_open(
|
|
self,
|
|
sample_alert: FormattedAlert,
|
|
mock_discord_channel: MagicMock,
|
|
) -> None:
|
|
"""Test that open circuit skips delivery."""
|
|
dispatcher = AlertDispatcher(
|
|
channels=[mock_discord_channel],
|
|
failure_threshold=3,
|
|
recovery_timeout_seconds=3600, # Long timeout
|
|
)
|
|
|
|
# Manually open the circuit
|
|
dispatcher._circuit_state["discord"].is_open = True
|
|
dispatcher._circuit_state["discord"].last_failure_time = datetime.now(UTC)
|
|
|
|
result = await dispatcher.dispatch(sample_alert)
|
|
|
|
assert result.channel_results["discord"] is False
|
|
# send() should not be called
|
|
mock_discord_channel.send.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_circuit_closes_on_success(
|
|
self,
|
|
sample_alert: FormattedAlert,
|
|
mock_discord_channel: MagicMock,
|
|
) -> None:
|
|
"""Test circuit closes on successful delivery."""
|
|
mock_discord_channel.send.return_value = False
|
|
|
|
dispatcher = AlertDispatcher(
|
|
channels=[mock_discord_channel],
|
|
failure_threshold=2,
|
|
)
|
|
|
|
# Cause failures to open circuit
|
|
await dispatcher.dispatch(sample_alert)
|
|
await dispatcher.dispatch(sample_alert)
|
|
assert dispatcher._circuit_state["discord"].is_open is True
|
|
|
|
# Now succeed
|
|
mock_discord_channel.send.return_value = True
|
|
# Force half-open by resetting last_failure to past
|
|
dispatcher._circuit_state["discord"].last_failure_time = datetime(2020, 1, 1, tzinfo=UTC)
|
|
|
|
result = await dispatcher.dispatch(sample_alert)
|
|
|
|
assert result.channel_results["discord"] is True
|
|
assert dispatcher._circuit_state["discord"].is_open is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_batch(
|
|
self,
|
|
sample_alert: FormattedAlert,
|
|
mock_discord_channel: MagicMock,
|
|
) -> None:
|
|
"""Test batch dispatch."""
|
|
dispatcher = AlertDispatcher(channels=[mock_discord_channel])
|
|
|
|
alerts = [sample_alert, sample_alert, sample_alert]
|
|
results = await dispatcher.dispatch_batch(alerts)
|
|
|
|
assert len(results) == 3
|
|
assert all(r.success_count == 1 for r in results)
|
|
|
|
def test_get_circuit_status(
|
|
self,
|
|
mock_discord_channel: MagicMock,
|
|
mock_telegram_channel: MagicMock,
|
|
) -> None:
|
|
"""Test getting circuit status."""
|
|
dispatcher = AlertDispatcher(channels=[mock_discord_channel, mock_telegram_channel])
|
|
|
|
status = dispatcher.get_circuit_status()
|
|
|
|
assert "discord" in status
|
|
assert "telegram" in status
|
|
assert status["discord"]["is_open"] is False
|
|
assert status["discord"]["failure_count"] == 0
|
|
|
|
def test_reset_circuit(
|
|
self,
|
|
mock_discord_channel: MagicMock,
|
|
) -> None:
|
|
"""Test manual circuit reset."""
|
|
dispatcher = AlertDispatcher(channels=[mock_discord_channel])
|
|
|
|
# Set up failure state
|
|
dispatcher._circuit_state["discord"].failure_count = 5
|
|
dispatcher._circuit_state["discord"].is_open = True
|
|
|
|
# Reset
|
|
result = dispatcher.reset_circuit("discord")
|
|
|
|
assert result is True
|
|
assert dispatcher._circuit_state["discord"].failure_count == 0
|
|
assert dispatcher._circuit_state["discord"].is_open is False
|
|
|
|
def test_reset_circuit_unknown_channel(
|
|
self,
|
|
mock_discord_channel: MagicMock,
|
|
) -> None:
|
|
"""Test reset with unknown channel name."""
|
|
dispatcher = AlertDispatcher(channels=[mock_discord_channel])
|
|
|
|
result = dispatcher.reset_circuit("unknown")
|
|
|
|
assert result is False
|