feat: add webhook case management
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user