from __future__ import annotations import json import tempfile import unittest from datetime import datetime, timezone from pathlib import Path from cold_display_guard.webhooks import ( build_batch_event_payload, build_case_event_payload, drain_webhook_retries, load_webhook_settings, load_retry_snapshots, send_batch_event_webhooks, send_case_webhooks, ) UTC = timezone.utc class WebhookTests(unittest.TestCase): def test_load_webhook_settings_from_config(self) -> None: settings = load_webhook_settings( { "webhooks": { "enabled": True, "event_url": "https://example.com/events", "case_url": "https://example.com/cases", "source_id": "cold-display-guard", "callback_token": "secret", "connect_timeout_seconds": 4, "read_timeout_seconds": 6, "retry_max_attempts": 4, "retry_backoff_seconds": 15, "retry_max_backoff_seconds": 90, "retry_batch_limit": 8, } } ) self.assertTrue(settings.enabled) self.assertEqual(settings.event_url, "https://example.com/events") self.assertEqual(settings.case_url, "https://example.com/cases") self.assertEqual(settings.source_id, "cold-display-guard") self.assertEqual(settings.callback_token, "secret") self.assertEqual(settings.connect_timeout_seconds, 4) self.assertEqual(settings.read_timeout_seconds, 6) self.assertEqual(settings.retry_max_attempts, 4) self.assertEqual(settings.retry_backoff_seconds, 15) self.assertEqual(settings.retry_max_backoff_seconds, 90) self.assertEqual(settings.retry_batch_limit, 8) def test_build_batch_event_payload_wraps_runtime_event(self) -> None: payload = build_batch_event_payload( { "event": "time_alarm", "ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(), "batch_id": "batch_000001", "camera_id": "cam_01", "zone_id": "1", "zone_label": "区域 1", "severity": "alarm", "state": "alerted", "started_at": datetime(2026, 6, 9, 8, 40, tzinfo=UTC).isoformat(), "dwell_seconds": 1200, "alerted_at": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(), }, camera_ip="192.168.3.4", ) self.assertEqual(payload["kind"], "batch_event") self.assertEqual(payload["event"], "time_alarm") self.assertEqual(payload["event_code"], "batch_000001") self.assertEqual(payload["camera_ip"], "192.168.3.4") self.assertEqual(payload["zone_label"], "区域 1") self.assertEqual(payload["started_at"], datetime(2026, 6, 9, 8, 40, tzinfo=UTC).isoformat()) self.assertEqual(payload["dwell_seconds"], 1200) self.assertFalse(payload["is_discarded"]) self.assertEqual(payload["alerted_at"], datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat()) def test_build_batch_event_payload_preserves_pre_warning_and_alarm_times(self) -> None: pre_warned_at = datetime(2026, 6, 9, 8, 59, tzinfo=UTC).isoformat() alarm_at = datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat() pre_warning_payload = build_batch_event_payload( { "event": "time_pre_warning", "ts": pre_warned_at, "batch_id": "batch_000001", "camera_id": "cam_01", "zone_id": "1", "zone_label": "区域 1", "severity": "warning", "state": "pre_warning", "started_at": datetime(2026, 6, 9, 8, 58, tzinfo=UTC).isoformat(), "pre_warned_at": pre_warned_at, } ) alarm_payload = build_batch_event_payload( { "event": "time_alarm", "ts": alarm_at, "batch_id": "batch_000001", "camera_id": "cam_01", "zone_id": "1", "zone_label": "区域 1", "severity": "alarm", "state": "alerted", "started_at": datetime(2026, 6, 9, 8, 58, tzinfo=UTC).isoformat(), "pre_warned_at": pre_warned_at, "alerted_at": alarm_at, } ) self.assertEqual(pre_warning_payload["pre_warned_at"], pre_warned_at) self.assertEqual(pre_warning_payload["created_at"], pre_warned_at) self.assertEqual(pre_warning_payload["alarm_at"], "") self.assertEqual(alarm_payload["pre_warned_at"], pre_warned_at) self.assertEqual(alarm_payload["alarm_at"], alarm_at) def test_build_batch_event_payload_includes_uploaded_snapshot_path(self) -> None: payload = build_batch_event_payload( { "event": "time_alarm", "ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(), "batch_id": "batch_000001", "camera_id": "cam_01", "zone_id": "1", "zone_label": "区域 1", "severity": "alarm", "state": "alerted", }, snapshot_upload={"status": "uploaded", "object_key": "uploads/alarms/a.jpg", "batch_ids": ["batch_000001"]}, ) self.assertEqual(payload["snapshot_upload_status"], "uploaded") self.assertEqual(payload["snapshot_object_key"], "uploads/alarms/a.jpg") def test_build_case_event_payload_wraps_case_snapshot(self) -> None: payload = build_case_event_payload( { "case_id": "case_batch_000001", "case_type": "warning_escalated", "case_status": "handled", "batch_id": "batch_000001", "camera_id": "cam_01", "zone_id": "1", "zone_label": "区域 1", "source_event": "warning_escalated", "created_at": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(), "handled_at": datetime(2026, 6, 9, 9, 6, tzinfo=UTC).isoformat(), "handled_source": "auto_closed", "updated_at": datetime(2026, 6, 9, 9, 5, tzinfo=UTC).isoformat(), "payload": { "event": { "started_at": datetime(2026, 6, 9, 8, 40, tzinfo=UTC).isoformat(), "ended_at": datetime(2026, 6, 9, 9, 4, tzinfo=UTC).isoformat(), "dwell_seconds": 1440, "alerted_at": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(), } }, }, camera_ip="192.168.3.4", ) self.assertEqual(payload["kind"], "case_event") self.assertEqual(payload["action"], "handled") self.assertEqual(payload["case_id"], "case_batch_000001") self.assertEqual(payload["event_code"], "batch_000001") self.assertEqual(payload["camera_id"], "cam_01") self.assertEqual(payload["camera_ip"], "192.168.3.4") self.assertEqual(payload["zone_label"], "区域 1") self.assertEqual(payload["started_at"], datetime(2026, 6, 9, 8, 40, tzinfo=UTC).isoformat()) self.assertEqual(payload["ended_at"], datetime(2026, 6, 9, 9, 4, tzinfo=UTC).isoformat()) self.assertEqual(payload["dwell_seconds"], 1440) self.assertTrue(payload["is_discarded"]) self.assertEqual(payload["discarded_at"], datetime(2026, 6, 9, 9, 6, tzinfo=UTC).isoformat()) self.assertEqual(payload["alerted_at"], datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat()) def test_build_case_event_payload_includes_uploaded_snapshot_path(self) -> None: payload = build_case_event_payload( { "case_id": "case_batch_000001", "case_type": "warning_escalated", "case_status": "open", "batch_id": "batch_000001", "source_event": "warning_escalated", "handled_source": "", "updated_at": datetime(2026, 6, 9, 9, 5, tzinfo=UTC).isoformat(), }, snapshot_upload={"status": "uploaded", "object_key": "uploads/alarms/a.jpg", "batch_ids": ["batch_000001"]}, ) self.assertEqual(payload["snapshot_upload_status"], "uploaded") self.assertEqual(payload["snapshot_object_key"], "uploads/alarms/a.jpg") def test_send_batch_event_webhooks_delivers_payload(self) -> None: deliveries: list[tuple[str, dict[str, object], tuple[float, float]]] = [] def fake_post(url: str, payload: dict[str, object], timeout: tuple[float, float]) -> tuple[int, str]: deliveries.append((url, payload, timeout)) return 202, "ok" with tempfile.TemporaryDirectory() as tmpdir: audit_path = Path(tmpdir) / "webhook_delivery.jsonl" send_batch_event_webhooks( [ { "event": "time_alarm", "ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(), "batch_id": "batch_000001", "camera_id": "cam_01", "zone_id": "1", "zone_label": "区域 1", "severity": "alarm", "state": "alerted", } ], { "webhooks": { "enabled": True, "event_url": "https://example.com/events", "source_id": "cold-display-guard", "connect_timeout_seconds": 4, "read_timeout_seconds": 6, }, "stream": {"rtsp_url": "rtsp://admin:secret@192.168.3.4:554/h264/ch1/main/av_stream"}, }, audit_path, http_post=fake_post, ) self.assertEqual(deliveries[0][0], "https://example.com/events") self.assertEqual(deliveries[0][1]["kind"], "batch_event") self.assertEqual(deliveries[0][1]["camera_ip"], "192.168.3.4") self.assertEqual(deliveries[0][1]["source_id"], "cold-display-guard") self.assertEqual(deliveries[0][2], (4.0, 6.0)) def test_send_case_webhooks_delivers_payload(self) -> None: deliveries: list[tuple[str, dict[str, object]]] = [] def fake_post(url: str, payload: dict[str, object], timeout: tuple[float, float]) -> tuple[int, str]: deliveries.append((url, payload)) return 200, "ok" with tempfile.TemporaryDirectory() as tmpdir: audit_path = Path(tmpdir) / "webhook_delivery.jsonl" send_case_webhooks( [ { "case_id": "case_batch_000001", "case_type": "time_alarm", "case_status": "handled", "batch_id": "batch_000001", "source_event": "time_alarm", "handled_source": "manual", "updated_at": datetime(2026, 6, 9, 9, 10, tzinfo=UTC).isoformat(), } ], { "webhooks": { "enabled": True, "case_url": "https://example.com/cases", "source_id": "cold-display-guard", }, "stream": {"rtsp_url": "rtsp://admin:secret@192.168.3.4:554/h264/ch1/main/av_stream"}, }, audit_path, http_post=fake_post, ) self.assertEqual(deliveries[0][0], "https://example.com/cases") self.assertEqual(deliveries[0][1]["kind"], "case_event") self.assertEqual(deliveries[0][1]["action"], "handled") self.assertEqual(deliveries[0][1]["camera_ip"], "192.168.3.4") self.assertEqual(deliveries[0][1]["source_id"], "cold-display-guard") def test_failed_delivery_is_logged_without_raising(self) -> None: def fake_post(url: str, payload: dict[str, object], timeout: tuple[float, float]) -> tuple[int, str]: raise OSError("network down") with tempfile.TemporaryDirectory() as tmpdir: audit_path = Path(tmpdir) / "webhook_delivery.jsonl" send_batch_event_webhooks( [ { "event": "time_alarm", "ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(), "batch_id": "batch_000001", "camera_id": "cam_01", "zone_id": "1", "zone_label": "区域 1", "severity": "alarm", "state": "alerted", } ], { "webhooks": { "enabled": True, "event_url": "https://example.com/events", } }, audit_path, http_post=fake_post, ) logged = [json.loads(line) for line in audit_path.read_text(encoding="utf-8").splitlines()] self.assertEqual(logged[0]["status"], "error") self.assertEqual(logged[0]["target"], "batch_event") self.assertIn("network down", logged[0]["message"]) def test_non_2xx_delivery_is_enqueued_for_retry(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: audit_path = Path(tmpdir) / "webhook_delivery.jsonl" retry_path = Path(tmpdir) / "webhook_retry.jsonl" send_batch_event_webhooks( [ { "event": "time_alarm", "ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(), "batch_id": "batch_000001", "camera_id": "cam_01", "zone_id": "1", "zone_label": "区域 1", "severity": "alarm", "state": "alerted", } ], { "webhooks": { "enabled": True, "event_url": "https://example.com/events", "retry_max_attempts": 3, "retry_backoff_seconds": 30, } }, audit_path, retry_path=retry_path, http_post=lambda url, payload, timeout: (503, "service unavailable"), now=datetime(2026, 6, 9, 9, 0, tzinfo=UTC), ) retries = load_retry_snapshots(retry_path) logged = [json.loads(line) for line in audit_path.read_text(encoding="utf-8").splitlines()] self.assertEqual(logged[0]["status"], "error") self.assertEqual(logged[0]["status_code"], 503) self.assertEqual(retries[-1]["status"], "pending") self.assertEqual(retries[-1]["attempt_count"], 1) self.assertEqual(retries[-1]["target"], "batch_event") self.assertEqual(retries[-1]["url"], "https://example.com/events") def test_due_retry_is_marked_delivered_after_success(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: audit_path = Path(tmpdir) / "webhook_delivery.jsonl" retry_path = Path(tmpdir) / "webhook_retry.jsonl" config = { "webhooks": { "enabled": True, "event_url": "https://example.com/events", "retry_max_attempts": 3, "retry_backoff_seconds": 30, } } send_batch_event_webhooks( [ { "event": "time_alarm", "ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(), "batch_id": "batch_000001", "camera_id": "cam_01", "zone_id": "1", "zone_label": "区域 1", "severity": "alarm", "state": "alerted", } ], config, audit_path, retry_path=retry_path, http_post=lambda url, payload, timeout: (503, "service unavailable"), now=datetime(2026, 6, 9, 9, 0, tzinfo=UTC), ) drained = drain_webhook_retries( config, retry_path, audit_path, http_post=lambda url, payload, timeout: (200, "ok"), now=datetime(2026, 6, 9, 9, 1, tzinfo=UTC), ) retries = load_retry_snapshots(retry_path) self.assertEqual(len(drained), 1) self.assertEqual(retries[-1]["status"], "delivered") self.assertEqual(retries[-1]["attempt_count"], 2) self.assertEqual(retries[-1]["last_status_code"], 200) def test_retry_reaches_dead_letter_after_attempt_limit(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: audit_path = Path(tmpdir) / "webhook_delivery.jsonl" retry_path = Path(tmpdir) / "webhook_retry.jsonl" config = { "webhooks": { "enabled": True, "event_url": "https://example.com/events", "retry_max_attempts": 2, "retry_backoff_seconds": 30, } } send_batch_event_webhooks( [ { "event": "time_alarm", "ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(), "batch_id": "batch_000001", "camera_id": "cam_01", "zone_id": "1", "zone_label": "区域 1", "severity": "alarm", "state": "alerted", } ], config, audit_path, retry_path=retry_path, http_post=lambda url, payload, timeout: (503, "service unavailable"), now=datetime(2026, 6, 9, 9, 0, tzinfo=UTC), ) drained = drain_webhook_retries( config, retry_path, audit_path, http_post=lambda url, payload, timeout: (503, "still down"), now=datetime(2026, 6, 9, 9, 1, tzinfo=UTC), ) retries = load_retry_snapshots(retry_path) self.assertEqual(len(drained), 1) self.assertEqual(retries[-1]["status"], "dead_letter") self.assertEqual(retries[-1]["attempt_count"], 2) self.assertEqual(retries[-1]["last_status_code"], 503) if __name__ == "__main__": unittest.main()