"""F17 SSE + Hook 单元测试 按 test-plan-v2.6.md §F17: - T1: SSE 事件推送(P0) - T2: Hook 注册/触发(P0) - T3: 回调 Hook(P0) - T4: 错误处理(P1) """ import asyncio import json import pytest from unittest.mock import AsyncMock, MagicMock from src.daemon.sse import ( Hook, HookManager, HookType, SSEBroker, SSEEvent, SSEEventType, ) # --------------------------------------------------------------------------- # SSE # --------------------------------------------------------------------------- class TestSSEEvent: def test_to_sse_format(self): event = SSEEvent("task_created", {"task_id": "t1"}) sse = event.to_sse() assert sse.startswith("id: ") assert "event: task_created" in sse assert '"task_id": "t1"' in sse assert sse.endswith("\n\n") def test_custom_event_id(self): event = SSEEvent("test", {}, event_id="my-id") assert event.id == "my-id" assert "id: my-id" in event.to_sse() class TestSSEBroker: def test_subscribe_returns_queue(self): broker = SSEBroker() async def _test(): cid, queue = broker.subscribe() assert cid assert isinstance(queue, asyncio.Queue) asyncio.run(_test()) def test_publish_to_subscriber(self): broker = SSEBroker() async def _test(): cid, queue = broker.subscribe() delivered = await broker.publish("task_created", {"id": "t1"}) assert delivered == 1 event = queue.get_nowait() assert event.event_type == "task_created" assert event.data["id"] == "t1" asyncio.run(_test()) def test_unsubscribe(self): broker = SSEBroker() async def _test(): cid, _ = broker.subscribe() assert broker.subscriber_count == 1 broker.unsubscribe(cid) assert broker.subscriber_count == 0 asyncio.run(_test()) def test_publish_no_subscribers(self): broker = SSEBroker() delivered = asyncio.run(broker.publish("test", {})) assert delivered == 0 def test_history_kept(self): broker = SSEBroker() asyncio.run(broker.publish("e1", {"a": 1})) asyncio.run(broker.publish("e2", {"b": 2})) assert len(broker.history) == 2 assert broker.history[0].event_type == "e1" def test_history_replays_to_new_subscriber(self): broker = SSEBroker() async def _test(): await broker.publish("e1", {"x": 1}) cid, queue = broker.subscribe() event = queue.get_nowait() assert event.event_type == "e1" asyncio.run(_test()) def test_history_max(self): broker = SSEBroker() broker._max_history = 3 for i in range(5): asyncio.run(broker.publish(f"e{i}", {})) assert len(broker.history) == 3 def test_publish_sync(self): broker = SSEBroker() async def _test(): cid, queue = broker.subscribe() delivered = broker.publish_sync("tick", {"n": 1}) assert delivered == 1 event = queue.get_nowait() assert event.data["n"] == 1 asyncio.run(_test()) def test_multiple_subscribers(self): broker = SSEBroker() async def _test(): c1, q1 = broker.subscribe() c2, q2 = broker.subscribe() await broker.publish("test", {"v": 42}) assert q1.get_nowait().data["v"] == 42 assert q2.get_nowait().data["v"] == 42 asyncio.run(_test()) # --------------------------------------------------------------------------- # Hook # --------------------------------------------------------------------------- class TestHookManager: def test_register_and_get(self): hm = HookManager() hook = Hook("h1", "task_created", HookType.WEBHOOK.value, {"url": "http://example.com"}) hm.register(hook) assert hm.get("h1") is not None assert hm.hook_count == 1 def test_unregister(self): hm = HookManager() hm.register(Hook("h1", "*", HookType.CALLBACK.value, {})) assert hm.unregister("h1") is True assert hm.hook_count == 0 def test_list_hooks_by_event(self): hm = HookManager() hm.register(Hook("h1", "task_created", HookType.WEBHOOK.value, {})) hm.register(Hook("h2", "task_updated", HookType.WEBHOOK.value, {})) created = hm.list_hooks(event_type="task_created") assert len(created) == 1 assert created[0].hook_id == "h1" def test_fire_matching_hook(self): results = [] async def callback(data): results.append(data) return "ok" hm = HookManager() hm.register(Hook("h1", "task_created", HookType.CALLBACK.value, {"callback": callback})) fire_results = asyncio.run(hm.fire("task_created", {"task_id": "t1"})) assert len(fire_results) == 1 assert fire_results[0]["status"] == "success" assert len(results) == 1 def test_fire_wildcard_hook(self): results = [] async def callback(data): results.append(data) hm = HookManager() hm.register(Hook("h1", "*", HookType.CALLBACK.value, {"callback": callback})) asyncio.run(hm.fire("any_event", {"x": 1})) assert len(results) == 1 def test_fire_no_match(self): hm = HookManager() hm.register(Hook("h1", "task_created", HookType.CALLBACK.value, {"callback": lambda d: None})) results = asyncio.run(hm.fire("task_updated", {})) assert len(results) == 0 def test_fire_disabled_hook(self): hm = HookManager() hm.register(Hook("h1", "*", HookType.CALLBACK.value, {"callback": lambda d: d}, enabled=False)) results = asyncio.run(hm.fire("test", {})) assert len(results) == 0 def test_sync_callback(self): results = [] def sync_callback(data): results.append(data) return "sync_ok" hm = HookManager() hm.register(Hook("h1", "test", HookType.CALLBACK.value, {"callback": sync_callback})) fire_results = asyncio.run(hm.fire("test", {"v": 1})) assert fire_results[0]["status"] == "success" assert results[0]["v"] == 1 def test_hook_fire_count(self): async def cb(data): pass hm = HookManager() hm.register(Hook("h1", "test", HookType.CALLBACK.value, {"callback": cb})) asyncio.run(hm.fire("test", {})) asyncio.run(hm.fire("test", {})) assert hm.get("h1").fire_count == 2 assert hm.get("h1").last_fired is not None # --------------------------------------------------------------------------- # T4: 错误处理 # --------------------------------------------------------------------------- class TestHookErrors: def test_webhook_error_handled(self): hm = HookManager() hm.register(Hook("h1", "test", HookType.WEBHOOK.value, {"url": "http://nonexistent.invalid/hook"})) results = asyncio.run(hm.fire("test", {})) assert len(results) == 1 assert results[0]["status"] == "error" def test_script_error_handled(self): hm = HookManager() hm.register(Hook("h1", "test", HookType.SCRIPT.value, {"script": "/nonexistent/script.sh"})) results = asyncio.run(hm.fire("test", {})) assert len(results) == 1 assert results[0]["status"] == "error" def test_callback_error_handled(self): def bad_callback(data): raise RuntimeError("callback error") hm = HookManager() hm.register(Hook("h1", "test", HookType.CALLBACK.value, {"callback": bad_callback})) results = asyncio.run(hm.fire("test", {})) assert results[0]["status"] == "error" assert "callback error" in results[0]["error"] def test_no_callable_callback(self): hm = HookManager() hm.register(Hook("h1", "test", HookType.CALLBACK.value, {"callback": None})) results = asyncio.run(hm.fire("test", {})) assert results[0]["status"] == "error"