galaxis-po/backend/tests/unit/test_notification.py
머니페니 12d235a1f1 feat: add 9 new modules - notification alerts, trading journal, position sizing, pension allocation, drawdown monitoring, benchmark dashboard, tax simulation, correlation analysis, parameter optimizer
Phase 1:
- Real-time signal alerts (Discord/Telegram webhook)
- Trading journal with entry/exit tracking
- Position sizing calculator (Fixed/Kelly/ATR)

Phase 2:
- Pension asset allocation (DC/IRP 70% risk limit)
- Drawdown monitoring with SVG gauge
- Benchmark dashboard (portfolio vs KOSPI vs deposit)

Phase 3:
- Tax benefit simulation (Korean pension tax rules)
- Correlation matrix heatmap
- Parameter optimizer with grid search + overfit detection
2026-03-29 10:03:08 +09:00

327 lines
10 KiB
Python

"""
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