feat: add webhook case management

This commit is contained in:
2026-06-09 11:13:56 +08:00
parent 490b3089d2
commit 9d791be174
17 changed files with 1982 additions and 12 deletions

View File

@@ -1,15 +1,47 @@
from __future__ import annotations
import http.client
import json
import tempfile
import threading
import unittest
from http.server import ThreadingHTTPServer
from pathlib import Path
from cold_display_guard.config import load_config_document, merge_calibration, save_config_document
from cold_display_guard.manage_api import ManageContext, build_summary
from cold_display_guard.manage_api import ManageContext, build_summary, config_payload, create_handler
class ManageApiTests(unittest.TestCase):
def _serve_once(self, ctx: ManageContext) -> tuple[ThreadingHTTPServer, threading.Thread]:
server = ThreadingHTTPServer(("127.0.0.1", 0), create_handler(ctx))
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, thread
def _request(
self,
server: ThreadingHTTPServer,
method: str,
path: str,
body: dict | None = None,
headers: dict[str, str] | None = None,
) -> tuple[int, dict]:
conn = http.client.HTTPConnection("127.0.0.1", server.server_address[1], timeout=5)
payload = None if body is None else json.dumps(body)
final_headers = {"Content-Type": "application/json"}
final_headers.update(headers or {})
conn.request(method, path, body=payload, headers=final_headers)
response = conn.getresponse()
raw = response.read().decode("utf-8")
conn.close()
return response.status, json.loads(raw or "{}")
def _stop_server(self, server: ThreadingHTTPServer, thread: threading.Thread) -> None:
server.shutdown()
thread.join()
server.server_close()
def test_merge_calibration_updates_zones_and_trash(self) -> None:
data = {
"camera_id": "cam",
@@ -350,6 +382,242 @@ class ManageApiTests(unittest.TestCase):
self.assertEqual(summary["metrics"]["latest_zone_counts"], {"1": 1, "2": 0})
def test_config_payload_exposes_case_sink_and_webhooks_without_callback_token(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
config_path = root / "config" / "local.toml"
save_config_document(
config_path,
{
"case_sink": {"path": "logs/cases.jsonl"},
"webhooks": {
"enabled": True,
"event_url": "https://example.com/events",
"case_url": "https://example.com/cases",
"callback_token": "secret",
},
},
)
payload = config_payload(ManageContext(config_path=config_path, project_root=root))
self.assertEqual(payload["case_sink"]["path"], str((root / "logs" / "cases.jsonl").resolve()))
self.assertTrue(payload["webhooks"]["enabled"])
self.assertNotIn("callback_token", payload["webhooks"])
def test_cases_endpoint_returns_latest_snapshots(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
config_path = root / "config" / "local.toml"
save_config_document(
config_path,
{
"case_sink": {"path": "logs/cases.jsonl"},
"layout": {"zone_ids": ["1"]},
},
)
cases_path = root / "logs" / "cases.jsonl"
cases_path.parent.mkdir()
cases_path.write_text(
json.dumps(
{
"case_id": "case_batch_000001",
"batch_id": "batch_000001",
"camera_id": "cam_01",
"zone_id": "1",
"zone_label": "区域 1",
"case_type": "time_alarm",
"case_status": "open",
"source_event": "time_alarm",
"created_at": "2026-06-09T09:00:00+08:00",
"updated_at": "2026-06-09T09:00:00+08:00",
"payload": {},
}
),
encoding="utf-8",
)
ctx = ManageContext(config_path=config_path, project_root=root)
server, thread = self._serve_once(ctx)
try:
status, payload = self._request(server, "GET", "/api/manage/cases?status=open")
finally:
self._stop_server(server, thread)
self.assertEqual(status, 200)
self.assertEqual(len(payload["items"]), 1)
self.assertEqual(payload["items"][0]["case_id"], "case_batch_000001")
def test_case_summary_endpoint_counts_open_and_handled(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
config_path = root / "config" / "local.toml"
save_config_document(
config_path,
{
"case_sink": {"path": "logs/cases.jsonl"},
"layout": {"zone_ids": ["1"]},
},
)
cases_path = root / "logs" / "cases.jsonl"
cases_path.parent.mkdir()
cases_path.write_text(
"\n".join(
[
json.dumps(
{
"case_id": "case_batch_000001",
"batch_id": "batch_000001",
"camera_id": "cam_01",
"zone_id": "1",
"zone_label": "区域 1",
"case_type": "time_alarm",
"case_status": "open",
"source_event": "time_alarm",
"created_at": "2026-06-09T09:00:00+08:00",
"updated_at": "2026-06-09T09:00:00+08:00",
"payload": {},
}
),
json.dumps(
{
"case_id": "case_batch_000002",
"batch_id": "batch_000002",
"camera_id": "cam_01",
"zone_id": "1",
"zone_label": "区域 1",
"case_type": "warning_escalated",
"case_status": "handled",
"source_event": "warning_escalated",
"created_at": "2026-06-09T09:01:00+08:00",
"updated_at": "2026-06-09T09:05:00+08:00",
"handled_source": "manual",
"payload": {},
}
),
]
),
encoding="utf-8",
)
ctx = ManageContext(config_path=config_path, project_root=root)
server, thread = self._serve_once(ctx)
try:
status, payload = self._request(server, "GET", "/api/manage/cases/summary")
finally:
self._stop_server(server, thread)
self.assertEqual(status, 200)
self.assertEqual(payload["open_case_count"], 1)
self.assertEqual(payload["handled_case_count"], 1)
self.assertEqual(payload["warning_escalated_case_count"], 1)
def test_manual_handle_endpoint_appends_handled_snapshot(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
config_path = root / "config" / "local.toml"
save_config_document(
config_path,
{
"case_sink": {"path": "logs/cases.jsonl"},
"layout": {"zone_ids": ["1"]},
},
)
cases_path = root / "logs" / "cases.jsonl"
cases_path.parent.mkdir()
cases_path.write_text(
json.dumps(
{
"case_id": "case_batch_000001",
"batch_id": "batch_000001",
"camera_id": "cam_01",
"zone_id": "1",
"zone_label": "区域 1",
"case_type": "time_alarm",
"case_status": "open",
"source_event": "time_alarm",
"created_at": "2026-06-09T09:00:00+08:00",
"updated_at": "2026-06-09T09:00:00+08:00",
"payload": {},
}
),
encoding="utf-8",
)
ctx = ManageContext(config_path=config_path, project_root=root)
server, thread = self._serve_once(ctx)
try:
status, payload = self._request(
server,
"POST",
"/api/manage/cases/case_batch_000001/handle",
body={"handled_by": "alice", "note": "checked"},
)
finally:
self._stop_server(server, thread)
lines = [json.loads(line) for line in cases_path.read_text(encoding="utf-8").splitlines()]
self.assertEqual(status, 200)
self.assertEqual(payload["case_status"], "handled")
self.assertEqual(lines[-1]["handled_source"], "manual")
self.assertEqual(lines[-1]["payload"]["note"], "checked")
def test_callback_endpoint_requires_token_and_handles_case(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
config_path = root / "config" / "local.toml"
save_config_document(
config_path,
{
"case_sink": {"path": "logs/cases.jsonl"},
"webhooks": {"callback_token": "secret"},
"layout": {"zone_ids": ["1"]},
},
)
cases_path = root / "logs" / "cases.jsonl"
cases_path.parent.mkdir()
cases_path.write_text(
json.dumps(
{
"case_id": "case_batch_000001",
"batch_id": "batch_000001",
"camera_id": "cam_01",
"zone_id": "1",
"zone_label": "区域 1",
"case_type": "time_alarm",
"case_status": "open",
"source_event": "time_alarm",
"created_at": "2026-06-09T09:00:00+08:00",
"updated_at": "2026-06-09T09:00:00+08:00",
"payload": {},
}
),
encoding="utf-8",
)
ctx = ManageContext(config_path=config_path, project_root=root)
server, thread = self._serve_once(ctx)
try:
unauthorized_status, _ = self._request(
server,
"POST",
"/api/manage/webhooks/case-update",
body={"case_id": "case_batch_000001", "status": "handled"},
)
status, payload = self._request(
server,
"POST",
"/api/manage/webhooks/case-update",
body={"case_id": "case_batch_000001", "status": "handled", "handled_by": "crm-bot"},
headers={"X-Webhook-Token": "secret"},
)
finally:
self._stop_server(server, thread)
lines = [json.loads(line) for line in cases_path.read_text(encoding="utf-8").splitlines()]
self.assertEqual(unauthorized_status, 403)
self.assertEqual(status, 200)
self.assertEqual(payload["handled_source"], "webhook_callback")
self.assertEqual(lines[-1]["handled_source"], "webhook_callback")
if __name__ == "__main__":
unittest.main()