563 lines
19 KiB
Python
563 lines
19 KiB
Python
"""任务通知系统测试。"""
|
||
|
||
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,
|
||
FeishuNotifier,
|
||
NotificationLevel,
|
||
Notifier,
|
||
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)
|
||
|
||
|
||
# ---------------------------------------------------------------------- #
|
||
# 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"]
|
||
|
||
|
||
# ---------------------------------------------------------------------- #
|
||
# 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, "ALL_LEVELS")
|
||
|
||
|
||
def test_all_levels_exported() -> None:
|
||
"""ALL_LEVELS 从顶层导出且与模块内一致。"""
|
||
assert px.ALL_LEVELS is ALL_LEVELS
|