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()