ForcePilot/backend/test/unit/yuxi/channel/test_bootstrap.py
Kris bab30f2715
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Ruff Format Check / Ruff Format & Lint (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
feat:0715
2026-07-15 12:30:58 +08:00

168 lines
6.2 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.

from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from yuxi.channel import ChannelGatewayBootstrap
@pytest.fixture
def app():
return FastAPI()
@pytest.fixture
def bootstrap():
return ChannelGatewayBootstrap()
@pytest.fixture
def start_mocks(monkeypatch):
"""为 start() 测试准备统一 mock。"""
mocks = {
"load_plugins": MagicMock(),
"registry": MagicMock(),
"config_manager": MagicMock(),
"lifecycle": AsyncMock(),
"dispatcher": AsyncMock(),
"router": MagicMock(),
"session_manager": MagicMock(),
"redis": MagicMock(),
"security_policy": MagicMock(),
"gateway": AsyncMock(),
"dummy_task": AsyncMock(),
}
monkeypatch.setattr("yuxi.channel.bootstrap.load_plugins", mocks["load_plugins"])
monkeypatch.setattr("yuxi.channel.bootstrap.get_registry", lambda: mocks["registry"])
monkeypatch.setattr("yuxi.channel.bootstrap.ChannelConfigManager", lambda: mocks["config_manager"])
monkeypatch.setattr(
"yuxi.channel.bootstrap.ChannelLifecycleManager",
lambda r, c: mocks["lifecycle"],
)
monkeypatch.setattr("yuxi.channel.bootstrap.OutboundDispatcher", lambda **kwargs: mocks["dispatcher"])
monkeypatch.setattr("yuxi.channel.bootstrap.BindingRouter", lambda: mocks["router"])
monkeypatch.setattr("yuxi.channel.bootstrap.SessionManager", lambda r: mocks["session_manager"])
monkeypatch.setattr("yuxi.channel.bootstrap.get_redis_client", AsyncMock(return_value=mocks["redis"]))
monkeypatch.setattr("yuxi.channel.bootstrap.SecurityPolicy", lambda *args, **kwargs: mocks["security_policy"])
monkeypatch.setattr("yuxi.channel.bootstrap.ChannelGateway", lambda **kwargs: mocks["gateway"])
monkeypatch.setattr("yuxi.channel.bootstrap.asyncio.create_task", lambda coro: mocks["dummy_task"])
return mocks
async def test_start_assembles_gateway_and_attaches_state(app, bootstrap, start_mocks):
"""验证启动器会按顺序组装网关组件并挂载到 app.state。"""
await bootstrap.start(app)
start_mocks["load_plugins"].assert_called_once()
start_mocks["lifecycle"].set_message_handler.assert_called_once()
start_mocks["lifecycle"].start_all.assert_awaited_once()
start_mocks["dispatcher"].start.assert_awaited_once()
start_mocks["gateway"].start_config_change_listener.assert_awaited_once()
assert app.state.channel_lifecycle_manager is start_mocks["lifecycle"]
assert app.state.channel_outbound_dispatcher is start_mocks["dispatcher"]
assert app.state.channel_gateway is start_mocks["gateway"]
assert bootstrap._compensate_task is start_mocks["dummy_task"]
assert bootstrap._started is True
async def test_start_registers_message_handler_before_starting_lifecycle(app, bootstrap, start_mocks):
"""验证消息处理器在 lifecycle 启动前注册,避免消息丢失窗口。"""
await bootstrap.start(app)
lifecycle_mock = start_mocks["lifecycle"]
method_names = [call[0] for call in lifecycle_mock.method_calls]
assert method_names[0] == "set_message_handler"
assert method_names.index("set_message_handler") < method_names.index("start_all")
async def test_start_failure_triggers_rollback(app, bootstrap, start_mocks):
"""验证启动中途失败会回滚已启动组件并重新抛出异常。"""
start_mocks["lifecycle"].start_all.side_effect = RuntimeError("boom")
with pytest.raises(RuntimeError, match="boom"):
await bootstrap.start(app)
# 回滚应关闭 gateway含 lifecycle与 outbound dispatcher
start_mocks["gateway"].stop.assert_awaited_once()
start_mocks["dispatcher"].stop.assert_awaited_once()
assert bootstrap._started is False
async def test_start_is_idempotent(app, bootstrap, start_mocks):
"""验证重复调用 start() 不会重复初始化组件。"""
await bootstrap.start(app)
await bootstrap.start(app)
start_mocks["load_plugins"].assert_called_once()
start_mocks["lifecycle"].start_all.assert_awaited_once()
start_mocks["dispatcher"].start.assert_awaited_once()
async def test_stop_closes_components_in_order(bootstrap):
"""验证关闭时按 compensate -> gateway -> outbound 的顺序释放资源。"""
mock_dispatcher = AsyncMock()
mock_gateway = AsyncMock()
mock_lifecycle = AsyncMock()
mock_task = AsyncMock()
bootstrap._outbound_dispatcher = mock_dispatcher
bootstrap._gateway = mock_gateway
bootstrap._lifecycle_manager = mock_lifecycle
bootstrap._compensate_task = mock_task
bootstrap._started = True
await bootstrap.stop()
mock_task.cancel.assert_called_once()
mock_gateway.stop.assert_awaited_once()
# gateway.stop() 内部已负责 lifecycle外部不应重复调用
mock_lifecycle.stop_all.assert_not_awaited()
mock_dispatcher.stop.assert_awaited_once()
assert bootstrap._compensate_task is None
assert bootstrap._started is False
async def test_stop_uses_lifecycle_directly_when_gateway_is_missing(bootstrap):
"""验证 start 失败在未创建 gateway 时仍能正确停止 lifecycle。"""
mock_lifecycle = AsyncMock()
mock_dispatcher = AsyncMock()
bootstrap._lifecycle_manager = mock_lifecycle
bootstrap._outbound_dispatcher = mock_dispatcher
bootstrap._gateway = None
bootstrap._started = False
await bootstrap.stop()
mock_lifecycle.stop_all.assert_awaited_once()
mock_dispatcher.stop.assert_awaited_once()
async def test_stop_is_idempotent(bootstrap):
"""验证重复调用 stop() 不会重复关闭组件。"""
mock_dispatcher = AsyncMock()
mock_gateway = AsyncMock()
mock_task = AsyncMock()
bootstrap._outbound_dispatcher = mock_dispatcher
bootstrap._gateway = mock_gateway
bootstrap._compensate_task = mock_task
bootstrap._started = True
await bootstrap.stop()
await bootstrap.stop()
mock_task.cancel.assert_called_once()
mock_gateway.stop.assert_awaited_once()
mock_dispatcher.stop.assert_awaited_once()
async def test_stop_without_start_is_safe(bootstrap):
"""验证未启动时调用 stop() 不会报错。"""
await bootstrap.stop()
assert bootstrap._started is False