""" tests/test_risk_engine.py — Unit tests for the Service Risk Index Engine. Tests scoring components, band classification, service threshold overrides, and full RiskReport assembly — all deterministic, no I/O required. """ import pytest import sys from pathlib import Path # Ensure router module path is on sys.path sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "services" / "router")) from risk_engine import ( load_risk_policy, _builtin_defaults, _reload_policy, score_to_band, get_service_thresholds, _score_open_incidents, _score_recurrence, _score_followups, _score_slo, _score_alerts_loop, _score_escalations, compute_service_risk, compute_risk_dashboard, ) @pytest.fixture(autouse=True) def reset_policy_cache(): """Reset the in-memory policy cache before each test.""" _reload_policy() yield _reload_policy() @pytest.fixture def policy(): return _builtin_defaults() @pytest.fixture def weights(policy): return policy["weights"] # ─── Band classification ────────────────────────────────────────────────────── class TestBands: def test_low(self, policy): assert score_to_band(0, policy) == "low" assert score_to_band(20, policy) == "low" def test_medium(self, policy): assert score_to_band(21, policy) == "medium" assert score_to_band(50, policy) == "medium" def test_high(self, policy): assert score_to_band(51, policy) == "high" assert score_to_band(80, policy) == "high" def test_critical(self, policy): assert score_to_band(81, policy) == "critical" assert score_to_band(200, policy) == "critical" # ─── Open incidents scoring ─────────────────────────────────────────────────── class TestOpenIncidents: def test_no_incidents(self, weights): pts, comp, reasons = _score_open_incidents([], weights) assert pts == 0 assert comp["P0"] == 0 assert reasons == [] def test_single_p0(self, weights): incs = [{"id": "i1", "severity": "P0", "status": "open"}] pts, comp, reasons = _score_open_incidents(incs, weights) assert pts == 50 assert comp["P0"] == 1 assert "P0" in reasons[0] def test_p1_p2_combined(self, weights): incs = [ {"id": "i1", "severity": "P1", "status": "open"}, {"id": "i2", "severity": "P2", "status": "open"}, {"id": "i3", "severity": "P2", "status": "open"}, ] pts, comp, reasons = _score_open_incidents(incs, weights) assert pts == 25 + 10 + 10 # 45 assert comp["P1"] == 1 assert comp["P2"] == 2 def test_unknown_severity(self, weights): incs = [{"id": "i1", "severity": "P9", "status": "open"}] pts, comp, reasons = _score_open_incidents(incs, weights) assert pts == 0 # ─── Recurrence scoring ─────────────────────────────────────────────────────── class TestRecurrence: def _make_rec(self, high_sigs=0, high_kinds=0, warn_sigs=0, warn_kinds=0): return { "high_recurrence": { "signatures": [f"sig_{i}" for i in range(high_sigs)], "kinds": [f"kind_{i}" for i in range(high_kinds)], }, "warn_recurrence": { "signatures": [f"wsig_{i}" for i in range(warn_sigs)], "kinds": [f"wkind_{i}" for i in range(warn_kinds)], }, } def test_no_recurrence(self, weights): pts, comp, reasons = _score_recurrence({}, weights) assert pts == 0 assert reasons == [] def test_one_high_signature(self, weights): rec = self._make_rec(high_sigs=1) pts, comp, reasons = _score_recurrence(rec, weights) assert pts == 20 # signature_high_7d = 20 assert comp["high_signatures_7d"] == 1 assert any("High recurrence signatures" in r for r in reasons) def test_high_kinds_adds_points(self, weights): rec = self._make_rec(high_kinds=2) pts, comp, _ = _score_recurrence(rec, weights) assert pts == 15 * 2 # kind_high_7d = 15 each def test_warn_signature(self, weights): rec = self._make_rec(warn_sigs=1) pts, comp, _ = _score_recurrence(rec, weights) assert pts == 10 # signature_warn_7d = 10 # ─── Follow-ups scoring ─────────────────────────────────────────────────────── class TestFollowups: def test_no_followups(self, weights): pts, comp, reasons = _score_followups({}, weights) assert pts == 0 def test_overdue_p0(self, weights): data = {"overdue_followups": [{"priority": "P0"}]} pts, comp, reasons = _score_followups(data, weights) assert pts == 20 assert comp["P0"] == 1 assert "P0" in reasons[0] def test_overdue_p1(self, weights): data = {"overdue_followups": [{"priority": "P1"}]} pts, comp, reasons = _score_followups(data, weights) assert pts == 12 assert comp["P1"] == 1 def test_overdue_mixed(self, weights): data = { "overdue_followups": [ {"priority": "P0"}, {"priority": "P1"}, {"priority": "P2"}, ] } pts, comp, _ = _score_followups(data, weights) assert pts == 20 + 12 + 6 # 38 # ─── SLO scoring ───────────────────────────────────────────────────────────── class TestSlo: def test_no_violations(self, weights): pts, comp, reasons = _score_slo({"violations": []}, weights) assert pts == 0 assert reasons == [] def test_one_violation(self, weights): pts, comp, reasons = _score_slo({"violations": [{"metric": "error_rate"}]}, weights) assert pts == 10 assert comp["violations"] == 1 assert reasons def test_two_violations(self, weights): slo = {"violations": [{"m": "latency"}, {"m": "error"}]} pts, comp, _ = _score_slo(slo, weights) assert pts == 20 assert comp["violations"] == 2 def test_skipped(self, weights): pts, comp, _ = _score_slo({"violations": [], "skipped": True}, weights) assert pts == 0 assert comp["skipped"] is True # ─── Alert-loop SLO scoring ─────────────────────────────────────────────────── class TestAlertsLoop: def test_no_violations(self, weights): pts, comp, _ = _score_alerts_loop({}, weights) assert pts == 0 def test_one_loop_violation(self, weights): pts, comp, reasons = _score_alerts_loop({"violations": [{"type": "missed_slo"}]}, weights) assert pts == 10 assert reasons # ─── Escalation scoring ────────────────────────────────────────────────────── class TestEscalations: def test_no_escalations(self, weights): pts, comp, _ = _score_escalations(0, weights) assert pts == 0 def test_warn_level(self, weights): pts, comp, reasons = _score_escalations(1, weights) assert pts == 5 # warn level assert comp["count_24h"] == 1 assert reasons def test_high_level(self, weights): pts, comp, reasons = _score_escalations(3, weights) assert pts == 12 # high level def test_high_level_more(self, weights): pts, comp, _ = _score_escalations(10, weights) assert pts == 12 # capped at high # ─── Full compute_service_risk ──────────────────────────────────────────────── class TestComputeServiceRisk: def test_zero_risk_empty_inputs(self, policy): report = compute_service_risk( "gateway", "prod", open_incidents=[], recurrence_7d={}, followups_data={}, slo_data={"violations": []}, alerts_loop_slo={}, escalation_count_24h=0, policy=policy, ) assert report["score"] == 0 assert report["band"] == "low" assert report["service"] == "gateway" assert report["env"] == "prod" assert isinstance(report["reasons"], list) assert isinstance(report["recommendations"], list) assert "updated_at" in report def test_p0_open_incident(self, policy): report = compute_service_risk( "gateway", "prod", open_incidents=[{"id": "i1", "severity": "P0", "status": "open"}], policy=policy, ) assert report["score"] == 50 assert report["band"] == "medium" assert report["components"]["open_incidents"]["P0"] == 1 def test_full_high_risk(self, policy): """Combining several signals pushes score into 'high' band.""" report = compute_service_risk( "gateway", "prod", open_incidents=[ {"id": "i1", "severity": "P1", "status": "open"}, {"id": "i2", "severity": "P2", "status": "open"}, ], recurrence_7d={ "high_recurrence": {"signatures": ["bucket_A"], "kinds": []}, "warn_recurrence": {"signatures": [], "kinds": []}, }, followups_data={"overdue_followups": [{"priority": "P1"}]}, slo_data={"violations": [{"metric": "error_rate"}]}, escalation_count_24h=1, policy=policy, ) # P1=25, P2=10, high_sig_7d=20, overdue_P1=12, slo=10, esc_warn=5 → 82 assert report["score"] >= 70 assert report["band"] in ("high", "critical") assert len(report["recommendations"]) > 0 def test_recommendations_present_for_high_score(self, policy): report = compute_service_risk( "router", "prod", open_incidents=[{"id": "i1", "severity": "P0", "status": "open"}], slo_data={"violations": [{"m": "latency"}]}, policy=policy, ) assert any("P0" in r or "SLO" in r for r in report["recommendations"]) # ─── Service threshold overrides ───────────────────────────────────────────── class TestServiceOverrides: def test_gateway_fail_at_75(self): """risk_policy.yml defines gateway.risk_watch.fail_at = 75.""" policy = load_risk_policy() thresholds = get_service_thresholds("gateway", policy) assert thresholds["fail_at"] == 75 def test_router_fail_at_80(self): policy = load_risk_policy() thresholds = get_service_thresholds("router", policy) assert thresholds["fail_at"] == 80 def test_unknown_service_default(self): policy = load_risk_policy() thresholds = get_service_thresholds("unknown-svc", policy) assert thresholds["fail_at"] >= 75 # must have a value def test_threshold_reflected_in_report(self): policy = load_risk_policy() report = compute_service_risk("gateway", "prod", policy=policy) assert report["thresholds"]["fail_at"] == 75