"""Tests for the SlackNotifier used by trade-executor.""" from __future__ import annotations from datetime import datetime, timezone from decimal import Decimal from unittest.mock import AsyncMock, patch from uuid import UUID, uuid4 import pytest from services.trade_executor.slack_notifier import SlackNotifier from shared.constants.kevin import KEVIN_STRATEGY_UUID from shared.schemas.trading import ( OrderResult, OrderSide, OrderStatus, SignalDirection, TradeSignal, ) def _signal(strategy_id=None, target_dollars=None) -> TradeSignal: return TradeSignal( ticker="NVDA", direction=SignalDirection.LONG, strength=0.85, strategy_sources=["meet_kevin:buy:0.85"], timestamp=datetime.now(timezone.utc), strategy_id=strategy_id, target_dollars=target_dollars, ) def _filled_order(ticker="NVDA", qty=10, price=217.50) -> OrderResult: return OrderResult( order_id=str(uuid4()), ticker=ticker, side=OrderSide.BUY, qty=qty, filled_price=price, status=OrderStatus.FILLED, timestamp=datetime.now(timezone.utc), ) class TestSlackNotifierNoWebhook: """Empty webhook_url -> notifier is a no-op (returns without raising).""" @pytest.mark.asyncio async def test_notify_trade_noop(self): notifier = SlackNotifier(webhook_url="") # should not raise even with no mock await notifier.notify_trade(_signal(), _filled_order()) @pytest.mark.asyncio async def test_notify_rejection_noop(self): notifier = SlackNotifier(webhook_url="") await notifier.notify_rejection(_signal(), "outside_market_hours") class TestSlackNotifierTradePost: @pytest.mark.asyncio async def test_trade_post_calls_webhook(self): notifier = SlackNotifier(webhook_url="https://hooks.slack.test/abc") with patch("httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_post = AsyncMock(return_value=AsyncMock(status_code=200)) mock_client.post = mock_post mock_client_cls.return_value.__aenter__.return_value = mock_client await notifier.notify_trade( _signal(strategy_id=KEVIN_STRATEGY_UUID), _filled_order(qty=10, price=217.50), ) mock_post.assert_called_once() url, kwargs = mock_post.call_args.args[0], mock_post.call_args.kwargs assert url == "https://hooks.slack.test/abc" payload = kwargs["json"] assert "NVDA" in payload["text"] assert "10" in payload["text"] assert "217.50" in payload["text"] @pytest.mark.asyncio async def test_trade_post_strategy_tag_when_kevin(self): notifier = SlackNotifier(webhook_url="https://hooks.slack.test/abc") with patch("httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client.post = AsyncMock(return_value=AsyncMock(status_code=200)) mock_client_cls.return_value.__aenter__.return_value = mock_client await notifier.notify_trade( _signal(strategy_id=KEVIN_STRATEGY_UUID), _filled_order(), ) payload = mock_client.post.call_args.kwargs["json"] assert "Meet Kevin" in payload["text"] @pytest.mark.asyncio async def test_trade_post_swallows_http_errors(self): """A failed Slack post must NOT bubble up — the trade already happened; we shouldn't crash the consumer loop because Slack is having a bad day.""" notifier = SlackNotifier(webhook_url="https://hooks.slack.test/abc") with patch("httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client.post = AsyncMock(side_effect=Exception("network down")) mock_client_cls.return_value.__aenter__.return_value = mock_client # should NOT raise await notifier.notify_trade(_signal(), _filled_order()) class TestSlackNotifierBotToken: @pytest.mark.asyncio async def test_bot_token_calls_chat_postmessage(self): notifier = SlackNotifier(bot_token="xoxb-test", channel="trading-bot") assert notifier.uses_bot_token with patch("httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_resp = AsyncMock() mock_resp.json = lambda: {"ok": True, "ts": "1.2"} mock_client.post = AsyncMock(return_value=mock_resp) mock_client_cls.return_value.__aenter__.return_value = mock_client await notifier.notify_trade(_signal(), _filled_order()) url = mock_client.post.call_args.args[0] assert url == "https://slack.com/api/chat.postMessage" kwargs = mock_client.post.call_args.kwargs assert kwargs["headers"]["Authorization"] == "Bearer xoxb-test" body = kwargs["json"] assert body["channel"] == "trading-bot" assert "NVDA" in body["text"] @pytest.mark.asyncio async def test_bot_token_swallows_channel_not_found(self): """When the user hasn't created #trading-bot yet, the API returns ok=false / error=channel_not_found. We log and continue.""" notifier = SlackNotifier(bot_token="xoxb-test", channel="nonexistent") with patch("httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_resp = AsyncMock() mock_resp.json = lambda: {"ok": False, "error": "channel_not_found"} mock_client.post = AsyncMock(return_value=mock_resp) mock_client_cls.return_value.__aenter__.return_value = mock_client # should not raise await notifier.notify_trade(_signal(), _filled_order()) @pytest.mark.asyncio async def test_bot_token_wins_when_both_set(self): notifier = SlackNotifier( webhook_url="https://hooks.slack.test/abc", bot_token="xoxb-test", channel="trading-bot", ) with patch("httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client.post = AsyncMock(return_value=AsyncMock(json=lambda: {"ok": True})) mock_client_cls.return_value.__aenter__.return_value = mock_client await notifier.notify_trade(_signal(), _filled_order()) assert mock_client.post.call_args.args[0] == "https://slack.com/api/chat.postMessage" class TestSlackNotifierRejectionPost: @pytest.mark.asyncio async def test_rejection_post_calls_webhook(self): notifier = SlackNotifier(webhook_url="https://hooks.slack.test/abc") with patch("httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client.post = AsyncMock(return_value=AsyncMock(status_code=200)) mock_client_cls.return_value.__aenter__.return_value = mock_client await notifier.notify_rejection( _signal(strategy_id=KEVIN_STRATEGY_UUID), reason="kevin_daily_trade_cap", ) payload = mock_client.post.call_args.kwargs["json"] assert "REJECTED" in payload["text"] assert "kevin_daily_trade_cap" in payload["text"] assert "NVDA" in payload["text"] @pytest.mark.asyncio async def test_rejection_post_skips_noise(self): """outside_market_hours fires every minute when the bot tries to trade after-hours — we don't want a Slack barrage. The notifier skips it.""" notifier = SlackNotifier( webhook_url="https://hooks.slack.test/abc", quiet_rejections={"outside_market_hours"}, ) with patch("httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client.post = AsyncMock() mock_client_cls.return_value.__aenter__.return_value = mock_client await notifier.notify_rejection(_signal(), "outside_market_hours") mock_client.post.assert_not_called()