169 lines
5.7 KiB
Python
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()
|