From 3ef91d78a96f31d557a0f2ad717e812a51c982f7 Mon Sep 17 00:00:00 2001 From: "DESKTOP-691E36A\\Administrator" Date: Mon, 10 Aug 2026 22:40:11 -0600 Subject: [PATCH] test: add webhook alert integration tests Mock urllib delivery for WebhookAlertChannel and AlertRouter fan-out. Closes #9. --- CHANGELOG.md | 6 +++ README.md | 2 +- tests/test_alerts.py | 90 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92f1b75..0961fdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project are documented here. +## [Unreleased] + +### Added + +- Webhook alert integration tests for `WebhookAlertChannel` and router fan-out + ## [0.1.0] - 2026-07-14 ### Added diff --git a/README.md b/README.md index ccdf87d..bcfc9b5 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Scheduling options are documented in [`docs/scheduling.md`](docs/scheduling.md). - [x] Failure triage runbook in [`docs/operations.md`](docs/operations.md) - [x] Unit and integration tests with CI - [x] Airflow DAG `dqo_contract_checks` for scheduled contract runs -- [ ] Webhook alert integration tests against mock server +- [x] Webhook alert integration tests against mock server ## Technology stack diff --git a/tests/test_alerts.py b/tests/test_alerts.py index 1cc1182..0642e33 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -1,4 +1,15 @@ -from src.dqo.alerts import AlertRouter, ConsoleAlertChannel, FileAlertChannel +from urllib.error import URLError +from unittest.mock import MagicMock, patch + +import pytest + +from src.dqo.alerts import ( + AlertEvent, + AlertRouter, + ConsoleAlertChannel, + FileAlertChannel, + WebhookAlertChannel, +) from src.dqo.models import CheckResult, CheckStatus, RunSummary, Severity from datetime import datetime, timezone @@ -30,3 +41,80 @@ def test_alert_router_writes_console_and_file(tmp_path, capsys) -> None: captured = capsys.readouterr() assert "CRITICAL" in captured.out assert alert_file.read_text(encoding="utf-8").strip() + + +def test_webhook_alert_channel_posts_json_payload() -> None: + channel = WebhookAlertChannel("https://example.test/hook") + event = AlertEvent( + contract_name="orders", + run_id="run-alert-1", + check_type="nulls", + severity=Severity.CRITICAL, + message="missing order_total", + ) + + with patch("src.dqo.alerts.request.urlopen") as urlopen: + urlopen.return_value.__enter__ = MagicMock(return_value=MagicMock()) + urlopen.return_value.__exit__ = MagicMock(return_value=False) + channel.send(event) + + request_obj = urlopen.call_args.args[0] + assert request_obj.full_url == "https://example.test/hook" + assert request_obj.headers["Content-type"] == "application/json" + assert b'"contract_name": "orders"' in request_obj.data + assert b'"check_type": "nulls"' in request_obj.data + assert b'"severity": "critical"' in request_obj.data + + +def test_webhook_alert_channel_raises_on_delivery_failure() -> None: + channel = WebhookAlertChannel("https://example.test/hook") + event = AlertEvent( + contract_name="orders", + run_id="run-alert-1", + check_type="nulls", + severity=Severity.CRITICAL, + message="missing order_total", + ) + + with patch("src.dqo.alerts.request.urlopen", side_effect=URLError("network down")): + with pytest.raises(RuntimeError, match="webhook delivery failed"): + channel.send(event) + + +def test_alert_router_delivers_failed_checks_to_webhook() -> None: + router = AlertRouter([WebhookAlertChannel("https://example.test/hook")]) + + with patch("src.dqo.alerts.request.urlopen") as urlopen: + urlopen.return_value.__enter__ = MagicMock(return_value=MagicMock()) + urlopen.return_value.__exit__ = MagicMock(return_value=False) + events = router.route(_failed_summary()) + + assert len(events) == 1 + assert urlopen.call_count == 1 + request_obj = urlopen.call_args.args[0] + assert b"missing order_total" in request_obj.data + + +def test_alert_router_skips_info_severity_for_webhook() -> None: + summary = RunSummary( + contract_name="orders", + run_id="run-alert-2", + started_at=datetime(2026, 7, 14, 10, 0, tzinfo=timezone.utc), + finished_at=datetime(2026, 7, 14, 10, 1, tzinfo=timezone.utc), + results=( + CheckResult( + contract_name="orders", + check_type="freshness", + status=CheckStatus.FAILED, + message="slightly stale", + severity=Severity.INFO, + ), + ), + ) + router = AlertRouter([WebhookAlertChannel("https://example.test/hook")]) + + with patch("src.dqo.alerts.request.urlopen") as urlopen: + events = router.route(summary) + + assert events == [] + urlopen.assert_not_called()