""" tests/test_risk_timeline.py Unit tests for build_timeline() in risk_attribution.py: - Buckets multiple same-type events in same time window into one item - Includes incident escalation events - Respects max_items limit - Sorts newest-first """ import sys, os sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../services/router")) import datetime import pytest from risk_attribution import build_timeline def _now() -> str: return datetime.datetime.utcnow().isoformat() def _ts(minutes_ago: int) -> str: return (datetime.datetime.utcnow() - datetime.timedelta(minutes=minutes_ago)).isoformat() _POLICY = { "timeline": { "enabled": True, "lookback_hours": 24, "max_items": 30, "include_types": ["deploy", "incident", "slo", "followup", "alert_loop", "release_gate", "dependency", "drift", "alert"], "time_bucket_minutes": 5, }, } class TestBuildTimeline: def test_empty_input(self): result = build_timeline([], _POLICY) assert result == [] def test_single_event(self): events = [{"ts": _ts(10), "type": "deploy", "label": "Deploy: canary", "refs": {}}] result = build_timeline(events, _POLICY) assert len(result) == 1 assert result[0]["type"] == "deploy" assert result[0]["label"] == "Deploy: canary" def test_newest_first(self): events = [ {"ts": _ts(60), "type": "deploy", "label": "Old deploy", "refs": {}}, {"ts": _ts(10), "type": "incident", "label": "New incident", "refs": {}}, ] result = build_timeline(events, _POLICY) assert result[0]["type"] == "incident" # newest first assert result[1]["type"] == "deploy" def test_buckets_same_type_same_window(self): """Multiple deploy alerts in the same 5-min window → coalesced to 1 item with xN.""" now = datetime.datetime.utcnow() # All within the same 5-min bucket base = now.replace(second=0, microsecond=0) bucket_start = base - datetime.timedelta(minutes=base.minute % 5) events = [ {"ts": (bucket_start + datetime.timedelta(seconds=i)).isoformat(), "type": "deploy", "label": "Deploy alert", "refs": {"alert_ref": f"alrt_{i}"}} for i in range(4) ] result = build_timeline(events, _POLICY) # Should be coalesced into 1 item deploy_items = [e for e in result if e["type"] == "deploy"] assert len(deploy_items) == 1 assert "×4" in deploy_items[0]["label"] def test_different_types_not_bucketed_together(self): now = datetime.datetime.utcnow() bucket_start = now.replace(second=0, microsecond=0) bucket_start -= datetime.timedelta(minutes=bucket_start.minute % 5) events = [ {"ts": bucket_start.isoformat(), "type": "deploy", "label": "Deploy", "refs": {}}, {"ts": bucket_start.isoformat(), "type": "incident", "label": "Incident", "refs": {}}, ] result = build_timeline(events, _POLICY) assert len(result) == 2 def test_max_items_respected(self): events = [ {"ts": _ts(i * 6), "type": "alert", "label": f"Alert {i}", "refs": {}} for i in range(50) ] policy = {**_POLICY, "timeline": {**_POLICY["timeline"], "max_items": 5}} result = build_timeline(events, policy) assert len(result) == 5 def test_include_types_filter(self): events = [ {"ts": _ts(10), "type": "deploy", "label": "Deploy", "refs": {}}, {"ts": _ts(20), "type": "unknown_type", "label": "Unknown", "refs": {}}, ] policy = {**_POLICY, "timeline": {**_POLICY["timeline"], "include_types": ["deploy"]}} result = build_timeline(events, policy) assert all(e["type"] == "deploy" for e in result) def test_incident_escalation_included(self): events = [ {"ts": _ts(5), "type": "incident", "label": "Incident escalated: inc_001", "refs": {"incident_id": "inc_001"}}, ] result = build_timeline(events, _POLICY) assert len(result) == 1 assert "inc_001" in str(result[0]["refs"]) def test_timeline_disabled(self): policy = {**_POLICY, "timeline": {**_POLICY["timeline"], "enabled": False}} events = [{"ts": _ts(5), "type": "deploy", "label": "D", "refs": {}}] result = build_timeline(events, policy) assert result == [] def test_refs_preserved(self): events = [{ "ts": _ts(5), "type": "deploy", "label": "Canary deploy", "refs": {"alert_ref": "alrt_xyz", "service": "gateway"}, }] result = build_timeline(events, _POLICY) assert len(result) == 1 refs = result[0]["refs"] # refs can be dict or list of tuples; we just need to verify alert_ref is present assert "alrt_xyz" in str(refs) def test_bucketed_item_refs_merged(self): """When items coalesce, refs from multiple events are merged (up to 5).""" now = datetime.datetime.utcnow() bucket_start = now.replace(second=0, microsecond=0) bucket_start -= datetime.timedelta(minutes=bucket_start.minute % 5) events = [ {"ts": (bucket_start + datetime.timedelta(seconds=i)).isoformat(), "type": "deploy", "label": "Deploy", "refs": {"alert_ref": f"alrt_{i}"}} for i in range(3) ] result = build_timeline(events, _POLICY) assert len(result) == 1 # Refs should contain at least one alert_ref refs_str = str(result[0]["refs"]) assert "alrt_" in refs_str