""" Tests for notification service, models, and API endpoints. """ import pytest from datetime import datetime, timedelta from decimal import Decimal from unittest.mock import AsyncMock, patch from app.models.signal import Signal, SignalType, SignalStatus from app.models.notification import ( NotificationSetting, NotificationHistory, ChannelType, NotificationStatus, ) from app.services.notification import ( format_signal_message, _is_duplicate, send_notification, ) # --- format_signal_message tests --- class TestFormatSignalMessage: def test_buy_signal_full(self): signal = Signal( ticker="005930", name="삼성전자", signal_type=SignalType.BUY, entry_price=Decimal("72000"), target_price=Decimal("75600"), stop_loss_price=Decimal("69840"), reason="breakout, large_candle", ) msg = format_signal_message(signal) assert "삼성전자" in msg assert "005930" in msg assert "매수" in msg assert "72,000" in msg assert "75,600" in msg assert "69,840" in msg assert "breakout" in msg def test_sell_signal_minimal(self): signal = Signal( ticker="035420", name=None, signal_type=SignalType.SELL, entry_price=None, target_price=None, stop_loss_price=None, reason=None, ) msg = format_signal_message(signal) assert "035420" in msg assert "매도" in msg assert "진입가" not in msg assert "목표가" not in msg def test_partial_sell_signal(self): signal = Signal( ticker="000660", name="SK하이닉스", signal_type=SignalType.PARTIAL_SELL, entry_price=Decimal("150000"), ) msg = format_signal_message(signal) assert "부분매도" in msg assert "150,000" in msg # --- _is_duplicate tests --- class TestIsDuplicate: def test_no_duplicate_when_empty(self, db): assert _is_duplicate(db, signal_id=999, channel_type=ChannelType.DISCORD) is False def test_duplicate_within_24h(self, db): history = NotificationHistory( signal_id=1, channel_type=ChannelType.DISCORD, status=NotificationStatus.SENT, sent_at=datetime.utcnow() - timedelta(hours=1), message="test", ) db.add(history) db.commit() assert _is_duplicate(db, signal_id=1, channel_type=ChannelType.DISCORD) is True def test_no_duplicate_after_24h(self, db): history = NotificationHistory( signal_id=1, channel_type=ChannelType.DISCORD, status=NotificationStatus.SENT, sent_at=datetime.utcnow() - timedelta(hours=25), message="test", ) db.add(history) db.commit() assert _is_duplicate(db, signal_id=1, channel_type=ChannelType.DISCORD) is False def test_failed_notification_not_duplicate(self, db): history = NotificationHistory( signal_id=1, channel_type=ChannelType.DISCORD, status=NotificationStatus.FAILED, sent_at=datetime.utcnow(), message="test", ) db.add(history) db.commit() assert _is_duplicate(db, signal_id=1, channel_type=ChannelType.DISCORD) is False def test_different_channel_not_duplicate(self, db): history = NotificationHistory( signal_id=1, channel_type=ChannelType.DISCORD, status=NotificationStatus.SENT, sent_at=datetime.utcnow(), message="test", ) db.add(history) db.commit() assert _is_duplicate(db, signal_id=1, channel_type=ChannelType.TELEGRAM) is False # --- send_notification tests --- class TestSendNotification: @pytest.mark.asyncio async def test_sends_to_enabled_channels(self, db, test_user): setting = NotificationSetting( user_id=test_user.id, channel_type=ChannelType.DISCORD, webhook_url="https://discord.com/api/webhooks/test", enabled=True, ) db.add(setting) signal = Signal( id=1, date=datetime.utcnow().date(), ticker="005930", name="삼성전자", signal_type=SignalType.BUY, entry_price=Decimal("72000"), status=SignalStatus.ACTIVE, ) db.add(signal) db.commit() with patch("app.services.notification.send_discord", new_callable=AsyncMock) as mock_discord: await send_notification(signal, db) mock_discord.assert_called_once() history = db.query(NotificationHistory).first() assert history is not None assert history.status == NotificationStatus.SENT assert history.signal_id == 1 @pytest.mark.asyncio async def test_skips_disabled_channels(self, db, test_user): setting = NotificationSetting( user_id=test_user.id, channel_type=ChannelType.DISCORD, webhook_url="https://discord.com/api/webhooks/test", enabled=False, ) db.add(setting) signal = Signal( id=2, date=datetime.utcnow().date(), ticker="005930", name="삼성전자", signal_type=SignalType.BUY, status=SignalStatus.ACTIVE, ) db.add(signal) db.commit() with patch("app.services.notification.send_discord", new_callable=AsyncMock) as mock_discord: await send_notification(signal, db) mock_discord.assert_not_called() @pytest.mark.asyncio async def test_skips_duplicate(self, db, test_user): setting = NotificationSetting( user_id=test_user.id, channel_type=ChannelType.DISCORD, webhook_url="https://discord.com/api/webhooks/test", enabled=True, ) db.add(setting) signal = Signal( id=3, date=datetime.utcnow().date(), ticker="005930", name="삼성전자", signal_type=SignalType.BUY, status=SignalStatus.ACTIVE, ) db.add(signal) # Pre-existing sent notification history = NotificationHistory( signal_id=3, channel_type=ChannelType.DISCORD, status=NotificationStatus.SENT, sent_at=datetime.utcnow(), message="already sent", ) db.add(history) db.commit() with patch("app.services.notification.send_discord", new_callable=AsyncMock) as mock_discord: await send_notification(signal, db) mock_discord.assert_not_called() @pytest.mark.asyncio async def test_records_failure(self, db, test_user): setting = NotificationSetting( user_id=test_user.id, channel_type=ChannelType.DISCORD, webhook_url="https://discord.com/api/webhooks/test", enabled=True, ) db.add(setting) signal = Signal( id=4, date=datetime.utcnow().date(), ticker="005930", name="삼성전자", signal_type=SignalType.BUY, status=SignalStatus.ACTIVE, ) db.add(signal) db.commit() with patch( "app.services.notification.send_discord", new_callable=AsyncMock, side_effect=Exception("Connection failed"), ): await send_notification(signal, db) history = db.query(NotificationHistory).first() assert history.status == NotificationStatus.FAILED assert "Connection failed" in history.error_message # --- API endpoint tests --- class TestNotificationAPI: def test_get_settings_empty(self, client, auth_headers): response = client.get("/api/notifications/settings", headers=auth_headers) assert response.status_code == 200 assert response.json() == [] def test_create_setting(self, client, auth_headers): response = client.post( "/api/notifications/settings", headers=auth_headers, json={ "channel_type": "discord", "webhook_url": "https://discord.com/api/webhooks/test/token", "enabled": True, }, ) assert response.status_code == 201 data = response.json() assert data["channel_type"] == "discord" assert data["webhook_url"] == "https://discord.com/api/webhooks/test/token" assert data["enabled"] is True def test_create_duplicate_channel_fails(self, client, auth_headers): payload = { "channel_type": "discord", "webhook_url": "https://discord.com/api/webhooks/test/token", } client.post("/api/notifications/settings", headers=auth_headers, json=payload) response = client.post("/api/notifications/settings", headers=auth_headers, json=payload) assert response.status_code == 400 def test_update_setting(self, client, auth_headers): create_resp = client.post( "/api/notifications/settings", headers=auth_headers, json={ "channel_type": "discord", "webhook_url": "https://discord.com/api/webhooks/old", }, ) setting_id = create_resp.json()["id"] update_resp = client.put( f"/api/notifications/settings/{setting_id}", headers=auth_headers, json={"webhook_url": "https://discord.com/api/webhooks/new", "enabled": False}, ) assert update_resp.status_code == 200 data = update_resp.json() assert data["webhook_url"] == "https://discord.com/api/webhooks/new" assert data["enabled"] is False def test_update_nonexistent_setting(self, client, auth_headers): response = client.put( "/api/notifications/settings/9999", headers=auth_headers, json={"enabled": False}, ) assert response.status_code == 404 def test_get_history(self, client, auth_headers): response = client.get("/api/notifications/history", headers=auth_headers) assert response.status_code == 200 assert isinstance(response.json(), list) def test_unauthenticated_access(self, client): response = client.get("/api/notifications/settings") assert response.status_code == 401