from __future__ import annotations from decimal import Decimal from pathlib import Path import pytest from app.models.portfolio import PortfolioConfig from app.services.alerts import AlertService, _normalize_alert_evaluation_input, build_portfolio_alert_context @pytest.fixture def alert_service(tmp_path: Path) -> AlertService: return AlertService(history_path=tmp_path / "alert_history.json") def test_normalize_alert_evaluation_input_coerces_numeric_boundary_values() -> None: config = PortfolioConfig(gold_value=215_000.0, entry_price=215.0, loan_amount=151_000.0) normalized = _normalize_alert_evaluation_input( config, { "ltv_ratio": "0.7023", "spot_price": "215.0", "quote_updated_at": 123, }, ) assert normalized.ltv_ratio == Decimal("0.7023") assert normalized.spot_price == Decimal("215.0") assert normalized.updated_at == "123" assert normalized.warning_threshold == Decimal("0.7") assert normalized.critical_threshold == Decimal("0.75") def test_normalize_alert_evaluation_input_rejects_bool_values() -> None: config = PortfolioConfig(gold_value=215_000.0, entry_price=215.0, loan_amount=151_000.0) with pytest.raises(TypeError, match="portfolio.ltv_ratio must be numeric, got bool"): _normalize_alert_evaluation_input( config, { "ltv_ratio": True, "spot_price": 215.0, "quote_updated_at": "2026-03-24T12:00:00Z", }, ) def test_alert_service_reports_ok_when_ltv_is_below_warning(alert_service: AlertService) -> None: config = PortfolioConfig(gold_value=215_000.0, entry_price=215.0, loan_amount=120_000.0) portfolio = build_portfolio_alert_context( config, spot_price=215.0, source="test", updated_at="2026-03-24T12:00:00Z" ) status = alert_service.evaluate(config, portfolio) assert status.severity == "ok" assert status.message == "LTV is within configured thresholds." assert status.history == [] def test_alert_service_preview_does_not_persist_history(alert_service: AlertService) -> None: config = PortfolioConfig( gold_value=215_000.0, entry_price=215.0, loan_amount=151_000.0, ltv_warning=0.70, margin_threshold=0.75, ) portfolio = build_portfolio_alert_context(config, spot_price=215.0, source="settings-preview", updated_at="") preview_status = alert_service.evaluate(config, portfolio, persist=False) assert preview_status.severity == "warning" assert [event.severity for event in preview_status.history] == ["warning"] assert alert_service.repository.load() == [] persisted_status = alert_service.evaluate(config, portfolio) assert [event.severity for event in persisted_status.history] == ["warning"] assert len(alert_service.repository.load()) == 1 def test_alert_service_logs_warning_once_when_warning_threshold_is_crossed(alert_service: AlertService) -> None: config = PortfolioConfig( gold_value=215_000.0, entry_price=215.0, loan_amount=151_000.0, ltv_warning=0.70, margin_threshold=0.75, email_alerts=True, ) portfolio = build_portfolio_alert_context( config, spot_price=215.0, source="test", updated_at="2026-03-24T12:00:00Z" ) first_status = alert_service.evaluate(config, portfolio) second_status = alert_service.evaluate(config, portfolio) assert first_status.severity == "warning" assert first_status.email_alerts_enabled is True assert len(first_status.history) == 1 assert first_status.history[0].severity == "warning" assert "70.2%" in first_status.history[0].message assert len(second_status.history) == 1 def test_alert_service_escalates_to_critical_and_keeps_history(alert_service: AlertService) -> None: config = PortfolioConfig( gold_value=215_000.0, entry_price=215.0, loan_amount=150_500.0, ltv_warning=0.70, margin_threshold=0.75, ) warning_portfolio = build_portfolio_alert_context( config, spot_price=215.0, source="test", updated_at="2026-03-24T12:00:00Z", ) critical_portfolio = build_portfolio_alert_context( config, spot_price=200.0, source="test", updated_at="2026-03-24T13:00:00Z", ) alert_service.evaluate(config, warning_portfolio) critical_status = alert_service.evaluate(config, critical_portfolio) assert critical_status.severity == "critical" assert "above the critical threshold" in critical_status.message assert [event.severity for event in critical_status.history] == ["critical", "warning"] assert critical_status.history[0].ltv_ratio == pytest.approx(0.7525, rel=1e-6) def test_alert_service_preserves_persisted_history_during_ok_evaluation(alert_service: AlertService) -> None: warning_config = PortfolioConfig( gold_value=215_000.0, entry_price=215.0, loan_amount=151_000.0, ltv_warning=0.70, margin_threshold=0.75, ) ok_config = PortfolioConfig( gold_value=215_000.0, entry_price=215.0, loan_amount=120_000.0, ltv_warning=0.70, margin_threshold=0.75, ) warning_portfolio = build_portfolio_alert_context( warning_config, spot_price=215.0, source="test", updated_at="2026-03-24T12:00:00Z", ) ok_portfolio = build_portfolio_alert_context( ok_config, spot_price=215.0, source="test", updated_at="2026-03-24T13:00:00Z", ) alert_service.evaluate(warning_config, warning_portfolio) ok_status = alert_service.evaluate(ok_config, ok_portfolio) assert ok_status.severity == "ok" assert [event.severity for event in ok_status.history] == ["warning"] assert len(alert_service.repository.load()) == 1