From 3ef91d78a96f31d557a0f2ad717e812a51c982f7 Mon Sep 17 00:00:00 2001 From: "DESKTOP-691E36A\\Administrator" Date: Mon, 10 Aug 2026 22:40:11 -0600 Subject: [PATCH 1/2] 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() From 3bcb804163cc3b00461122224ab85df3fd8172ab Mon Sep 17 00:00:00 2001 From: "DESKTOP-691E36A\\Administrator" Date: Fri, 21 Aug 2026 13:04:08 -0600 Subject: [PATCH 2/2] docs: cross-link published Dev.to articles in README --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index bcfc9b5..f5094af 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,13 @@ Coverage includes contract loading, each check type, end-to-end runs, history pe Complements [`production-data-pipeline`](https://github.com/br413/production-data-pipeline), which focuses on incremental ingestion and transformation. This repository isolates the quality and observability boundary. +## Writing + +| Article | Topic | +|---------|-------| +| [Building a Production Data Pipeline with Incremental Loading and dbt](https://dev.to/bobby_ray_581732c715283b2/building-a-production-data-pipeline-with-incremental-loading-and-dbt-2e2c) | Incremental ingestion and medallion layering — companion pipeline repo | +| [Data Quality Contracts in Production Pipelines](https://dev.to/bobby_ray_581732c715283b2/data-quality-contracts-in-production-pipelines-without-a-separate-platform-team-f3) | YAML contracts, quarantine/DLQ stack, alert routing — uses this repo | + ## Topics `data-quality` · `data-observability` · `data-contracts` · `data-engineering` · `data-platform` · `airflow` · `python` · `schema-validation` · `monitoring` · `alerting`