添加了channels限界上下文的大量单元测试文件,包括: 1. 各层级通用与专用的conftest夹具 2. 核心领域模型、事件、服务测试 3. 应用层流水线、扩展处理器测试 4. 适配器与插件层测试 5. 修复并补充了pool manager测试用例
425 lines
14 KiB
Python
425 lines
14 KiB
Python
"""FeishuLifecycleHandler 单元测试。
|
||
|
||
覆盖生命周期钩子的 8 个方法(``onInit`` / ``onStart`` / ``onStop`` /
|
||
``onPause`` / ``onResume`` / ``onUnload`` / ``onReconfigure`` / ``onFail``),
|
||
以及构造器从 config_schema 派生不可热更新键的逻辑。验证状态变迁、
|
||
httpx 连接池管理、配置热更新校验(``ConfigRestartRequiredError``)与
|
||
``onFail`` 降级回退(logger 不可用时回退 ``stderr``)。
|
||
"""
|
||
|
||
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.feishu.lifecycle import FeishuLifecycleHandler
|
||
|
||
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(含可热更新与不可热更新字段)。"""
|
||
return (
|
||
ConfigField(key="app_id", type="string", required=True, hot_reloadable=False),
|
||
ConfigField(key="app_secret", type="string", required=True, hot_reloadable=False),
|
||
ConfigField(key="event_mode", type="enum", required=True, hot_reloadable=False),
|
||
ConfigField(key="bot_name", type="string", required=True, hot_reloadable=True),
|
||
ConfigField(key="bot_open_id", type="string", required=True, hot_reloadable=True),
|
||
)
|
||
|
||
|
||
def _make_handler(
|
||
*,
|
||
config_schema: tuple[ConfigField, ...] | None = None,
|
||
) -> FeishuLifecycleHandler:
|
||
return FeishuLifecycleHandler(
|
||
_make_config_port(),
|
||
_make_logger(),
|
||
_make_cache_port(),
|
||
config_schema,
|
||
)
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# 构造器
|
||
# ------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestConstructor:
|
||
"""FeishuLifecycleHandler 构造器测试。"""
|
||
|
||
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 "app_id" in handler._restart_required_keys
|
||
assert "app_secret" in handler._restart_required_keys
|
||
assert "event_mode" in handler._restart_required_keys
|
||
assert "bot_name" not in handler._restart_required_keys
|
||
assert "bot_open_id" not in handler._restart_required_keys
|
||
|
||
def test_without_config_schema_uses_default_restart_keys(self) -> None:
|
||
# Arrange / Act
|
||
handler = _make_handler(config_schema=None)
|
||
|
||
# Assert - 使用默认不可热更新键
|
||
assert "app_id" in handler._restart_required_keys
|
||
assert "app_secret" in handler._restart_required_keys
|
||
assert "event_mode" in handler._restart_required_keys
|
||
assert "webhook_url" in handler._restart_required_keys
|
||
|
||
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
|
||
assert handler._last_config is None
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# 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_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
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# onReconfigure
|
||
# ------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestOnReconfigure:
|
||
"""onReconfigure 配置热更新校验测试。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_first_call_sets_baseline(self) -> None:
|
||
# Arrange
|
||
handler = _make_handler(config_schema=_make_config_schema())
|
||
config = {"app_id": "cli_a", "app_secret": "s", "event_mode": "websocket"}
|
||
|
||
# Act
|
||
await handler.onReconfigure(config)
|
||
|
||
# Assert
|
||
assert handler._last_config == config
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_hot_reloadable_key_change_succeeds(self) -> None:
|
||
# Arrange
|
||
handler = _make_handler(config_schema=_make_config_schema())
|
||
await handler.onReconfigure({
|
||
"app_id": "cli_a", "app_secret": "s", "event_mode": "websocket",
|
||
"bot_name": "Bot1",
|
||
})
|
||
|
||
# Act - bot_name 可热更新,变更不应抛异常
|
||
await handler.onReconfigure({
|
||
"app_id": "cli_a", "app_secret": "s", "event_mode": "websocket",
|
||
"bot_name": "Bot2",
|
||
})
|
||
|
||
# Assert
|
||
assert handler._last_config["bot_name"] == "Bot2"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_non_hot_reloadable_key_change_raises(self) -> None:
|
||
# Arrange
|
||
handler = _make_handler(config_schema=_make_config_schema())
|
||
await handler.onReconfigure({
|
||
"app_id": "cli_a", "app_secret": "s", "event_mode": "websocket",
|
||
})
|
||
|
||
# Act / Assert - app_id 不可热更新,变更抛 ConfigRestartRequiredError
|
||
with pytest.raises(ConfigRestartRequiredError) as exc_info:
|
||
await handler.onReconfigure({
|
||
"app_id": "cli_b", "app_secret": "s", "event_mode": "websocket",
|
||
})
|
||
assert exc_info.value.key == "app_id"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_failed_reconfigure_keeps_old_baseline(self) -> None:
|
||
# Arrange
|
||
handler = _make_handler(config_schema=_make_config_schema())
|
||
original_config = {
|
||
"app_id": "cli_a", "app_secret": "s", "event_mode": "websocket",
|
||
}
|
||
await handler.onReconfigure(dict(original_config))
|
||
|
||
# Act - 尝试变更不可热更新键,抛异常
|
||
try:
|
||
await handler.onReconfigure({
|
||
"app_id": "cli_b", "app_secret": "s", "event_mode": "websocket",
|
||
})
|
||
except ConfigRestartRequiredError:
|
||
pass
|
||
|
||
# Assert - 基线保持不变
|
||
assert handler._last_config["app_id"] == "cli_a"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_multiple_non_hot_reloadable_keys_checked(self) -> None:
|
||
# Arrange
|
||
handler = _make_handler(config_schema=_make_config_schema())
|
||
await handler.onReconfigure({
|
||
"app_id": "cli_a", "app_secret": "s1", "event_mode": "websocket",
|
||
})
|
||
|
||
# Act / Assert - app_secret 变更也抛异常
|
||
with pytest.raises(ConfigRestartRequiredError) as exc_info:
|
||
await handler.onReconfigure({
|
||
"app_id": "cli_a", "app_secret": "s2", "event_mode": "websocket",
|
||
})
|
||
assert exc_info.value.key == "app_secret"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_no_change_succeeds_and_updates_baseline(self) -> None:
|
||
# Arrange
|
||
handler = _make_handler(config_schema=_make_config_schema())
|
||
config = {"app_id": "cli_a", "app_secret": "s", "event_mode": "websocket"}
|
||
await handler.onReconfigure(dict(config))
|
||
|
||
# Act - 相同配置不抛异常
|
||
await handler.onReconfigure(dict(config))
|
||
|
||
# Assert
|
||
assert handler._last_config == config
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_default_restart_keys_used_without_schema(self) -> None:
|
||
# Arrange - 无 config_schema,使用默认键
|
||
handler = _make_handler(config_schema=None)
|
||
await handler.onReconfigure({
|
||
"app_id": "cli_a", "app_secret": "s",
|
||
"event_mode": "websocket", "webhook_url": "",
|
||
})
|
||
|
||
# Act / Assert - webhook_url 变更抛异常(默认不可热更新键)
|
||
with pytest.raises(ConfigRestartRequiredError) as exc_info:
|
||
await handler.onReconfigure({
|
||
"app_id": "cli_a", "app_secret": "s",
|
||
"event_mode": "websocket", "webhook_url": "https://hook.x.com",
|
||
})
|
||
assert exc_info.value.key == "webhook_url"
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# 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("Feishu 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
|