ForcePilot/backend/test/unit/channels/plugins/feishu/test_lifecycle.py
Kris 1022121bee test: add batch of unit tests for channels module
添加了channels限界上下文的大量单元测试文件,包括:
1. 各层级通用与专用的conftest夹具
2. 核心领域模型、事件、服务测试
3. 应用层流水线、扩展处理器测试
4. 适配器与插件层测试
5. 修复并补充了pool manager测试用例
2026-07-02 03:28:19 +08:00

425 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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