feat: upload alarm snapshots to webhook payloads
This commit is contained in:
132
tests/test_alarm_snapshots.py
Normal file
132
tests/test_alarm_snapshots.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from cold_display_guard.alarm_snapshots import (
|
||||
capture_alert_snapshot,
|
||||
load_alarm_snapshot_settings,
|
||||
upload_snapshot_bytes,
|
||||
)
|
||||
from cold_display_guard.vision import Frame
|
||||
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
class AlarmSnapshotTests(unittest.TestCase):
|
||||
def test_load_alarm_snapshot_settings_from_config(self) -> None:
|
||||
settings = load_alarm_snapshot_settings(
|
||||
{
|
||||
"alarm_snapshot_upload": {
|
||||
"enabled": True,
|
||||
"service_url": "https://ota.zhengxinshipin.com",
|
||||
"secret": "change-me-in-production",
|
||||
"object_key_prefix": "alarms/cold-display",
|
||||
"connect_timeout_seconds": 4,
|
||||
"read_timeout_seconds": 9,
|
||||
"encode_timeout_seconds": 7,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(settings.enabled)
|
||||
self.assertEqual(settings.service_url, "https://ota.zhengxinshipin.com")
|
||||
self.assertEqual(settings.secret, "change-me-in-production")
|
||||
self.assertEqual(settings.object_key_prefix, "alarms/cold-display")
|
||||
self.assertEqual(settings.connect_timeout_seconds, 4)
|
||||
self.assertEqual(settings.read_timeout_seconds, 9)
|
||||
self.assertEqual(settings.encode_timeout_seconds, 7)
|
||||
|
||||
def test_upload_snapshot_bytes_uses_documented_chunk_upload_flow(self) -> None:
|
||||
json_calls: list[tuple[str, dict[str, object]]] = []
|
||||
chunk_calls: list[tuple[str, dict[str, str], bytes, dict[str, str]]] = []
|
||||
|
||||
def fake_post_json(url: str, payload: dict[str, object], timeout: tuple[float, float]) -> tuple[int, dict[str, object]]:
|
||||
json_calls.append((url, payload))
|
||||
if url.endswith("/token/generate"):
|
||||
return 200, {"token": "token-1", "expires_at": 1770003600}
|
||||
if url.endswith("/upload/init"):
|
||||
return 200, {"upload_id": "upload-1"}
|
||||
if url.endswith("/upload/complete"):
|
||||
return 200, {"upload_id": "upload-1", "object_key": "uploads/alarms/a.jpg", "file_size": 3, "file_md5": "900150983cd24fb0d6963f7d28e17f72"}
|
||||
raise AssertionError(url)
|
||||
|
||||
def fake_post_multipart(
|
||||
url: str,
|
||||
fields: dict[str, str],
|
||||
file_field: str,
|
||||
file_name: str,
|
||||
file_bytes: bytes,
|
||||
timeout: tuple[float, float],
|
||||
) -> tuple[int, dict[str, object]]:
|
||||
chunk_calls.append((url, fields, file_bytes, {"file_field": file_field, "file_name": file_name}))
|
||||
return 200, {"upload_id": "upload-1", "index": 0, "size": len(file_bytes), "received_chunks": 1, "total_chunks": 1}
|
||||
|
||||
result = upload_snapshot_bytes(
|
||||
b"abc",
|
||||
file_name="alarm.jpg",
|
||||
object_key_hint="alarms/a.jpg",
|
||||
settings=load_alarm_snapshot_settings({}),
|
||||
post_json_request=fake_post_json,
|
||||
post_multipart_request=fake_post_multipart,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "uploaded")
|
||||
self.assertEqual(result["object_key"], "uploads/alarms/a.jpg")
|
||||
self.assertEqual(json_calls[0][0], "https://ota.zhengxinshipin.com/token/generate")
|
||||
self.assertEqual(json_calls[1][0], "https://ota.zhengxinshipin.com/upload/init")
|
||||
self.assertEqual(json_calls[2][0], "https://ota.zhengxinshipin.com/upload/complete")
|
||||
self.assertIn("token=token-1", chunk_calls[0][0])
|
||||
self.assertIn("upload_id=upload-1", chunk_calls[0][0])
|
||||
self.assertEqual(chunk_calls[0][1]["chunk_md5"], "900150983cd24fb0d6963f7d28e17f72")
|
||||
self.assertEqual(chunk_calls[0][3]["file_field"], "chunk")
|
||||
|
||||
def test_capture_alert_snapshot_skips_non_alert_events(self) -> None:
|
||||
result = capture_alert_snapshot(
|
||||
Frame(width=1, height=1, rgb=b"\x00\x00\x00"),
|
||||
[{"event": "batch_started", "severity": "info", "batch_id": "batch_1"}],
|
||||
{},
|
||||
now=datetime(2026, 6, 9, 9, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_capture_alert_snapshot_uploads_current_frame_for_alert_events(self) -> None:
|
||||
encode_calls: list[Frame] = []
|
||||
upload_calls: list[tuple[bytes, str, str]] = []
|
||||
|
||||
def fake_encode(frame: Frame, timeout_seconds: float) -> bytes:
|
||||
encode_calls.append(frame)
|
||||
return b"jpeg-bytes"
|
||||
|
||||
def fake_upload(
|
||||
image_bytes: bytes,
|
||||
*,
|
||||
file_name: str,
|
||||
object_key_hint: str,
|
||||
settings,
|
||||
post_json_request=None,
|
||||
post_multipart_request=None,
|
||||
) -> dict[str, object]:
|
||||
upload_calls.append((image_bytes, file_name, object_key_hint))
|
||||
return {"status": "uploaded", "object_key": "uploads/alarms/test.jpg", "file_name": file_name}
|
||||
|
||||
result = capture_alert_snapshot(
|
||||
Frame(width=1, height=1, rgb=b"\x01\x02\x03"),
|
||||
[{"event": "time_alarm", "severity": "alarm", "batch_id": "batch_1", "camera_id": "cam_1", "zone_id": "1", "ts": "2026-06-09T09:00:00+00:00"}],
|
||||
{"alarm_snapshot_upload": {"enabled": True, "object_key_prefix": "alarms/cold-display"}},
|
||||
now=datetime(2026, 6, 9, 9, 0, tzinfo=UTC),
|
||||
jpeg_encoder=fake_encode,
|
||||
uploader=fake_upload,
|
||||
)
|
||||
|
||||
self.assertEqual(len(encode_calls), 1)
|
||||
self.assertEqual(upload_calls[0][0], b"jpeg-bytes")
|
||||
self.assertEqual(result["status"], "uploaded")
|
||||
self.assertEqual(result["object_key"], "uploads/alarms/test.jpg")
|
||||
self.assertEqual(result["batch_ids"], ["batch_1"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -128,6 +128,12 @@ zone_ids = ["1", "2", "3"]
|
||||
save_config_document(
|
||||
path,
|
||||
{
|
||||
"alarm_snapshot_upload": {
|
||||
"enabled": True,
|
||||
"service_url": "https://ota.zhengxinshipin.com",
|
||||
"secret": "change-me-in-production",
|
||||
"object_key_prefix": "cold-display/alarms",
|
||||
},
|
||||
"webhooks": {
|
||||
"enabled": True,
|
||||
"event_url": "https://example.com/events",
|
||||
@@ -146,6 +152,10 @@ zone_ids = ["1", "2", "3"]
|
||||
)
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("[alarm_snapshot_upload]", text)
|
||||
self.assertIn('service_url = "https://ota.zhengxinshipin.com"', text)
|
||||
self.assertIn('secret = "change-me-in-production"', text)
|
||||
self.assertIn('object_key_prefix = "cold-display/alarms"', text)
|
||||
self.assertIn("[webhooks]", text)
|
||||
self.assertIn('event_url = "https://example.com/events"', text)
|
||||
self.assertIn('case_url = "https://example.com/cases"', text)
|
||||
|
||||
@@ -9,11 +9,13 @@ from pathlib import Path
|
||||
from cold_display_guard.cases import CaseStore
|
||||
from cold_display_guard.main import (
|
||||
case_sink_path,
|
||||
capture_runtime_alarm_snapshot,
|
||||
deliver_runtime_webhooks,
|
||||
persist_case_updates,
|
||||
restore_runtime_state,
|
||||
webhook_retry_sink_path,
|
||||
)
|
||||
from cold_display_guard.vision import Frame
|
||||
from cold_display_guard.webhooks import load_retry_snapshots
|
||||
|
||||
|
||||
@@ -114,6 +116,29 @@ class RuntimeRestoreTests(unittest.TestCase):
|
||||
self.assertEqual(deliveries[0][1]["kind"], "batch_event")
|
||||
self.assertEqual(deliveries[1][1]["kind"], "case_event")
|
||||
|
||||
def test_capture_runtime_alarm_snapshot_uses_current_frame_for_alert_events(self) -> None:
|
||||
frame = Frame(width=1, height=1, rgb=b"\x01\x02\x03")
|
||||
result = capture_runtime_alarm_snapshot(
|
||||
frame,
|
||||
[
|
||||
{
|
||||
"event": "time_alarm",
|
||||
"severity": "alarm",
|
||||
"batch_id": "batch_000001",
|
||||
"camera_id": "cam_01",
|
||||
"zone_id": "1",
|
||||
"ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||
}
|
||||
],
|
||||
{"alarm_snapshot_upload": {"enabled": True}},
|
||||
now=datetime(2026, 6, 9, 9, 0, tzinfo=UTC),
|
||||
jpeg_encoder=lambda frame, timeout_seconds: b"jpeg",
|
||||
uploader=lambda image_bytes, **kwargs: {"status": "uploaded", "object_key": "uploads/alarms/a.jpg", "file_name": "a.jpg"},
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "uploaded")
|
||||
self.assertEqual(result["object_key"], "uploads/alarms/a.jpg")
|
||||
|
||||
def test_deliver_runtime_webhooks_enqueues_failure_and_drains_due_retry(self) -> None:
|
||||
attempts = {"count": 0}
|
||||
|
||||
@@ -169,6 +194,53 @@ class RuntimeRestoreTests(unittest.TestCase):
|
||||
self.assertEqual(retries[-1]["status"], "delivered")
|
||||
self.assertEqual(retries[-1]["attempt_count"], 2)
|
||||
|
||||
def test_deliver_runtime_webhooks_includes_snapshot_path_in_alert_payloads(self) -> None:
|
||||
deliveries: list[dict[str, object]] = []
|
||||
|
||||
def fake_post(url: str, payload: dict[str, object], timeout: tuple[float, float]) -> tuple[int, str]:
|
||||
deliveries.append(payload)
|
||||
return 200, "ok"
|
||||
|
||||
deliver_runtime_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",
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"case_id": "case_batch_000001",
|
||||
"batch_id": "batch_000001",
|
||||
"case_type": "time_alarm",
|
||||
"case_status": "open",
|
||||
"source_event": "time_alarm",
|
||||
"handled_source": "",
|
||||
"created_at": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||
"updated_at": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||
}
|
||||
],
|
||||
{
|
||||
"webhooks": {
|
||||
"enabled": True,
|
||||
"event_url": "https://example.com/events",
|
||||
"case_url": "https://example.com/cases",
|
||||
}
|
||||
},
|
||||
Path(tempfile.mkdtemp()) / "webhook_delivery.jsonl",
|
||||
http_post=fake_post,
|
||||
snapshot_upload={"status": "uploaded", "object_key": "uploads/alarms/a.jpg", "batch_ids": ["batch_000001"]},
|
||||
)
|
||||
|
||||
self.assertEqual(deliveries[0]["snapshot_object_key"], "uploads/alarms/a.jpg")
|
||||
self.assertEqual(deliveries[1]["snapshot_object_key"], "uploads/alarms/a.jpg")
|
||||
|
||||
def test_restore_runtime_state_uses_stable_occupancy_when_raw_metrics_flicker(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
diagnostics_path = Path(tmpdir) / "runtime_diagnostics.jsonl"
|
||||
|
||||
@@ -392,6 +392,11 @@ class ManageApiTests(unittest.TestCase):
|
||||
{
|
||||
"case_sink": {"path": "logs/cases.jsonl"},
|
||||
"webhook_retry_sink": {"path": "logs/webhook_retry.jsonl"},
|
||||
"alarm_snapshot_upload": {
|
||||
"enabled": True,
|
||||
"service_url": "https://ota.zhengxinshipin.com",
|
||||
"secret": "change-me-in-production",
|
||||
},
|
||||
"webhooks": {
|
||||
"enabled": True,
|
||||
"event_url": "https://example.com/events",
|
||||
@@ -406,6 +411,9 @@ class ManageApiTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(payload["case_sink"]["path"], str((root / "logs" / "cases.jsonl").resolve()))
|
||||
self.assertEqual(payload["webhook_retry_sink"]["path"], str((root / "logs" / "webhook_retry.jsonl").resolve()))
|
||||
self.assertTrue(payload["alarm_snapshot_upload"]["enabled"])
|
||||
self.assertEqual(payload["alarm_snapshot_upload"]["service_url"], "https://ota.zhengxinshipin.com")
|
||||
self.assertNotIn("secret", payload["alarm_snapshot_upload"])
|
||||
self.assertTrue(payload["webhooks"]["enabled"])
|
||||
self.assertEqual(payload["webhooks"]["retry_max_attempts"], 4)
|
||||
self.assertNotIn("callback_token", payload["webhooks"])
|
||||
|
||||
@@ -68,6 +68,24 @@ class WebhookTests(unittest.TestCase):
|
||||
self.assertEqual(payload["event"], "time_alarm")
|
||||
self.assertEqual(payload["zone_label"], "区域 1")
|
||||
|
||||
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(
|
||||
{
|
||||
@@ -85,6 +103,23 @@ class WebhookTests(unittest.TestCase):
|
||||
self.assertEqual(payload["action"], "updated")
|
||||
self.assertEqual(payload["case_id"], "case_batch_000001")
|
||||
|
||||
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]]] = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user