ForcePilot/backend/test/unit/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

169 lines
5.7 KiB
Python

from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from yuxi.channel.bootstrap import ChannelGatewayBootstrap
@pytest.fixture
def app():
return FastAPI()
@pytest.fixture
def bootstrap():
return ChannelGatewayBootstrap()
@pytest.fixture
def start_mocks(monkeypatch):
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):
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):
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)
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):
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):
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()
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):
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):
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):
await bootstrap.stop()
assert bootstrap._started is False
async def test_stop_cancels_compensate_task_on_cancelled_error(bootstrap):
mock_task = AsyncMock()
mock_task.cancel = MagicMock()
mock_task.__await__ = lambda: iter([])
bootstrap._compensate_task = mock_task
bootstrap._started = True
await bootstrap.stop()
mock_task.cancel.assert_called_once()