Add missing fields (event_code, camera_ip, started_at, ended_at, dwell_seconds, is_discarded, alerted_at, etc.) to both batch_event and case_event payloads. Introduce source_id config for payload injection and infer_camera_ip to extract IP from RTSP stream URL. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
410 lines
17 KiB
Python
410 lines
17 KiB
Python
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_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()
|