""" tests/test_risk_trend.py — Unit tests for compute_trend and enrich helpers. Tests: - delta_24h / delta_7d computed correctly - volatility computed from daily series - regression flags set by policy thresholds - enrich_risk_report_with_trend: non-fatal on store error """ import datetime import sys import pytest from pathlib import Path from unittest.mock import MagicMock sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "services" / "router")) from risk_engine import compute_trend, enrich_risk_report_with_trend, _reload_policy, _builtin_defaults from risk_history_store import MemoryRiskHistoryStore, RiskSnapshot def _reload(): _reload_policy() @pytest.fixture(autouse=True) def reset_policy_cache(): _reload_policy() yield _reload_policy() @pytest.fixture def policy(): return _builtin_defaults() def _snap(service, env, score, hours_ago=0) -> RiskSnapshot: from risk_engine import score_to_band, _builtin_defaults p = _builtin_defaults() ts = (datetime.datetime.utcnow() - datetime.timedelta(hours=hours_ago)).isoformat() band = score_to_band(score, p) return RiskSnapshot(ts=ts, service=service, env=env, score=score, band=band) # ─── compute_trend ──────────────────────────────────────────────────────────── class TestComputeTrend: def test_empty_series_returns_nulls(self, policy): trend = compute_trend([], policy=policy) assert trend["delta_24h"] is None assert trend["delta_7d"] is None assert trend["slope_per_day"] is None assert trend["volatility"] is None assert trend["regression"] == {"warn": False, "fail": False} def test_delta_24h_computed(self, policy): """Latest score 60, baseline 40 → delta_24h = 20.""" series = [ _snap("gw", "prod", 60, hours_ago=0), _snap("gw", "prod", 40, hours_ago=25), # baseline at -25h ] trend = compute_trend(series, policy=policy) assert trend["delta_24h"] == 20 def test_delta_7d_computed(self, policy): series = [ _snap("gw", "prod", 65, hours_ago=0), _snap("gw", "prod", 30, hours_ago=170), # baseline at ~7d ] trend = compute_trend(series, policy=policy) assert trend["delta_7d"] == 35 def test_delta_none_when_no_baseline_in_window(self, policy): """Only recent snaps — no baseline before 24h.""" series = [_snap("gw", "prod", 60, hours_ago=0)] trend = compute_trend(series, policy=policy) assert trend["delta_24h"] is None def test_improving_negative_delta(self, policy): series = [ _snap("gw", "prod", 20, hours_ago=0), _snap("gw", "prod", 70, hours_ago=25), ] trend = compute_trend(series, policy=policy) assert trend["delta_24h"] == -50 # improving def test_regression_warn_set_when_delta_exceeds_warn(self, policy): """delta_24h_warn = 10 by default; delta 15 → warn=True.""" series = [ _snap("gw", "prod", 55, hours_ago=0), _snap("gw", "prod", 40, hours_ago=25), # delta_24h = 15 ] trend = compute_trend(series, policy=policy) assert trend["regression"]["warn"] is True assert trend["regression"]["fail"] is False def test_regression_fail_set_when_delta_exceeds_fail(self, policy): """delta_24h_fail = 20 by default; delta 25 → fail=True.""" series = [ _snap("gw", "prod", 65, hours_ago=0), _snap("gw", "prod", 40, hours_ago=25), # delta_24h = 25 ] trend = compute_trend(series, policy=policy) assert trend["regression"]["fail"] is True assert trend["regression"]["warn"] is True # fail implies warn def test_no_regression_below_threshold(self, policy): """delta 5 < warn 10 → no flags.""" series = [ _snap("gw", "prod", 45, hours_ago=0), _snap("gw", "prod", 40, hours_ago=25), ] trend = compute_trend(series, policy=policy) assert trend["regression"]["warn"] is False assert trend["regression"]["fail"] is False def test_slope_computed_with_multiple_points(self, policy): """Linear slope should reflect direction of change.""" # Increasing: 20, 40, 60 over hours 3, 2, 1 ago series = [ _snap("gw", "prod", 60, hours_ago=1), _snap("gw", "prod", 40, hours_ago=2), _snap("gw", "prod", 20, hours_ago=3), ] trend = compute_trend(series, policy=policy) assert trend["slope_per_day"] is not None assert trend["slope_per_day"] > 0 # rising def test_volatility_with_daily_scores(self, policy): """Multiple daily snapshots → non-zero volatility.""" series = [] scores = [20, 50, 30, 80, 40, 60, 10] for i, score in enumerate(scores): series.append(_snap("gw", "prod", score, hours_ago=i * 24 + 1)) trend = compute_trend(series, policy=policy) assert trend["volatility"] is not None assert trend["volatility"] > 0 def test_volatility_none_if_single_day(self, policy): series = [_snap("gw", "prod", 50, hours_ago=1)] trend = compute_trend(series, policy=policy) assert trend["volatility"] is None # ─── enrich_risk_report_with_trend ─────────────────────────────────────────── class TestEnrichWithTrend: def test_adds_trend_key_from_store(self, policy): store = MemoryRiskHistoryStore() store.write_snapshot([ _snap("gateway", "prod", 40, hours_ago=25), _snap("gateway", "prod", 60, hours_ago=1), ]) report = {"service": "gateway", "env": "prod", "score": 60} enrich_risk_report_with_trend(report, store, policy) assert "trend" in report assert report["trend"]["delta_24h"] == 20 def test_trend_null_on_store_error(self, policy): """Store raises → trend=None, never raises.""" broken_store = MagicMock() broken_store.get_series.side_effect = RuntimeError("DB down") report = {"service": "gateway", "env": "prod", "score": 60} enrich_risk_report_with_trend(report, broken_store, policy) assert report["trend"] is None def test_trend_empty_when_no_history(self, policy): store = MemoryRiskHistoryStore() # empty report = {"service": "gateway", "env": "prod", "score": 60} enrich_risk_report_with_trend(report, store, policy) assert report["trend"]["delta_24h"] is None assert report["trend"]["delta_7d"] is None assert report["trend"]["regression"]["warn"] is False