""" Unit tests for MericaAlertHook: multi-channel alerting, governance enrichment, and anti-alert fatigue throttling engine. """ import pytest from unittest.mock import patch, MagicMock from helpers.merica_alert_hook import MericaAlertHook, _ALERT_SUPPRESSION_CACHE @pytest.fixture(autouse=True) def clean_alert_cache(): """Clear suppression cache before and after every test.""" MericaAlertHook.clear_suppression_cache() yield MericaAlertHook.clear_suppression_cache() class TestMericaAlertHookChannels: """Test individual dispatch channels for Telegram, Slack, Discord, SITOMAT.""" @patch("urllib.request.urlopen") def test_send_webhook_success(self, mock_urlopen): mock_resp = MagicMock() mock_resp.status = 200 mock_urlopen.return_value.__enter__.return_value = mock_resp result = MericaAlertHook.send_webhook("http://example.com/webhook", {"test": "data"}) assert result is True mock_urlopen.assert_called_once() @patch("urllib.request.urlopen") def test_send_webhook_failure(self, mock_urlopen): mock_urlopen.side_effect = Exception("Connection refused") result = MericaAlertHook.send_webhook("http://example.com/webhook", {"test": "data"}) assert result is False @patch("urllib.request.urlopen") def test_send_telegram_alert(self, mock_urlopen): mock_resp = MagicMock() mock_resp.status = 200 mock_urlopen.return_value.__enter__.return_value = mock_resp result = MericaAlertHook.send_telegram_alert( bot_token="bot123456:ABC-DEF", chat_id="-100987654321", message="Test Alert" ) assert result is True args, kwargs = mock_urlopen.call_args req = args[0] assert "api.telegram.org/botbot123456:ABC-DEF/sendMessage" in req.full_url assert b"-100987654321" in req.data @patch("urllib.request.urlopen") def test_send_slack_alert(self, mock_urlopen): mock_resp = MagicMock() mock_resp.status = 200 mock_urlopen.return_value.__enter__.return_value = mock_resp result = MericaAlertHook.send_slack_alert( webhook_url="https://hooks.slack.com/services/T00/B00/X00", payload={"text": "Test Slack"} ) assert result is True args, kwargs = mock_urlopen.call_args req = args[0] assert "hooks.slack.com" in req.full_url @patch("urllib.request.urlopen") def test_send_discord_alert(self, mock_urlopen): mock_resp = MagicMock() mock_resp.status = 204 mock_urlopen.return_value.__enter__.return_value = mock_resp result = MericaAlertHook.send_discord_alert( webhook_url="https://discord.com/api/webhooks/123/abc", payload={"content": "Test Discord"} ) assert result is True @patch("urllib.request.urlopen") def test_send_sitomat_alert(self, mock_urlopen): mock_resp = MagicMock() mock_resp.status = 200 mock_urlopen.return_value.__enter__.return_value = mock_resp result = MericaAlertHook.send_sitomat_alert( endpoint_url="http://sitomat:8080/api/v1/lakehouse/webhook", incident_payload={"incidentType": "TASK_FAILURE"}, api_key="secret-api-key" ) assert result is True args, kwargs = mock_urlopen.call_args req = args[0] assert req.headers.get("X-api-key") == "secret-api-key" or req.headers.get("Authorization") == "Bearer secret-api-key" class TestAntiAlertFatigue: """Test suppression and throttling of duplicate/cascading incident alerts.""" def test_anti_alert_fatigue_throttling(self): cache_key = "dag_permits:task_silver:TASK_FAILURE" # Panggilan 1: Belum pernah terlihat, harus lolos (suppressed = False) is_suppressed, count = MericaAlertHook.check_and_record_suppression(cache_key, window_seconds=300) assert is_suppressed is False assert count == 1 # Panggilan 2: Terjadi dalam 300s window, harus dibatasi (suppressed = True) is_suppressed, count = MericaAlertHook.check_and_record_suppression(cache_key, window_seconds=300) assert is_suppressed is True assert count == 2 # Panggilan 3: Masih dalam window is_suppressed, count = MericaAlertHook.check_and_record_suppression(cache_key, window_seconds=300) assert is_suppressed is True assert count == 3 # Reset cache manual MericaAlertHook.clear_suppression_cache() is_suppressed, count = MericaAlertHook.check_and_record_suppression(cache_key, window_seconds=300) assert is_suppressed is False assert count == 1 class TestGovernanceEnrichment: """Test governance metadata extraction and severity routing.""" def test_context_extraction_tier1_p0(self): mock_dag = MagicMock() mock_dag.dag_id = "gold_analytics_pipeline" mock_dag.params = { "domain": "TataLingkunganPerizinan", "owner": "dlh_data_engineering_group", "tier": "Tier.Tier1" } mock_ti = MagicMock() mock_ti.task_id = "dbt_gold_mart" mock_ti.try_number = 2 mock_ti.max_tries = 3 mock_ti.duration = 42.5 mock_ti.log_url = "http://localhost:8088/log/123" context = { "dag": mock_dag, "task_instance": mock_ti, "exception": Exception("Table locked by concurrent vacuum process") } data = MericaAlertHook.extract_context(context) assert data["dag_id"] == "gold_analytics_pipeline" assert data["task_id"] == "dbt_gold_mart" assert data["try_number"] == 2 assert data["domain"] == "TataLingkunganPerizinan" assert data["tier"] == "Tier.Tier1" assert "P0 (CRITICAL)" in data["severity"] assert data["color_hex"] == "#E01E5A" assert "Table locked" in data["error_msg"] def test_context_extraction_tier2_p1(self): mock_dag = MagicMock() mock_dag.dag_id = "silver_cleaning_pipeline" mock_dag.params = {"tier": "Tier.Tier2"} mock_ti = MagicMock() mock_ti.task_id = "cleanse_task" mock_ti.try_number = 1 mock_ti.duration = 10.0 context = { "dag": mock_dag, "task_instance": mock_ti, "exception": Exception("Syntax error") } data = MericaAlertHook.extract_context(context) assert "P1 (WARNING)" in data["severity"] assert data["color_hex"] == "#ECB22E" class TestAirflowLifecycleCallbacks: """Test failure, SLA miss, and quality breach lifecycle handlers.""" @patch("helpers.merica_alert_hook.MericaAlertHook.send_telegram_alert") @patch("helpers.merica_alert_hook.MericaAlertHook.send_slack_alert") def test_on_failure_callback_dispatches_channels(self, mock_slack, mock_tg, monkeypatch): monkeypatch.setenv("ALERT_TELEGRAM_BOT_TOKEN", "mock_bot_token") monkeypatch.setenv("ALERT_TELEGRAM_CHAT_ID", "mock_chat_id") monkeypatch.setenv("ALERT_SLACK_WEBHOOK_URL", "https://hooks.slack.com/mock") mock_slack.return_value = True mock_tg.return_value = True mock_dag = MagicMock() mock_dag.dag_id = "permits_medallion" mock_dag.params = {"tier": "Tier.Tier1", "domain": "Perizinan", "owner": "DE"} mock_ti = MagicMock() mock_ti.task_id = "quality_gate_silver" mock_ti.duration = 15.0 context = { "dag": mock_dag, "task_instance": mock_ti, "exception": Exception("Check constraint violated: status must be in set") } results = MericaAlertHook.on_failure_callback(context) assert results.get("telegram") is True assert results.get("slack") is True mock_tg.assert_called_once() mock_slack.assert_called_once() @patch("helpers.merica_alert_hook.MericaAlertHook.dispatch_alert") def test_sla_miss_callback(self, mock_dispatch): mock_dispatch.return_value = {"telegram": True} mock_dag = MagicMock() mock_dag.dag_id = "sla_test_dag" res = MericaAlertHook.sla_miss_callback( dag=mock_dag, task_list=["task_1"], blocking_task_list=["task_1"], slas=[30], blocking_tis=[] ) assert res == {"telegram": True} mock_dispatch.assert_called_once() call_args = mock_dispatch.call_args[0] assert call_args[0] == "SLA_MISS" assert call_args[1]["dag_id"] == "sla_test_dag" @patch("helpers.merica_alert_hook.MericaAlertHook.dispatch_alert") def test_on_quality_gate_breach(self, mock_dispatch): mock_dispatch.return_value = {"slack": True} context = { "dag": MagicMock(dag_id="spku_iot_pipeline", params={"tier": "Tier.Tier2"}), "task_instance": MagicMock(task_id="ge_pm25_check") } failed_rules = [{"rule": "expect_column_values_to_be_between", "column": "pm25"}] res = MericaAlertHook.on_quality_gate_breach(context, failed_rules, 75.0) assert res == {"slack": True} call_args = mock_dispatch.call_args[0] assert call_args[0] == "QUALITY_GATE_BREACH" assert "75.0%" in call_args[1]["error_msg"] assert "pm25" in call_args[1]["error_msg"]