ForcePilot/backend/test/unit/channels/plugins/qqbot/test_lifecycle.py
Kris 532e7c59ec test: add and fix multi-channel unit tests
1. 新增QQBot、Wecom、Dingtalk、Instagram、WechatMp等渠道的单元测试夹具与测试用例
2. 修复WechatILink的缓存键顺序与测试用例
3. 补全Feishu适配器的日志断言与测试逻辑
4. 清理WechatILink冗余的clear_context_token测试代码
2026-07-08 22:59:24 +08:00

546 lines
18 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.

"""QQBotLifecycleHandler 单元测试。
覆盖生命周期钩子的 8 个方法(``onInit`` / ``onStart`` / ``onStop`` /
``onPause`` / ``onResume`` / ``onUnload`` / ``onReconfigure`` / ``onFail``
以及构造器从 config_schema 派生不可热更新键的逻辑。验证状态变迁、
httpx 连接池管理(含 ``attach_http_client`` / ``detach_http_client`` 注入
与解除)、配置热更新校验(``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.qqbot.lifecycle import QQBotLifecycleHandler
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="intents", type="integer", required=False, hot_reloadable=False),
ConfigField(key="api_base", type="string", required=False, hot_reloadable=False),
ConfigField(key="sandbox", type="boolean", required=False, hot_reloadable=False),
ConfigField(key="http_timeout_ms", type="integer", required=False, hot_reloadable=True),
ConfigField(key="ws_heartbeat_interval_ms", type="integer", required=False, hot_reloadable=True),
)
def _make_handler(
*,
config_schema: tuple[ConfigField, ...] | None = None,
client: object | None = None,
) -> QQBotLifecycleHandler:
"""构造 handler 实例。
config_schema 默认使用测试 schema含可热更新与不可热更新字段
client 非 None 时注入共享连接池,由 onInit/onUnload 调用 attach/detach。
"""
return QQBotLifecycleHandler(
_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:
"""QQBotLifecycleHandler 构造器测试。"""
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 "intents" in handler._restart_required_keys
assert "api_base" in handler._restart_required_keys
assert "sandbox" in handler._restart_required_keys
assert "http_timeout_ms" not in handler._restart_required_keys
assert "ws_heartbeat_interval_ms" 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):
QQBotLifecycleHandler(
_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
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="http_timeout_ms", type="integer", required=False, hot_reloadable=True),
ConfigField(key="ws_heartbeat_interval_ms", type="integer", 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 注入 QQBotClient。"""
# 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``F-08
不再自行维护 ``_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 = {"app_id": "app1", "app_secret": "s", "intents": 100663296}
# 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 - http_timeout_ms 可热更新,变更不应抛异常
handler = _make_handler()
old_config = {
"app_id": "app1",
"app_secret": "s",
"http_timeout_ms": 30000,
}
new_config = {
"app_id": "app1",
"app_secret": "s",
"http_timeout_ms": 45000,
}
# 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 - app_id 不可热更新,变更抛 ConfigRestartRequiredError
handler = _make_handler()
old_config = {
"app_id": "app1",
"app_secret": "s",
"intents": 100663296,
}
new_config = {
"app_id": "app2",
"app_secret": "s",
"intents": 100663296,
}
# 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 == "app_id"
@pytest.mark.asyncio
async def test_app_secret_change_raises(self) -> None:
# Arrange - app_secret 变更也抛异常
handler = _make_handler()
old_config = {
"app_id": "app1",
"app_secret": "s1",
"intents": 100663296,
}
new_config = {
"app_id": "app1",
"app_secret": "s2",
"intents": 100663296,
}
# 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 == "app_secret"
@pytest.mark.asyncio
async def test_sandbox_change_raises(self) -> None:
"""sandbox 不可热更新schema 派生),变更抛异常。"""
# Arrange
handler = _make_handler()
old_config = {
"app_id": "app1",
"app_secret": "s",
"sandbox": False,
}
new_config = {
"app_id": "app1",
"app_secret": "s",
"sandbox": True,
}
# Act / Assert - sandbox 变更抛异常schema 派生,非硬编码回退)
with pytest.raises(ConfigRestartRequiredError) as exc_info:
await handler.onReconfigure(old_config=old_config, new_config=new_config)
assert exc_info.value.key == "sandbox"
@pytest.mark.asyncio
async def test_intents_change_raises(self) -> None:
"""intents 不可热更新,变更抛异常。"""
# Arrange
handler = _make_handler()
old_config = {
"app_id": "app1",
"app_secret": "s",
"intents": 100663296,
}
new_config = {
"app_id": "app1",
"app_secret": "s",
"intents": 100663297,
}
# 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 == "intents"
@pytest.mark.asyncio
async def test_no_change_succeeds(self) -> None:
# Arrange - 相同配置不抛异常
handler = _make_handler()
config = {"app_id": "app1", "app_secret": "s", "intents": 100663296}
# 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={"app_id": "app1"},
new_config={"app_id": "app1"},
)
# 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("QQBot 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