556 lines
18 KiB
Python
556 lines
18 KiB
Python
|
|
"""DingTalkLifecycleHandler 单元测试。
|
|||
|
|
|
|||
|
|
覆盖生命周期钩子的 8 个方法(``onInit`` / ``onStart`` / ``onStop`` /
|
|||
|
|
``onPause`` / ``onResume`` / ``onUnload`` / ``onReconfigure`` / ``onFail``),
|
|||
|
|
以及构造器从 config_schema 派生不可热更新键的逻辑。验证状态变迁、
|
|||
|
|
httpx 连接池管理(含 ``attach_http_client`` / ``detach_http_client`` 注入
|
|||
|
|
与解除)、配置热更新校验(``ConfigRestartRequiredError``)与 ``onFail``
|
|||
|
|
降级回退(logger 不可用时回退 ``stderr``)。
|
|||
|
|
|
|||
|
|
钉钉不可热更新键(``hot_reloadable=False``)从 config_schema 派生:
|
|||
|
|
``client_id`` / ``client_secret`` / ``robot_code`` / ``event_mode``。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from unittest.mock import AsyncMock, MagicMock
|
|||
|
|
|
|||
|
|
import httpx
|
|||
|
|
import pytest
|
|||
|
|
from yuxi.channels.contract.dtos.config import ConfigField
|
|||
|
|
from yuxi.channels.contract.errors import ConfigRestartRequiredError
|
|||
|
|
from yuxi.channels.plugins.dingtalk.lifecycle import DingTalkLifecycleHandler
|
|||
|
|
|
|||
|
|
pytestmark = pytest.mark.unit
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# 桩辅助
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _make_logger() -> AsyncMock:
|
|||
|
|
return AsyncMock()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _make_config_port() -> AsyncMock:
|
|||
|
|
return AsyncMock()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _make_cache_port() -> AsyncMock:
|
|||
|
|
return AsyncMock()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _make_config_schema() -> tuple[ConfigField, ...]:
|
|||
|
|
"""构造测试用 config_schema(含可热更新与不可热更新字段)。
|
|||
|
|
|
|||
|
|
对齐钉钉 manifest:client_id / client_secret / robot_code / event_mode
|
|||
|
|
为不可热更新;其余为可热更新。
|
|||
|
|
"""
|
|||
|
|
return (
|
|||
|
|
ConfigField(key="client_id", type="string", required=True, hot_reloadable=False),
|
|||
|
|
ConfigField(key="client_secret", type="string", required=True, hot_reloadable=False),
|
|||
|
|
ConfigField(key="robot_code", type="string", required=True, hot_reloadable=False),
|
|||
|
|
ConfigField(key="event_mode", type="enum", required=False, hot_reloadable=False),
|
|||
|
|
ConfigField(key="webhook_url", type="string", required=False, hot_reloadable=True),
|
|||
|
|
ConfigField(key="sign_secret", type="string", required=False, hot_reloadable=True),
|
|||
|
|
ConfigField(key="bot_name", type="string", required=False, hot_reloadable=True),
|
|||
|
|
ConfigField(key="api_base_url", type="string", required=False, hot_reloadable=True),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _make_handler(
|
|||
|
|
*,
|
|||
|
|
config_schema: tuple[ConfigField, ...] | None = None,
|
|||
|
|
client: object | None = None,
|
|||
|
|
) -> DingTalkLifecycleHandler:
|
|||
|
|
"""构造 handler 实例。
|
|||
|
|
|
|||
|
|
config_schema 默认使用测试 schema(含可热更新与不可热更新字段)。
|
|||
|
|
client 非 None 时注入共享连接池,由 onInit/onUnload 调用 attach/detach。
|
|||
|
|
"""
|
|||
|
|
return DingTalkLifecycleHandler(
|
|||
|
|
_make_config_port(),
|
|||
|
|
_make_logger(),
|
|||
|
|
_make_cache_port(),
|
|||
|
|
config_schema if config_schema is not None else _make_config_schema(),
|
|||
|
|
client,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# 构造器
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
class TestConstructor:
|
|||
|
|
"""DingTalkLifecycleHandler 构造器测试。"""
|
|||
|
|
|
|||
|
|
def test_with_config_schema_derives_restart_keys(self) -> None:
|
|||
|
|
# Arrange
|
|||
|
|
schema = _make_config_schema()
|
|||
|
|
|
|||
|
|
# Act
|
|||
|
|
handler = _make_handler(config_schema=schema)
|
|||
|
|
|
|||
|
|
# Assert - 不可热更新键从 schema 派生
|
|||
|
|
assert "client_id" in handler._restart_required_keys
|
|||
|
|
assert "client_secret" in handler._restart_required_keys
|
|||
|
|
assert "robot_code" in handler._restart_required_keys
|
|||
|
|
assert "event_mode" in handler._restart_required_keys
|
|||
|
|
assert "webhook_url" not in handler._restart_required_keys
|
|||
|
|
assert "bot_name" not in handler._restart_required_keys
|
|||
|
|
|
|||
|
|
def test_config_schema_required_raises_typeerror(self) -> None:
|
|||
|
|
"""config_schema 必填,未传时构造器抛 TypeError。"""
|
|||
|
|
# Arrange / Act / Assert
|
|||
|
|
with pytest.raises(TypeError):
|
|||
|
|
DingTalkLifecycleHandler(
|
|||
|
|
_make_config_port(),
|
|||
|
|
_make_logger(),
|
|||
|
|
_make_cache_port(),
|
|||
|
|
) # type: ignore[call-arg]
|
|||
|
|
|
|||
|
|
def test_initial_state_not_started(self) -> None:
|
|||
|
|
# Arrange / Act
|
|||
|
|
handler = _make_handler()
|
|||
|
|
|
|||
|
|
# Assert
|
|||
|
|
assert handler._started is False
|
|||
|
|
assert handler._http_client is None
|
|||
|
|
# onReconfigure 不再自行维护基线,由宿主通过 old_config 入参传入
|
|||
|
|
assert not hasattr(handler, "_last_config")
|
|||
|
|
|
|||
|
|
def test_restart_keys_derived_from_schema_no_fallback(self) -> None:
|
|||
|
|
"""_restart_required_keys 仅从 config_schema 派生,无硬编码回退。"""
|
|||
|
|
# Arrange - 仅含可热更新字段
|
|||
|
|
schema = (
|
|||
|
|
ConfigField(key="bot_name", type="string", required=False, hot_reloadable=True),
|
|||
|
|
ConfigField(key="api_base_url", type="string", required=False, hot_reloadable=True),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Act
|
|||
|
|
handler = _make_handler(config_schema=schema)
|
|||
|
|
|
|||
|
|
# Assert - 全部可热更新,restart_required_keys 为空
|
|||
|
|
assert handler._restart_required_keys == ()
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# onInit / onStart / onStop / onPause / onResume
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
class TestLifecycleStateTransitions:
|
|||
|
|
"""生命周期状态变迁测试。"""
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_on_init_creates_http_client(self) -> None:
|
|||
|
|
# Arrange
|
|||
|
|
handler = _make_handler()
|
|||
|
|
|
|||
|
|
# Act
|
|||
|
|
await handler.onInit()
|
|||
|
|
|
|||
|
|
# Assert
|
|||
|
|
assert handler._http_client is not None
|
|||
|
|
assert isinstance(handler._http_client, httpx.AsyncClient)
|
|||
|
|
await handler._http_client.aclose()
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_on_init_attaches_http_client_to_client(self) -> None:
|
|||
|
|
"""onInit 应将创建的连接池通过 attach_http_client 注入 DingTalkClient。"""
|
|||
|
|
# Arrange
|
|||
|
|
client = MagicMock()
|
|||
|
|
handler = _make_handler(client=client)
|
|||
|
|
|
|||
|
|
# Act
|
|||
|
|
await handler.onInit()
|
|||
|
|
|
|||
|
|
# Assert - client.attach_http_client 被调用且参数为 handler 创建的连接池
|
|||
|
|
client.attach_http_client.assert_called_once_with(handler._http_client)
|
|||
|
|
await handler._http_client.aclose()
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_on_init_without_client_does_not_attach(self) -> None:
|
|||
|
|
"""未注入 client 时 onInit 仅创建连接池,不调用 attach_http_client。"""
|
|||
|
|
# Arrange
|
|||
|
|
handler = _make_handler(client=None)
|
|||
|
|
|
|||
|
|
# Act
|
|||
|
|
await handler.onInit()
|
|||
|
|
|
|||
|
|
# Assert - 连接池已创建但不引用任何 client
|
|||
|
|
assert handler._http_client is not None
|
|||
|
|
assert handler._client is None
|
|||
|
|
await handler._http_client.aclose()
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_on_start_sets_started_flag(self) -> None:
|
|||
|
|
# Arrange
|
|||
|
|
handler = _make_handler()
|
|||
|
|
|
|||
|
|
# Act
|
|||
|
|
await handler.onStart()
|
|||
|
|
|
|||
|
|
# Assert
|
|||
|
|
assert handler._started is True
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_on_stop_clears_started_flag(self) -> None:
|
|||
|
|
# Arrange
|
|||
|
|
handler = _make_handler()
|
|||
|
|
await handler.onStart()
|
|||
|
|
|
|||
|
|
# Act
|
|||
|
|
await handler.onStop()
|
|||
|
|
|
|||
|
|
# Assert
|
|||
|
|
assert handler._started is False
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_on_pause_clears_started_flag(self) -> None:
|
|||
|
|
# Arrange
|
|||
|
|
handler = _make_handler()
|
|||
|
|
await handler.onStart()
|
|||
|
|
|
|||
|
|
# Act
|
|||
|
|
await handler.onPause()
|
|||
|
|
|
|||
|
|
# Assert
|
|||
|
|
assert handler._started is False
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_on_resume_sets_started_flag(self) -> None:
|
|||
|
|
# Arrange
|
|||
|
|
handler = _make_handler()
|
|||
|
|
await handler.onStart()
|
|||
|
|
await handler.onPause()
|
|||
|
|
|
|||
|
|
# Act
|
|||
|
|
await handler.onResume()
|
|||
|
|
|
|||
|
|
# Assert
|
|||
|
|
assert handler._started is True
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# onUnload
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
class TestOnUnload:
|
|||
|
|
"""onUnload 资源释放测试。"""
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_unload_closes_http_client(self) -> None:
|
|||
|
|
# Arrange
|
|||
|
|
handler = _make_handler()
|
|||
|
|
await handler.onInit()
|
|||
|
|
assert handler._http_client is not None
|
|||
|
|
|
|||
|
|
# Act
|
|||
|
|
await handler.onUnload()
|
|||
|
|
|
|||
|
|
# Assert
|
|||
|
|
assert handler._http_client is None
|
|||
|
|
assert handler._started is False
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_unload_without_init_does_not_raise(self) -> None:
|
|||
|
|
# Arrange - 未调用 onInit,_http_client 为 None
|
|||
|
|
handler = _make_handler()
|
|||
|
|
|
|||
|
|
# Act / Assert - 不抛异常
|
|||
|
|
await handler.onUnload()
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_unload_logs_warning_on_close_failure(self) -> None:
|
|||
|
|
# Arrange - 注入会抛异常的 mock client
|
|||
|
|
handler = _make_handler()
|
|||
|
|
mock_client = AsyncMock()
|
|||
|
|
mock_client.aclose.side_effect = RuntimeError("close failed")
|
|||
|
|
handler._http_client = mock_client
|
|||
|
|
logger = handler._logger
|
|||
|
|
|
|||
|
|
# Act
|
|||
|
|
await handler.onUnload()
|
|||
|
|
|
|||
|
|
# Assert - 关闭失败记录告警,_http_client 仍置 None
|
|||
|
|
logger.warn.assert_awaited_once()
|
|||
|
|
assert handler._http_client is None
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_unload_detaches_http_client_from_client(self) -> None:
|
|||
|
|
"""onUnload 应先调用 client.detach_http_client 解除引用,再关闭连接池。"""
|
|||
|
|
# Arrange
|
|||
|
|
client = MagicMock()
|
|||
|
|
handler = _make_handler(client=client)
|
|||
|
|
await handler.onInit()
|
|||
|
|
http_client = handler._http_client
|
|||
|
|
assert http_client is not None
|
|||
|
|
client.attach_http_client.assert_called_once_with(http_client)
|
|||
|
|
|
|||
|
|
# Act
|
|||
|
|
await handler.onUnload()
|
|||
|
|
|
|||
|
|
# Assert - detach 在 close 之前调用,避免悬挂引用
|
|||
|
|
client.detach_http_client.assert_called_once()
|
|||
|
|
assert handler._http_client is None
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_unload_without_client_does_not_detach(self) -> None:
|
|||
|
|
"""未注入 client 时 onUnload 不调用 detach_http_client。"""
|
|||
|
|
# Arrange
|
|||
|
|
handler = _make_handler(client=None)
|
|||
|
|
await handler.onInit()
|
|||
|
|
|
|||
|
|
# Act
|
|||
|
|
await handler.onUnload()
|
|||
|
|
|
|||
|
|
# Assert - 仅关闭连接池,不调用 detach(_client=None)
|
|||
|
|
assert handler._client is None
|
|||
|
|
assert handler._http_client is None
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# onReconfigure
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
class TestOnReconfigure:
|
|||
|
|
"""onReconfigure 配置热更新校验测试。
|
|||
|
|
|
|||
|
|
签名扩展为关键字参数 ``old_config`` / ``new_config``,不再自行维护
|
|||
|
|
``_last_config`` 基线,差异化校验直接使用入参 ``old_config``。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_first_call_with_none_old_config_succeeds(self) -> None:
|
|||
|
|
# Arrange - 首次调用 old_config 为 None,仅记录日志不做对比
|
|||
|
|
handler = _make_handler()
|
|||
|
|
config = {"client_id": "ding_a", "client_secret": "s", "event_mode": "stream"}
|
|||
|
|
|
|||
|
|
# Act - 不传 old_config(默认 None),不抛异常
|
|||
|
|
await handler.onReconfigure(new_config=config)
|
|||
|
|
|
|||
|
|
# Assert - 记录初始基线日志,不维护 _last_config
|
|||
|
|
handler._logger.info.assert_awaited()
|
|||
|
|
assert not hasattr(handler, "_last_config")
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_hot_reloadable_key_change_succeeds(self) -> None:
|
|||
|
|
# Arrange - bot_name 可热更新,变更不应抛异常
|
|||
|
|
handler = _make_handler()
|
|||
|
|
old_config = {
|
|||
|
|
"client_id": "ding_a",
|
|||
|
|
"client_secret": "s",
|
|||
|
|
"event_mode": "stream",
|
|||
|
|
"bot_name": "Bot1",
|
|||
|
|
}
|
|||
|
|
new_config = {
|
|||
|
|
"client_id": "ding_a",
|
|||
|
|
"client_secret": "s",
|
|||
|
|
"event_mode": "stream",
|
|||
|
|
"bot_name": "Bot2",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Act
|
|||
|
|
await handler.onReconfigure(old_config=old_config, new_config=new_config)
|
|||
|
|
|
|||
|
|
# Assert
|
|||
|
|
handler._logger.info.assert_awaited()
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_non_hot_reloadable_key_change_raises(self) -> None:
|
|||
|
|
# Arrange - client_id 不可热更新,变更抛 ConfigRestartRequiredError
|
|||
|
|
handler = _make_handler()
|
|||
|
|
old_config = {
|
|||
|
|
"client_id": "ding_a",
|
|||
|
|
"client_secret": "s",
|
|||
|
|
"event_mode": "stream",
|
|||
|
|
}
|
|||
|
|
new_config = {
|
|||
|
|
"client_id": "ding_b",
|
|||
|
|
"client_secret": "s",
|
|||
|
|
"event_mode": "stream",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Act / Assert
|
|||
|
|
with pytest.raises(ConfigRestartRequiredError) as exc_info:
|
|||
|
|
await handler.onReconfigure(old_config=old_config, new_config=new_config)
|
|||
|
|
assert exc_info.value.key == "client_id"
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_client_secret_change_raises(self) -> None:
|
|||
|
|
# Arrange - client_secret 变更也抛异常
|
|||
|
|
handler = _make_handler()
|
|||
|
|
old_config = {
|
|||
|
|
"client_id": "ding_a",
|
|||
|
|
"client_secret": "s1",
|
|||
|
|
"event_mode": "stream",
|
|||
|
|
}
|
|||
|
|
new_config = {
|
|||
|
|
"client_id": "ding_a",
|
|||
|
|
"client_secret": "s2",
|
|||
|
|
"event_mode": "stream",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Act / Assert
|
|||
|
|
with pytest.raises(ConfigRestartRequiredError) as exc_info:
|
|||
|
|
await handler.onReconfigure(old_config=old_config, new_config=new_config)
|
|||
|
|
assert exc_info.value.key == "client_secret"
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_robot_code_change_raises(self) -> None:
|
|||
|
|
# Arrange - robot_code 不可热更新(schema 派生),变更抛异常
|
|||
|
|
handler = _make_handler()
|
|||
|
|
old_config = {
|
|||
|
|
"client_id": "ding_a",
|
|||
|
|
"client_secret": "s",
|
|||
|
|
"event_mode": "stream",
|
|||
|
|
"robot_code": "r1",
|
|||
|
|
}
|
|||
|
|
new_config = {
|
|||
|
|
"client_id": "ding_a",
|
|||
|
|
"client_secret": "s",
|
|||
|
|
"event_mode": "stream",
|
|||
|
|
"robot_code": "r2",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Act / Assert
|
|||
|
|
with pytest.raises(ConfigRestartRequiredError) as exc_info:
|
|||
|
|
await handler.onReconfigure(old_config=old_config, new_config=new_config)
|
|||
|
|
assert exc_info.value.key == "robot_code"
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_event_mode_change_raises(self) -> None:
|
|||
|
|
# Arrange - event_mode 不可热更新(schema 派生),变更抛异常
|
|||
|
|
handler = _make_handler()
|
|||
|
|
old_config = {
|
|||
|
|
"client_id": "ding_a",
|
|||
|
|
"client_secret": "s",
|
|||
|
|
"event_mode": "stream",
|
|||
|
|
}
|
|||
|
|
new_config = {
|
|||
|
|
"client_id": "ding_a",
|
|||
|
|
"client_secret": "s",
|
|||
|
|
"event_mode": "http",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Act / Assert - event_mode 变更抛异常(schema 派生,非硬编码回退)
|
|||
|
|
with pytest.raises(ConfigRestartRequiredError) as exc_info:
|
|||
|
|
await handler.onReconfigure(old_config=old_config, new_config=new_config)
|
|||
|
|
assert exc_info.value.key == "event_mode"
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_no_change_succeeds(self) -> None:
|
|||
|
|
# Arrange - 相同配置不抛异常
|
|||
|
|
handler = _make_handler()
|
|||
|
|
config = {"client_id": "ding_a", "client_secret": "s", "event_mode": "stream"}
|
|||
|
|
|
|||
|
|
# Act
|
|||
|
|
await handler.onReconfigure(old_config=dict(config), new_config=dict(config))
|
|||
|
|
|
|||
|
|
# Assert
|
|||
|
|
handler._logger.info.assert_awaited()
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_does_not_maintain_last_config_attribute(self) -> None:
|
|||
|
|
# Arrange / Act - 验证调用后不再维护 _last_config 实例属性
|
|||
|
|
handler = _make_handler()
|
|||
|
|
await handler.onReconfigure(
|
|||
|
|
old_config={"client_id": "ding_a"},
|
|||
|
|
new_config={"client_id": "ding_a"},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Assert
|
|||
|
|
assert not hasattr(handler, "_last_config")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# onFail
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
class TestOnFail:
|
|||
|
|
"""onFail 失败清理测试。"""
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_fail_clears_started_flag(self) -> None:
|
|||
|
|
# Arrange
|
|||
|
|
handler = _make_handler()
|
|||
|
|
await handler.onStart()
|
|||
|
|
|
|||
|
|
# Act
|
|||
|
|
await handler.onFail("something went wrong")
|
|||
|
|
|
|||
|
|
# Assert
|
|||
|
|
assert handler._started is False
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_fail_logs_error_message(self) -> None:
|
|||
|
|
# Arrange
|
|||
|
|
handler = _make_handler()
|
|||
|
|
logger = handler._logger
|
|||
|
|
|
|||
|
|
# Act
|
|||
|
|
await handler.onFail("crash")
|
|||
|
|
|
|||
|
|
# Assert
|
|||
|
|
logger.error.assert_awaited_once_with("DingTalk plugin failed", error="crash")
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_fail_does_not_raise(self) -> None:
|
|||
|
|
# Arrange
|
|||
|
|
handler = _make_handler()
|
|||
|
|
|
|||
|
|
# Act / Assert - onFail 不抛异常,不阻断降级流程
|
|||
|
|
await handler.onFail("error")
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_fail_falls_back_to_stderr_when_logger_broken(
|
|||
|
|
self,
|
|||
|
|
monkeypatch: pytest.MonkeyPatch,
|
|||
|
|
) -> None:
|
|||
|
|
# Arrange - logger 不可用
|
|||
|
|
handler = _make_handler()
|
|||
|
|
handler._logger.error.side_effect = RuntimeError("logger broken")
|
|||
|
|
stderr_messages: list[str] = []
|
|||
|
|
mock_stderr = MagicMock()
|
|||
|
|
mock_stderr.write = lambda msg: stderr_messages.append(msg)
|
|||
|
|
monkeypatch.setattr("sys.stderr", mock_stderr)
|
|||
|
|
|
|||
|
|
# Act - 不抛异常
|
|||
|
|
await handler.onFail("fatal error")
|
|||
|
|
|
|||
|
|
# Assert - stderr 收到失败信息
|
|||
|
|
assert any("fatal error" in msg for msg in stderr_messages)
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_fail_falls_back_to_stderr_and_logs_logger_error(
|
|||
|
|
self,
|
|||
|
|
monkeypatch: pytest.MonkeyPatch,
|
|||
|
|
) -> None:
|
|||
|
|
# Arrange - logger 不可用,验证 stderr 包含 logger 错误信息
|
|||
|
|
handler = _make_handler()
|
|||
|
|
logger_exc = RuntimeError("logger unavailable")
|
|||
|
|
handler._logger.error.side_effect = logger_exc
|
|||
|
|
stderr_messages: list[str] = []
|
|||
|
|
mock_stderr = MagicMock()
|
|||
|
|
mock_stderr.write = lambda msg: stderr_messages.append(msg)
|
|||
|
|
monkeypatch.setattr("sys.stderr", mock_stderr)
|
|||
|
|
|
|||
|
|
# Act
|
|||
|
|
await handler.onFail("plugin error")
|
|||
|
|
|
|||
|
|
# Assert - stderr 同时包含 plugin 错误和 logger 错误
|
|||
|
|
combined = "".join(stderr_messages)
|
|||
|
|
assert "plugin error" in combined
|
|||
|
|
assert "logger unavailable" in combined
|