"""任务通知系统测试。""" from __future__ import annotations import json from urllib.error import URLError import pytest import pyflowx as px from pyflowx.notification import ( ALL_LEVELS, CallbackNotifier, DingTalkNotifier, DiscordNotifier, FeishuNotifier, NotificationLevel, Notifier, SlackNotifier, TelegramNotifier, WebhookNotifier, WeChatNotifier, _format_event_message, _status_to_level, ) from pyflowx.task import TaskEvent, TaskStatus # ---------------------------------------------------------------------- # # 测试夹具 # ---------------------------------------------------------------------- # def _event(status: TaskStatus, **kwargs: object) -> TaskEvent: """构造测试事件。""" return TaskEvent(task="test_task", status=status, **kwargs) # type: ignore[arg-type] # ---------------------------------------------------------------------- # # NotificationLevel 与映射 # ---------------------------------------------------------------------- # def test_notification_level_values() -> None: """NotificationLevel 四个值正确。""" assert NotificationLevel.RUNNING.value == "running" assert NotificationLevel.SUCCESS.value == "success" assert NotificationLevel.FAILED.value == "failed" assert NotificationLevel.SKIPPED.value == "skipped" def test_all_levels_contains_all() -> None: """ALL_LEVELS 包含全部四个级别。""" assert ( frozenset( { NotificationLevel.RUNNING, NotificationLevel.SUCCESS, NotificationLevel.FAILED, NotificationLevel.SKIPPED, } ) == ALL_LEVELS ) def test_status_to_level_mapping() -> None: """TaskStatus 正确映射到 NotificationLevel。""" assert _status_to_level(TaskStatus.RUNNING) == NotificationLevel.RUNNING assert _status_to_level(TaskStatus.SUCCESS) == NotificationLevel.SUCCESS assert _status_to_level(TaskStatus.FAILED) == NotificationLevel.FAILED assert _status_to_level(TaskStatus.SKIPPED) == NotificationLevel.SKIPPED def test_status_to_level_pending_returns_none() -> None: """PENDING 状态无对应级别,返回 None。""" assert _status_to_level(TaskStatus.PENDING) is None # ---------------------------------------------------------------------- # # _format_event_message # ---------------------------------------------------------------------- # def test_format_event_message_success() -> None: """成功事件格式化包含任务名与状态。""" msg = _format_event_message(_event(TaskStatus.SUCCESS, duration=0.5)) assert "test_task" in msg assert "success" in msg assert "0.500s" in msg def test_format_event_message_failed_with_error() -> None: """失败事件格式化包含错误信息。""" msg = _format_event_message(_event(TaskStatus.FAILED, error="ValueError", duration=0.1)) assert "test_task" in msg assert "failed" in msg assert "ValueError" in msg def test_format_event_message_skipped_with_reason() -> None: """跳过事件格式化包含原因。""" msg = _format_event_message(_event(TaskStatus.SKIPPED, reason="条件不满足")) assert "test_task" in msg assert "skipped" in msg assert "条件不满足" in msg def test_format_event_message_with_attempts() -> None: """多次尝试事件格式化包含尝试次数。""" msg = _format_event_message(_event(TaskStatus.FAILED, attempts=3, error="err")) assert "尝试次数: 3" in msg def test_format_event_message_running_no_duration() -> None: """RUNNING 事件无 duration 时不显示耗时。""" msg = _format_event_message(_event(TaskStatus.RUNNING)) assert "test_task" in msg assert "running" in msg assert "s)" not in msg # ---------------------------------------------------------------------- # # Notifier Protocol # ---------------------------------------------------------------------- # def _noop(event: TaskEvent) -> None: """空回调,用于协议检查。""" return None def test_callback_notifier_satisfies_protocol() -> None: """CallbackNotifier 满足 Notifier 协议。""" notifier = CallbackNotifier(_noop) assert isinstance(notifier, Notifier) def test_webhook_notifier_satisfies_protocol() -> None: """WebhookNotifier 满足 Notifier 协议。""" notifier = WebhookNotifier(url="http://example.com/hook") assert isinstance(notifier, Notifier) def test_feishu_notifier_satisfies_protocol() -> None: """FeishuNotifier 满足 Notifier 协议。""" assert isinstance(FeishuNotifier(url="http://example.com/hook"), Notifier) def test_dingtalk_notifier_satisfies_protocol() -> None: """DingTalkNotifier 满足 Notifier 协议。""" assert isinstance(DingTalkNotifier(url="http://example.com/hook"), Notifier) def test_wechat_notifier_satisfies_protocol() -> None: """WeChatNotifier 满足 Notifier 协议。""" assert isinstance(WeChatNotifier(url="http://example.com/hook"), Notifier) def test_slack_notifier_satisfies_protocol() -> None: """SlackNotifier 满足 Notifier 协议。""" assert isinstance(SlackNotifier(url="http://example.com/hook"), Notifier) def test_discord_notifier_satisfies_protocol() -> None: """DiscordNotifier 满足 Notifier 协议。""" assert isinstance(DiscordNotifier(url="http://example.com/hook"), Notifier) def test_telegram_notifier_satisfies_protocol() -> None: """TelegramNotifier 满足 Notifier 协议。""" assert isinstance( TelegramNotifier(url="http://example.com/hook", chat_id="123456"), Notifier, ) # ---------------------------------------------------------------------- # # CallbackNotifier # ---------------------------------------------------------------------- # def test_callback_notifier_invokes_callback() -> None: """CallbackNotifier 在事件触发时调用回调。""" received: list[TaskEvent] = [] notifier = CallbackNotifier(received.append) event = _event(TaskStatus.SUCCESS, duration=0.1) notifier.notify(event) assert received == [event] def test_callback_notifier_filters_by_levels() -> None: """levels 参数限制触发的事件级别。""" received: list[TaskEvent] = [] notifier = CallbackNotifier( received.append, levels=(NotificationLevel.FAILED, NotificationLevel.SKIPPED), ) success_event = _event(TaskStatus.SUCCESS, duration=0.1) failed_event = _event(TaskStatus.FAILED, error="err") notifier.notify(success_event) notifier.notify(failed_event) assert received == [failed_event] def test_callback_notifier_default_all_levels() -> None: """默认 levels 包含全部事件级别。""" received: list[TaskEvent] = [] notifier = CallbackNotifier(received.append) for status in (TaskStatus.RUNNING, TaskStatus.SUCCESS, TaskStatus.FAILED, TaskStatus.SKIPPED): notifier.notify(_event(status)) assert len(received) == 4 def test_callback_notifier_pending_not_notified() -> None: """PENDING 事件不触发通知(无对应级别)。""" received: list[TaskEvent] = [] notifier = CallbackNotifier(received.append) notifier.notify(_event(TaskStatus.PENDING)) assert received == [] def test_callback_notifier_close_is_noop() -> None: """close() 无副作用,可安全调用。""" notifier = CallbackNotifier(_noop) notifier.close() # 不应抛异常 # ---------------------------------------------------------------------- # # WebhookNotifier 基类 # ---------------------------------------------------------------------- # class _StubResponse: """模拟 urllib.response 的上下文管理器响应。""" def __init__(self, payload: bytes = b"{}") -> None: self._payload = payload def __enter__(self) -> _StubResponse: return self def __exit__(self, *exc: object) -> None: return None def read(self) -> bytes: return self._payload def _patch_urlopen( monkeypatch: pytest.MonkeyPatch, capture: dict[str, object], response: _StubResponse | None = None, raise_exc: BaseException | None = None, ) -> None: """patch urllib.request.urlopen,捕获请求参数。""" def _fake_urlopen(req: object, timeout: float | None = None) -> _StubResponse: capture["req"] = req capture["timeout"] = timeout if raise_exc is not None: raise raise_exc return response or _StubResponse() monkeypatch.setattr("pyflowx.notification.urllib.request.urlopen", _fake_urlopen) def _get_req_fields(req: object) -> dict[str, object]: """从 Request 对象提取 url/data/method/headers。""" return { "url": req.full_url, # type: ignore[attr-defined] "data": req.data, # type: ignore[attr-defined] "method": req.method, # type: ignore[attr-defined] "content_type": req.headers.get("Content-type"), # type: ignore[attr-defined] } def test_webhook_notifier_send_success(monkeypatch: pytest.MonkeyPatch) -> None: """webhook 成功发送 POST 请求。""" capture: dict[str, object] = {} _patch_urlopen(monkeypatch, capture) notifier = WebhookNotifier(url="http://example.com/hook") notifier.notify(_event(TaskStatus.SUCCESS, duration=0.1)) req = capture["req"] fields = _get_req_fields(req) assert fields["url"] == "http://example.com/hook" assert fields["method"] == "POST" assert fields["content_type"] == "application/json" payload = json.loads(fields["data"]) # type: ignore[arg-type] assert "text" in payload def test_webhook_notifier_filters_by_levels(monkeypatch: pytest.MonkeyPatch) -> None: """levels 过滤:SUCCESS 不在 levels 中时不发送。""" capture: dict[str, object] = {} _patch_urlopen(monkeypatch, capture) notifier = WebhookNotifier( url="http://example.com/hook", levels=(NotificationLevel.FAILED,), ) notifier.notify(_event(TaskStatus.SUCCESS, duration=0.1)) assert "req" not in capture # 未发送 notifier.notify(_event(TaskStatus.FAILED, error="err")) assert "req" in capture # 发送 def test_webhook_notifier_timeout_passed(monkeypatch: pytest.MonkeyPatch) -> None: """timeout 参数传递给 urlopen。""" capture: dict[str, object] = {} _patch_urlopen(monkeypatch, capture) notifier = WebhookNotifier(url="http://example.com/hook", timeout=5.0) notifier.notify(_event(TaskStatus.SUCCESS, duration=0.1)) assert capture["timeout"] == 5.0 def test_webhook_notifier_failure_logged_not_raised(monkeypatch: pytest.MonkeyPatch) -> None: """URLError 发生时仅记录日志,不抛异常。""" capture: dict[str, object] = {} _patch_urlopen(monkeypatch, capture, raise_exc=URLError("connection refused")) notifier = WebhookNotifier(url="http://example.com/hook") # 不应抛异常 notifier.notify(_event(TaskStatus.FAILED, error="err")) def test_webhook_notifier_oserror_handled(monkeypatch: pytest.MonkeyPatch) -> None: """OSError 同样被捕获,不影响任务执行。""" capture: dict[str, object] = {} _patch_urlopen(monkeypatch, capture, raise_exc=OSError("network error")) notifier = WebhookNotifier(url="http://example.com/hook") notifier.notify(_event(TaskStatus.SUCCESS, duration=0.1)) def test_webhook_notifier_close_is_noop() -> None: """close() 无副作用。""" notifier = WebhookNotifier(url="http://example.com/hook") notifier.close() def test_webhook_notifier_payload_contains_message(monkeypatch: pytest.MonkeyPatch) -> None: """基类 _build_payload 返回包含 text 的字典。""" capture: dict[str, object] = {} _patch_urlopen(monkeypatch, capture) notifier = WebhookNotifier(url="http://example.com/hook") notifier.notify(_event(TaskStatus.FAILED, error="boom", duration=0.2)) fields = _get_req_fields(capture["req"]) payload = json.loads(fields["data"]) # type: ignore[arg-type] assert "text" in payload assert "test_task" in payload["text"] assert "boom" in payload["text"] # ---------------------------------------------------------------------- # # FeishuNotifier # ---------------------------------------------------------------------- # def test_feishu_notifier_payload_format(monkeypatch: pytest.MonkeyPatch) -> None: """飞书消息格式:msg_type/content.text。""" capture: dict[str, object] = {} _patch_urlopen(monkeypatch, capture) notifier = FeishuNotifier(url="http://example.com/hook") notifier.notify(_event(TaskStatus.SUCCESS, duration=0.1)) fields = _get_req_fields(capture["req"]) payload = json.loads(fields["data"]) # type: ignore[arg-type] assert payload["msg_type"] == "text" assert "text" in payload["content"] assert "test_task" in payload["content"]["text"] # ---------------------------------------------------------------------- # # DingTalkNotifier # ---------------------------------------------------------------------- # def test_dingtalk_notifier_payload_format(monkeypatch: pytest.MonkeyPatch) -> None: """钉钉消息格式:msgtype/text.content。""" capture: dict[str, object] = {} _patch_urlopen(monkeypatch, capture) notifier = DingTalkNotifier(url="http://example.com/hook") notifier.notify(_event(TaskStatus.FAILED, error="err")) fields = _get_req_fields(capture["req"]) payload = json.loads(fields["data"]) # type: ignore[arg-type] assert payload["msgtype"] == "text" assert "content" in payload["text"] assert "test_task" in payload["text"]["content"] # ---------------------------------------------------------------------- # # WeChatNotifier # ---------------------------------------------------------------------- # def test_wechat_notifier_payload_format(monkeypatch: pytest.MonkeyPatch) -> None: """企业微信消息格式:msgtype/text.content。""" capture: dict[str, object] = {} _patch_urlopen(monkeypatch, capture) notifier = WeChatNotifier(url="http://example.com/hook") notifier.notify(_event(TaskStatus.SKIPPED, reason="跳过原因")) fields = _get_req_fields(capture["req"]) payload = json.loads(fields["data"]) # type: ignore[arg-type] assert payload["msgtype"] == "text" assert "content" in payload["text"] assert "test_task" in payload["text"]["content"] assert "跳过原因" in payload["text"]["content"] # ---------------------------------------------------------------------- # # SlackNotifier # ---------------------------------------------------------------------- # def test_slack_notifier_payload_format(monkeypatch: pytest.MonkeyPatch) -> None: """Slack 消息格式:text 字段。""" capture: dict[str, object] = {} _patch_urlopen(monkeypatch, capture) notifier = SlackNotifier(url="http://example.com/hook") notifier.notify(_event(TaskStatus.FAILED, error="boom", duration=0.3)) fields = _get_req_fields(capture["req"]) payload = json.loads(fields["data"]) # type: ignore[arg-type] assert "text" in payload assert "test_task" in payload["text"] assert "boom" in payload["text"] def test_slack_notifier_levels_filter(monkeypatch: pytest.MonkeyPatch) -> None: """Slack levels 过滤:SUCCESS 不在 levels 中时不发送。""" capture: dict[str, object] = {} _patch_urlopen(monkeypatch, capture) notifier = SlackNotifier( url="http://example.com/hook", levels=(NotificationLevel.FAILED,), ) notifier.notify(_event(TaskStatus.SUCCESS, duration=0.1)) assert "req" not in capture notifier.notify(_event(TaskStatus.FAILED, error="err")) assert "req" in capture # ---------------------------------------------------------------------- # # DiscordNotifier # ---------------------------------------------------------------------- # def test_discord_notifier_payload_format(monkeypatch: pytest.MonkeyPatch) -> None: """Discord 消息格式:content 字段。""" capture: dict[str, object] = {} _patch_urlopen(monkeypatch, capture) notifier = DiscordNotifier(url="http://example.com/hook") notifier.notify(_event(TaskStatus.SUCCESS, duration=0.5)) fields = _get_req_fields(capture["req"]) payload = json.loads(fields["data"]) # type: ignore[arg-type] assert "content" in payload assert "test_task" in payload["content"] assert "success" in payload["content"] def test_discord_notifier_timeout_passed(monkeypatch: pytest.MonkeyPatch) -> None: """Discord timeout 参数透传到 urlopen。""" capture: dict[str, object] = {} _patch_urlopen(monkeypatch, capture) notifier = DiscordNotifier(url="http://example.com/hook", timeout=8.0) notifier.notify(_event(TaskStatus.SUCCESS, duration=0.1)) assert capture["timeout"] == 8.0 # ---------------------------------------------------------------------- # # TelegramNotifier # ---------------------------------------------------------------------- # def test_telegram_notifier_payload_format(monkeypatch: pytest.MonkeyPatch) -> None: """Telegram 消息格式:chat_id + text 字段。""" capture: dict[str, object] = {} _patch_urlopen(monkeypatch, capture) notifier = TelegramNotifier(url="http://example.com/hook", chat_id="@mychannel") notifier.notify(_event(TaskStatus.FAILED, error="网络异常")) fields = _get_req_fields(capture["req"]) payload = json.loads(fields["data"]) # type: ignore[arg-type] assert payload["chat_id"] == "@mychannel" assert "text" in payload assert "test_task" in payload["text"] assert "网络异常" in payload["text"] def test_telegram_notifier_chat_id_numeric(monkeypatch: pytest.MonkeyPatch) -> None: """Telegram 数字 chat_id 正确传递。""" capture: dict[str, object] = {} _patch_urlopen(monkeypatch, capture) notifier = TelegramNotifier(url="http://example.com/hook", chat_id="987654321") notifier.notify(_event(TaskStatus.SUCCESS, duration=0.2)) fields = _get_req_fields(capture["req"]) payload = json.loads(fields["data"]) # type: ignore[arg-type] assert payload["chat_id"] == "987654321" def test_telegram_notifier_levels_filter(monkeypatch: pytest.MonkeyPatch) -> None: """Telegram levels 过滤:SKIPPED 不在 levels 中时不发送。""" capture: dict[str, object] = {} _patch_urlopen(monkeypatch, capture) notifier = TelegramNotifier( url="http://example.com/hook", chat_id="123", levels=(NotificationLevel.FAILED,), ) notifier.notify(_event(TaskStatus.SKIPPED, reason="跳过")) assert "req" not in capture notifier.notify(_event(TaskStatus.FAILED, error="err")) assert "req" in capture def test_telegram_notifier_close_is_noop() -> None: """TelegramNotifier.close() 无副作用。""" notifier = TelegramNotifier(url="http://example.com/hook", chat_id="123") notifier.close() # ---------------------------------------------------------------------- # # run() 集成:notifiers 参数 # ---------------------------------------------------------------------- # def _build_simple_graph() -> px.Graph: """构造简单两任务图。""" @px.task def a() -> int: return 1 @px.task(depends_on=("a",)) def b(a: int) -> int: return a + 1 return px.Graph.from_specs([a, b]) def test_run_with_single_notifier() -> None: """run() 接受单个 notifier。""" received: list[TaskEvent] = [] notifier = CallbackNotifier(received.append) report = px.run(_build_simple_graph(), notifiers=notifier) assert report.success # 至少收到 a/b 的 RUNNING + SUCCESS 事件 statuses = [e.status for e in received] assert TaskStatus.RUNNING in statuses assert TaskStatus.SUCCESS in statuses task_names = {e.task for e in received} assert "a" in task_names assert "b" in task_names def test_run_with_list_of_notifiers() -> None: """run() 接受 notifier 列表。""" received1: list[TaskEvent] = [] received2: list[TaskEvent] = [] notifiers: list[Notifier] = [ CallbackNotifier(received1.append), CallbackNotifier(received2.append), ] report = px.run(_build_simple_graph(), notifiers=notifiers) assert report.success assert len(received1) > 0 assert len(received2) > 0 assert len(received1) == len(received2) def test_run_with_notifier_and_progress_coexist() -> None: """notifier 与 progress 监控器共存。""" received: list[TaskEvent] = [] notifier = CallbackNotifier(received.append) class _NullMonitor: def __init__(self) -> None: self.started = False self.finished = False def start(self, total: int) -> None: self.started = True def on_event(self, event: TaskEvent) -> None: return None def finish(self) -> None: self.finished = True monitor = _NullMonitor() report = px.run(_build_simple_graph(), progress=monitor, notifiers=notifier) assert report.success assert monitor.started assert monitor.finished assert len(received) > 0 def test_run_notifiers_close_called_on_success() -> None: """成功结束时调用 notifier.close()。""" class _TrackingNotifier: def __init__(self) -> None: self.closed = False self.events: list[TaskEvent] = [] def notify(self, event: TaskEvent) -> None: self.events.append(event) def close(self) -> None: self.closed = True notifier = _TrackingNotifier() report = px.run(_build_simple_graph(), notifiers=notifier) assert report.success assert notifier.closed assert len(notifier.events) > 0 def test_run_notifiers_close_called_on_failure() -> None: """任务失败时也调用 notifier.close()。""" class _TrackingNotifier: def __init__(self) -> None: self.closed = False def notify(self, event: TaskEvent) -> None: return None def close(self) -> None: self.closed = True notifier = _TrackingNotifier() @px.task def fail_task() -> None: raise RuntimeError("boom") graph = px.Graph.from_specs([fail_task]) with pytest.raises(px.TaskFailedError): px.run(graph, notifiers=notifier) assert notifier.closed def test_run_notifiers_default_none() -> None: """notifiers 默认 None,不影响执行。""" report = px.run(_build_simple_graph()) assert report.success def test_run_notifier_levels_filter() -> None: """仅 FAILED 的 notifier 只收到失败事件。""" received: list[TaskEvent] = [] notifier = CallbackNotifier( received.append, levels=(NotificationLevel.FAILED,), ) @px.task def ok() -> int: return 1 @px.task def bad() -> None: raise RuntimeError("fail") graph = px.Graph.from_specs([ok, bad]) with pytest.raises(px.TaskFailedError): px.run(graph, notifiers=notifier) # 只收到 bad 的 FAILED 事件 assert len(received) == 1 assert received[0].task == "bad" assert received[0].status == TaskStatus.FAILED def test_run_webhook_notifier_integration(monkeypatch: pytest.MonkeyPatch) -> None: """run() 集成 WebhookNotifier,webhook 被调用。""" capture: dict[str, object] = {} _patch_urlopen(monkeypatch, capture) notifier = WebhookNotifier(url="http://example.com/hook") report = px.run(_build_simple_graph(), notifiers=notifier) assert report.success assert "req" in capture # 至少一次 webhook 调用 # ---------------------------------------------------------------------- # # 公共 API 导出 # ---------------------------------------------------------------------- # def test_notification_classes_exported() -> None: """通知类从 pyflowx 顶层导出。""" assert hasattr(px, "Notifier") assert hasattr(px, "NotificationLevel") assert hasattr(px, "CallbackNotifier") assert hasattr(px, "WebhookNotifier") assert hasattr(px, "FeishuNotifier") assert hasattr(px, "DingTalkNotifier") assert hasattr(px, "WeChatNotifier") assert hasattr(px, "SlackNotifier") assert hasattr(px, "DiscordNotifier") assert hasattr(px, "TelegramNotifier") assert hasattr(px, "ALL_LEVELS") def test_all_levels_exported() -> None: """ALL_LEVELS 从顶层导出且与模块内一致。""" assert px.ALL_LEVELS is ALL_LEVELS