feat: initial commit for merica-pipeline delivery

This commit is contained in:
MERICA Admin
2026-10-08 19:33:15 +07:00
commit 42fedab752
46 changed files with 5484 additions and 0 deletions
View File
+248
View File
@@ -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"]
+139
View File
@@ -0,0 +1,139 @@
"""
Unit Tests for GreatExpectationsLakehouseOperator
Verifies declarative rules (not_null, unique, in_set, row_count),
custom SQL assertions, error handling, and XCom payload formatting.
"""
import os
import sys
import pytest
from unittest.mock import MagicMock, patch
# Ensure airflow/dags directory is on python path
DAGS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "dags"))
if DAGS_DIR not in sys.path:
sys.path.insert(0, DAGS_DIR)
from helpers.ge_lakehouse_operator import GreatExpectationsLakehouseOperator
try:
from airflow.exceptions import AirflowException
except ImportError:
class AirflowException(Exception):
pass
class TestGreatExpectationsLakehouseOperator:
@patch("helpers.ge_lakehouse_operator.create_engine")
def test_declarative_rules_all_pass(self, mock_create_engine):
mock_conn = MagicMock()
mock_engine = MagicMock()
mock_engine.connect.return_value.__enter__.return_value = mock_conn
mock_create_engine.return_value = mock_engine
# Mock query outputs:
# 1. row count = 10 (valid for min 1)
# 2. null count = 0 (valid for not_null)
# 3. in_set invalid count = 0 (valid for in_set)
mock_conn.execute.return_value.scalar.side_effect = [10, 0, 0]
op = GreatExpectationsLakehouseOperator(
task_id="test_ge_pass",
expectation_suite="permits_test_suite",
table_name="bronze_lakehouse.permits",
rules=[
{"expect": "table_row_count_to_be_between", "min_value": 1},
{"expect": "column_values_to_not_be_null", "column": "permit_id"},
{"expect": "column_values_to_be_in_set", "column": "status", "allowed_values": ["APPROVED", "REJECTED"]}
],
database_url="sqlite:///:memory:"
)
res = op.execute(context={})
assert res["status"] == "PASSED"
assert res["overall_success"] is True
assert res["total_evaluations"] == 3
assert res["successful_evaluations"] == 3
assert res["success_percent"] == 100.0
@patch("helpers.ge_lakehouse_operator.create_engine")
def test_custom_sql_query_pass(self, mock_create_engine):
mock_conn = MagicMock()
mock_engine = MagicMock()
mock_engine.connect.return_value.__enter__.return_value = mock_conn
mock_create_engine.return_value = mock_engine
# Query returns 0 (expected 0)
mock_conn.execute.return_value.scalar.return_value = 0
op = GreatExpectationsLakehouseOperator(
task_id="test_custom_sql",
expectation_suite="permits_sql_suite",
queries=[
{
"name": "check_no_negative_amounts",
"sql": "SELECT count(*) FROM table WHERE amount < 0",
"expected": 0,
"error_message": "Found negative amounts!"
}
],
database_url="sqlite:///:memory:"
)
res = op.execute(context={})
assert res["status"] == "PASSED"
assert res["overall_success"] is True
assert len(res["details"]) == 1
assert res["details"][0]["observed_value"] == 0
@patch("helpers.ge_lakehouse_operator.create_engine")
def test_fail_fast_raises_airflow_exception(self, mock_create_engine):
mock_conn = MagicMock()
mock_engine = MagicMock()
mock_engine.connect.return_value.__enter__.return_value = mock_conn
mock_create_engine.return_value = mock_engine
# Mock: 3 null values found in permit_id
mock_conn.execute.return_value.scalar.return_value = 3
op = GreatExpectationsLakehouseOperator(
task_id="test_fail_fast",
expectation_suite="permits_fail_suite",
table_name="bronze_lakehouse.permits",
rules=[
{"expect": "column_values_to_not_be_null", "column": "permit_id"}
],
fail_on_error=True,
database_url="sqlite:///:memory:"
)
with pytest.raises(Exception) as excinfo:
op.execute(context={})
assert "Great Expectations Quality Gate GAGAL" in str(excinfo.value)
@patch("helpers.ge_lakehouse_operator.create_engine")
def test_fail_on_error_false_does_not_raise(self, mock_create_engine):
mock_conn = MagicMock()
mock_engine = MagicMock()
mock_engine.connect.return_value.__enter__.return_value = mock_conn
mock_create_engine.return_value = mock_engine
# Mock: 2 invalid records
mock_conn.execute.return_value.scalar.return_value = 2
op = GreatExpectationsLakehouseOperator(
task_id="test_warn_only",
expectation_suite="permits_warn_suite",
table_name="silver.permits",
rules=[
{"expect": "column_values_to_be_in_set", "column": "status", "allowed_values": ["APPROVED"]}
],
fail_on_error=False,
database_url="sqlite:///:memory:"
)
res = op.execute(context={})
assert res["status"] == "FAILED"
assert res["overall_success"] is False
assert res["unsuccessful_evaluations"] == 1
assert res["total_unexpected_records"] == 2
+203
View File
@@ -0,0 +1,203 @@
import os
import sys
import json
import pytest
import pandas as pd
from datetime import datetime, timedelta
# Ensure airflow/dags directory is on python path
DAGS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "dags"))
if DAGS_DIR not in sys.path:
sys.path.insert(0, DAGS_DIR)
from helpers.storage_helper import storage_manager, build_partitioned_path, StorageManager
from helpers.api_helper import (
GenericAPIExtractorOperator,
create_resilient_session,
generate_synthetic_telemetry
)
from helpers.sling_helper import (
SlingDBIngestionOperator,
get_checkpoint_value,
set_checkpoint_value,
generate_synthetic_permits_data
)
from helpers.gws_helper import (
GWSExtractorOperator,
DataCleansingEngine,
generate_synthetic_gws_waste_data
)
class TestStorageHelper:
def test_build_partitioned_path(self):
fixed_dt = datetime(2026, 9, 28, 10, 0, 0)
path = build_partitioned_path("api/spku_api", "telemetry.json", dt=fixed_dt)
assert path == "api/spku_api/year=2026/month=09/day=28/telemetry.json"
def test_save_json_local_fallback(self):
test_payload = {"service": "merica", "metric": "air_quality", "value": 42}
result = storage_manager.save_json(
data=test_payload,
bucket="dlh-bronze-test",
key="test/sample.json"
)
assert result["s3_uri"] == "s3://dlh-bronze-test/test/sample.json"
assert os.path.exists(result["local_path"])
with open(result["local_path"], "r") as f:
loaded = json.load(f)
assert loaded["value"] == 42
os.remove(result["local_path"])
def test_save_dataframe_csv(self):
df = pd.DataFrame({"col1": [1, 2], "col2": ["A", "B"]})
result = storage_manager.save_dataframe(
df=df,
bucket="dlh-bronze-test",
key="test/table.csv",
target_format="csv"
)
assert result["format"] == "csv"
assert result["rows_written"] == 2
assert os.path.exists(result["local_path"])
read_df = pd.read_csv(result["local_path"])
assert len(read_df) == 2
os.remove(result["local_path"])
class TestApiHelper:
def test_resilient_session(self):
session = create_resilient_session(max_retries=2, backoff_factor=1.0)
assert session is not None
assert "http://" in session.adapters
assert "https://" in session.adapters
def test_generate_synthetic_telemetry(self):
telemetry = generate_synthetic_telemetry("SPKU_01", "2026-09-28T00:00:00Z", "2026-09-28T03:00:00Z")
assert telemetry["status"] == "success"
assert telemetry["station_id"] == "SPKU_01"
assert len(telemetry["readings"]) > 0
assert "pm25" in telemetry["readings"][0]
def test_api_extractor_operator_execution(self):
config = {
"source_type": "api",
"connection_id": "conn_spku_api_service",
"api_endpoint": "https://api.spku.dlh.id/v1/stasiun/spku/data-realtime",
"lookback_hours": 3,
"target": {
"bucket": "dlh-bronze-test",
"prefix": "test_api/spku"
}
}
operator = GenericAPIExtractorOperator(
task_id="test_api_op",
bronze_config=config,
mock_on_network_error=True
)
result = operator.execute(context={"execution_date": datetime(2026, 9, 28, 12, 0, 0)})
assert result["status"] == "success"
assert result["record_count"] >= 1
assert "window_start" in result
assert "window_end" in result
assert os.path.exists(result["local_path"])
os.remove(result["local_path"])
class TestSlingHelper:
def test_checkpoint_state(self):
key = "unit_test_checkpoint"
set_checkpoint_value(key, "2026-09-28 10:00:00")
val = get_checkpoint_value(key)
assert val == "2026-09-28 10:00:00"
state_file = f"/tmp/{key}.state"
if os.path.exists(state_file):
os.remove(state_file)
def test_generate_synthetic_permits(self):
df = generate_synthetic_permits_data(row_count=4)
assert len(df) == 4
assert "permit_number" in df.columns
assert "status" in df.columns
def test_sling_db_ingestion_operator_execution(self):
config = {
"source_type": "database",
"connection_id": "conn_postgres_source_db",
"table_name": "public.environmental_permits",
"ingestion_mode": "incremental_watermark",
"watermark_column": "watermark_ts",
"target": {
"bucket": "dlh-bronze-test",
"format": "csv",
"prefix": "test_db/permits"
}
}
operator = SlingDBIngestionOperator(
task_id="test_sling_op",
bronze_config=config
)
result = operator.execute(context={"execution_date": datetime(2026, 9, 28, 12, 0, 0)})
assert result["status"] == "success"
assert result["rows_extracted"] > 0
assert result["checkpoint_new"] is not None
assert os.path.exists(result["local_path"])
os.remove(result["local_path"])
class TestGwsHelper:
def test_data_cleansing_headers(self):
assert DataCleansingEngine.clean_header("Total Volume (Kg) ") == "total_volume_kg"
assert DataCleansingEngine.clean_header("Nama TPS / Lokasi") == "nama_tps_lokasi"
assert DataCleansingEngine.clean_header("Biaya Retribusi (Rp)") == "biaya_retribusi_rp"
def test_data_cleansing_numeric_units(self):
assert DataCleansingEngine.clean_numeric_value("1,500 kg") == 1500
assert DataCleansingEngine.clean_numeric_value("Rp 250,000") == 250000
assert DataCleansingEngine.clean_numeric_value("25.5 Ton") == 25.5
assert DataCleansingEngine.clean_numeric_value("Jakarta Timur") == "Jakarta Timur"
def test_data_cleansing_dataframe_sanitization(self):
raw_df = generate_synthetic_gws_waste_data()
cleaned_df = DataCleansingEngine.sanitize_dataframe(raw_df)
# Headers sanitized
assert "wilayah_kecamatan" in cleaned_df.columns
assert "volume_sampah_kg" in cleaned_df.columns
assert "biaya_retribusi_rp" in cleaned_df.columns
# Merged cells forward-filled (first column had empty strings)
assert cleaned_df["wilayah_kecamatan"].isna().sum() == 0
assert cleaned_df["wilayah_kecamatan"].iloc[1] == "Jakarta Timur"
assert cleaned_df["wilayah_kecamatan"].iloc[4] == "Jakarta Selatan"
# Numeric values cleaned
assert cleaned_df["volume_sampah_kg"].iloc[0] == 1250
assert cleaned_df["biaya_retribusi_rp"].iloc[0] == 250000
def test_gws_extractor_operator_execution(self):
config = {
"source_type": "gws",
"connection_id": "conn_gws_service_account",
"spreadsheet_id": "1OSRugXZvfjoY9M0yySKT-F3L4LTZXa40J0knU3_4Hik",
"sheet_range": "Sheet1!A1:Z1000",
"target": {
"bucket": "dlh-bronze-test",
"format": "csv",
"prefix": "test_gws/waste_reports"
}
}
operator = GWSExtractorOperator(
task_id="test_gws_op",
bronze_config=config,
auto_clean=True
)
result = operator.execute(context={"execution_date": datetime(2026, 9, 28, 12, 0, 0)})
assert result["status"] == "success"
assert result["rows_extracted"] == 5
assert "volume_sampah_kg" in result["columns"]
assert os.path.exists(result["local_path"])
os.remove(result["local_path"])
+88
View File
@@ -0,0 +1,88 @@
import os
import sys
import unittest
from unittest.mock import MagicMock, patch
# Ensure airflow/dags directory is on python path
DAGS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "dags"))
if DAGS_DIR not in sys.path:
sys.path.insert(0, DAGS_DIR)
from helpers.base_lakehouse_operator import BaseLakehouseOperator
from helpers.duckdb_operator import DuckDBIcebergOperator
from helpers.pyiceberg_operator import PyIcebergTransformationOperator
from helpers.dbt_trino_operator import DbtTrinoIcebergOperator
from helpers.pyspark_operator import PySparkIcebergOperator
class TestLakehouseOperators(unittest.TestCase):
def test_base_lakehouse_operator_initialization(self):
op = BaseLakehouseOperator(
task_id="test_base_task",
inlets=["s3://dlh-bronze/test_data.parquet"],
outlets=["iceberg://dlh_lakehouse.silver.test_data"],
pipeline_id="test_pipeline",
layer="silver",
engine="generic"
)
self.assertEqual(op.task_id, "test_base_task")
self.assertEqual(op.inlets, ["s3://dlh-bronze/test_data.parquet"])
self.assertEqual(op.outlets, ["iceberg://dlh_lakehouse.silver.test_data"])
self.assertEqual(op.layer, "silver")
self.assertEqual(op.engine, "generic")
def test_duckdb_iceberg_operator_initialization(self):
op = DuckDBIcebergOperator(
task_id="silver_duckdb_task",
source_parquet_path="s3://dlh-bronze/environmental_permits.parquet",
target_table_name="dlh_lakehouse.silver.environmental_permits",
primary_keys=["id", "updated_at"]
)
self.assertEqual(op.task_id, "silver_duckdb_task")
self.assertEqual(op.inlets, ["s3://dlh-bronze/environmental_permits.parquet"])
self.assertEqual(op.outlets, ["dlh_lakehouse.silver.environmental_permits"])
self.assertEqual(op.layer, "silver")
self.assertEqual(op.engine, "duckdb")
self.assertIn("PARTITION BY id", op.transformation_sql)
def test_pyiceberg_operator_initialization(self):
op = PyIcebergTransformationOperator(
task_id="silver_pyiceberg_task",
source_parquet_path="s3://dlh-bronze/waste_reports.parquet",
target_table_name="dlh_lakehouse.silver.waste_reports",
write_mode="overwrite"
)
self.assertEqual(op.task_id, "silver_pyiceberg_task")
self.assertEqual(op.inlets, ["s3://dlh-bronze/waste_reports.parquet"])
self.assertEqual(op.outlets, ["dlh_lakehouse.silver.waste_reports"])
self.assertEqual(op.layer, "silver")
self.assertEqual(op.engine, "pyiceberg")
self.assertEqual(op.write_mode, "overwrite")
def test_dbt_trino_operator_initialization(self):
op = DbtTrinoIcebergOperator(
task_id="gold_dbt_task",
select_models="gold_permits_summary_daily",
layer="gold"
)
self.assertEqual(op.task_id, "gold_dbt_task")
self.assertEqual(op.select_models, "gold_permits_summary_daily")
self.assertEqual(op.layer, "gold")
self.assertEqual(op.engine, "dbt_trino")
self.assertEqual(op.outlets, ["gold.gold_permits_summary_daily"])
def test_pyspark_operator_initialization(self):
op = PySparkIcebergOperator(
task_id="silver_spark_task",
source_parquet_path="s3://dlh-bronze/spku_realtime.parquet",
target_table_name="dlh_lakehouse.silver.spku_realtime"
)
self.assertEqual(op.task_id, "silver_spark_task")
self.assertEqual(op.inlets, ["s3://dlh-bronze/spku_realtime.parquet"])
self.assertEqual(op.outlets, ["dlh_lakehouse.silver.spku_realtime"])
self.assertEqual(op.layer, "silver")
self.assertEqual(op.engine, "pyspark")
if __name__ == "__main__":
unittest.main()
+122
View File
@@ -0,0 +1,122 @@
"""
Unit tests for SitomatCallbackOperator (F16 Pipeline Hub Integration).
Menggunakan unittest.mock standar tanpa dependensi eksternal tambahan.
"""
import os
import sys
import pytest
from unittest.mock import patch, MagicMock
# Ensure airflow/dags directory is on python path
DAGS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "dags"))
if DAGS_DIR not in sys.path:
sys.path.insert(0, DAGS_DIR)
from helpers.sitomat_callback_operator import SitomatCallbackOperator, create_sitomat_session
class TestSitomatCallbackOperator:
def test_invalid_layer_raises_value_error(self):
with pytest.raises(ValueError, match="Layer 'platinum' tidak valid"):
SitomatCallbackOperator(
task_id="test_invalid_layer",
run_id="run-123",
layer="platinum",
artifact_ref="table.name"
)
def test_mock_mode_execution(self):
operator = SitomatCallbackOperator(
task_id="test_mock",
run_id="run-001",
layer="bronze",
artifact_ref="iceberg.bronze.permits",
checksum="sha256-mock",
mock_mode=True
)
result = operator.execute()
assert result["status"] == "mock_success"
assert result["acknowledged"] is True
assert result["layer"] == "bronze"
assert result["runId"] == "run-001"
@patch("helpers.sitomat_callback_operator.create_sitomat_session")
def test_successful_bronze_callback(self, mock_create_session):
mock_session = MagicMock()
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.json.return_value = {"success": True, "versionId": "v1-uuid", "layer": "bronze"}
mock_session.post.return_value = mock_response
mock_create_session.return_value = mock_session
sitomat_url = "http://sitomat.bpkp.go.id:3000"
run_id = "run-20261005-001"
operator = SitomatCallbackOperator(
task_id="test_bronze_callback",
run_id=run_id,
layer="bronze",
artifact_ref="lakehouse.bronze.permits_cdc",
checksum="sha256-111",
sitomat_base_url=sitomat_url,
api_key="secret-pipeline-key",
metadata_payload={"record_count": 8, "ge_status": "PASSED"},
mock_mode=False
)
result = operator.execute()
assert result["status"] == "success"
assert result["statusCode"] == 201
assert result["response"]["versionId"] == "v1-uuid"
mock_session.post.assert_called_once()
@patch("helpers.sitomat_callback_operator.create_sitomat_session")
def test_idempotent_replay_conflict_409(self, mock_create_session):
mock_session = MagicMock()
mock_response = MagicMock()
mock_response.status_code = 409
mock_response.text = "conflict_artifact: run already registered"
mock_session.post.return_value = mock_response
mock_create_session.return_value = mock_session
sitomat_url = "http://sitomat.bpkp.go.id:3000"
run_id = "run-20261005-002"
operator = SitomatCallbackOperator(
task_id="test_conflict_silver",
run_id=run_id,
layer="silver",
artifact_ref="lakehouse.silver.permits_clean",
derived_from_version_id="v1-uuid",
checksum="sha256-222",
sitomat_base_url=sitomat_url,
mock_mode=False
)
result = operator.execute()
assert result["status"] == "conflict_acknowledged"
assert result["statusCode"] == 409
assert "conflict_artifact" in result["response"]
@patch("helpers.sitomat_callback_operator.create_sitomat_session")
def test_connection_error_graceful_handling(self, mock_create_session):
import requests
mock_session = MagicMock()
mock_session.post.side_effect = requests.exceptions.ConnectionError("Connection refused to Sitomat")
mock_create_session.return_value = mock_session
operator = SitomatCallbackOperator(
task_id="test_conn_error",
run_id="run-unreachable",
layer="gold",
artifact_ref="lakehouse.gold.mart",
sitomat_base_url="http://192.0.2.1:9999",
mock_mode=False
)
result = operator.execute()
assert result["status"] == "connection_warning"
assert result["acknowledged"] is False
assert "Connection refused" in result["error"]