feat: initial commit for merica-pipeline delivery
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
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="<b>Test Alert</b>"
|
||||
)
|
||||
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"]
|
||||
Reference in New Issue
Block a user