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