from __future__ import annotations import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest from yuxi.channel.exceptions import ChannelTransportReconnectRequested from yuxi.channel.lifecycle.manager import ChannelInstance, ChannelLifecycleManager, _build_webhook_callback_url from yuxi.channel.plugins.protocol import ChannelMeta, TransportType from yuxi.channel.transport.protocol import TransportState class _DummyTask: """可 await 且可被 cancel 的任务占位符。""" def __init__(self): self.cancel = MagicMock() def __await__(self): return iter([]) class TestBuildWebhookCallbackUrl: def test_uses_default_base_url(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.delenv("CHANNEL_WEBHOOK_CALLBACK_URL", raising=False) assert _build_webhook_callback_url("wechat") == "http://localhost:5050/api/channels/wechat/webhook" def test_uses_env_var(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("CHANNEL_WEBHOOK_CALLBACK_URL", "https://example.com/") assert _build_webhook_callback_url("wechat") == "https://example.com/api/channels/wechat/webhook" def test_strips_trailing_slash(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("CHANNEL_WEBHOOK_CALLBACK_URL", "https://example.com") assert _build_webhook_callback_url("wechat") == "https://example.com/api/channels/wechat/webhook" class TestChannelInstance: def test_initial_state(self): plugin = MagicMock() instance = ChannelInstance("wechat", "acc-1", {"token": "x"}, plugin) assert instance.channel_type == "wechat" assert instance.account_id == "acc-1" assert instance.config == {"token": "x"} assert instance.plugin is plugin assert instance.transport is None assert instance.no_transport is False assert instance.last_error is None assert instance.reconnect_attempts == 0 assert instance.is_closed() is False async def test_start_creates_transport(self): plugin = MagicMock() plugin.supports_qr_login.return_value = False transport = MagicMock() transport.state = TransportState.CONNECTED transport.start = AsyncMock() transport.on_message = MagicMock() plugin.create_transport = AsyncMock(return_value=transport) instance = ChannelInstance("wechat", "acc-1", {}, plugin) await instance.start() assert instance.transport is transport assert instance.no_transport is False transport.start.assert_awaited_once() async def test_start_with_no_transport(self): plugin = MagicMock() plugin.create_transport = AsyncMock(return_value=None) instance = ChannelInstance("wechat", "acc-1", {}, plugin) await instance.start() assert instance.transport is None assert instance.no_transport is True async def test_start_stops_existing_transport(self): plugin = MagicMock() plugin.supports_qr_login.return_value = False old_transport = MagicMock() old_transport.state = TransportState.CONNECTED old_transport.stop = AsyncMock() new_transport = MagicMock() new_transport.state = TransportState.CONNECTED new_transport.start = AsyncMock() new_transport.on_message = MagicMock() plugin.create_transport = AsyncMock(return_value=new_transport) instance = ChannelInstance("wechat", "acc-1", {}, plugin) instance._transport = old_transport await instance.start() old_transport.stop.assert_awaited_once() assert instance.transport is new_transport async def test_stop(self): plugin = MagicMock() transport = MagicMock() transport.stop = AsyncMock() instance = ChannelInstance("wechat", "acc-1", {}, plugin) instance._transport = transport await instance.stop(timeout=5.0) transport.stop.assert_awaited_once() assert instance.transport is None async def test_stop_without_transport_is_safe(self): instance = ChannelInstance("wechat", "acc-1", {}, MagicMock()) await instance.stop() assert instance.transport is None async def test_stop_times_out(self): plugin = MagicMock() transport = MagicMock() async def slow_stop(): await asyncio.sleep(10) transport.stop = slow_stop instance = ChannelInstance("wechat", "acc-1", {}, plugin) instance._transport = transport await instance.stop(timeout=0.01) assert instance.transport is None def test_set_message_handler_attaches_to_existing_transport(self): plugin = MagicMock() transport = MagicMock() transport.on_message = MagicMock() handler = AsyncMock() instance = ChannelInstance("wechat", "acc-1", {}, plugin) instance._transport = transport instance.set_message_handler(handler) assert instance._message_handler is handler transport.on_message.assert_called_once() def test_set_message_handler_without_transport(self): plugin = MagicMock() handler = AsyncMock() instance = ChannelInstance("wechat", "acc-1", {}, plugin) instance.set_message_handler(handler) assert instance._message_handler is handler async def test_register_transport_message_handler(self): plugin = MagicMock() transport = MagicMock() transport.on_message = MagicMock() handler = AsyncMock() instance = ChannelInstance("wechat", "acc-1", {}, plugin, message_handler=handler) instance._transport = transport instance._register_transport_message_handler() transport.on_message.assert_called_once() wrapped = transport.on_message.call_args[0][0] raw = b"hello" await wrapped(raw) # Wait briefly for the spawned task await asyncio.sleep(0) await asyncio.sleep(0) handler.assert_awaited_once_with(raw, "wechat", "acc-1") async def test_register_transport_message_handler_reconnect_requested(self): plugin = MagicMock() transport = MagicMock() transport.on_message = MagicMock() transport.stop = AsyncMock() handler = AsyncMock(side_effect=ChannelTransportReconnectRequested("reconnect")) instance = ChannelInstance("wechat", "acc-1", {}, plugin, message_handler=handler) instance._transport = transport instance._register_transport_message_handler() wrapped = transport.on_message.call_args[0][0] await wrapped(b"hello") await asyncio.sleep(0) await asyncio.sleep(0) handler.assert_awaited_once() transport.stop.assert_awaited_once() assert instance._reconnect_requested is True async def test_register_transport_message_handler_error(self): plugin = MagicMock() transport = MagicMock() transport.on_message = MagicMock() handler = AsyncMock(side_effect=RuntimeError("boom")) instance = ChannelInstance("wechat", "acc-1", {}, plugin, message_handler=handler) instance._transport = transport instance._register_transport_message_handler() wrapped = transport.on_message.call_args[0][0] await wrapped(b"hello") await asyncio.sleep(0) await asyncio.sleep(0) handler.assert_awaited_once() def test_record_error(self): instance = ChannelInstance("wechat", "acc-1", {}, MagicMock()) instance.record_error(RuntimeError("boom")) assert instance.last_error == "boom" assert instance.reconnect_attempts == 1 async def test_wait_for_exit_with_transport(self): plugin = MagicMock() transport = MagicMock() transport.state = TransportState.CONNECTED instance = ChannelInstance("wechat", "acc-1", {}, plugin) instance._transport = transport async def waiter(): await asyncio.sleep(0.05) transport.state = TransportState.DISCONNECTED task = asyncio.create_task(waiter()) await instance.wait_for_exit() await task async def test_wait_for_exit_without_transport(self): instance = ChannelInstance("wechat", "acc-1", {}, MagicMock()) async def closer(): await asyncio.sleep(0.05) instance.close() task = asyncio.create_task(closer()) await instance.wait_for_exit() await task def test_close(self): instance = ChannelInstance("wechat", "acc-1", {}, MagicMock()) instance.close() assert instance.is_closed() is True class TestChannelLifecycleManager: def _make_manager(self, registry=None, config_manager=None, handler=None): registry = registry or MagicMock() config_manager = config_manager or MagicMock() return ChannelLifecycleManager(registry, config_manager, handler) def test_initial_state(self): registry = MagicMock() config_manager = MagicMock() manager = ChannelLifecycleManager(registry, config_manager) assert manager.registry is registry assert manager.config_manager is config_manager assert manager._instances == {} assert manager._tasks == {} def test_instance_key(self): manager = self._make_manager() assert manager._instance_key("wechat", "acc-1") == "wechat:acc-1" async def test_set_message_handler_propagates_to_instances(self): manager = self._make_manager() instance = MagicMock() instance.set_message_handler = MagicMock() manager._instances["wechat:acc-1"] = instance handler = AsyncMock() manager.set_message_handler(handler) assert manager._message_handler is handler instance.set_message_handler.assert_called_once_with(handler) async def test_start_all_starts_enabled_accounts(self): config_manager = MagicMock() config_manager.list_enabled_accounts = AsyncMock( return_value=[ {"channel_type": "wechat", "account_id": "acc-1"}, {"channel_type": "slack", "account_id": "acc-2"}, ] ) manager = self._make_manager(config_manager=config_manager) manager.start_channel = AsyncMock() await manager.start_all() manager.start_channel.assert_any_await("wechat", "acc-1") manager.start_channel.assert_any_await("slack", "acc-2") assert manager.start_channel.await_count == 2 async def test_start_with_semaphore_uses_semaphore(self): manager = self._make_manager() manager.start_channel = AsyncMock() config = {"channel_type": "wechat", "account_id": "acc-1"} await manager._start_with_semaphore(config) manager.start_channel.assert_awaited_once_with("wechat", "acc-1") async def test_start_channel_skips_existing(self): manager = self._make_manager() manager._instances["wechat:acc-1"] = MagicMock() await manager.start_channel("wechat", "acc-1") manager.registry.get_plugin.assert_not_called() async def test_start_channel_warns_when_plugin_missing(self): manager = self._make_manager() manager.registry.get_plugin = MagicMock(return_value=None) await manager.start_channel("unknown", "acc-1") manager.registry.get_plugin.assert_called_once_with("unknown") async def test_start_channel_handles_config_load_error(self): config_manager = MagicMock() config_manager.get_config = AsyncMock(side_effect=RuntimeError("db down")) registry = MagicMock() registry.get_plugin = MagicMock(return_value=MagicMock()) manager = self._make_manager(registry=registry, config_manager=config_manager) await manager.start_channel("wechat", "acc-1") assert "wechat:acc-1" not in manager._instances async def test_start_channel_creates_instance(self): plugin = MagicMock() plugin.get_meta.return_value = ChannelMeta( channel_type="wechat", display_name="WeChat", transport_type=TransportType.POLLING ) plugin.run_startup_maintenance = AsyncMock() plugin.on_channel_enabled = AsyncMock() config_manager = MagicMock() config_manager.get_config = AsyncMock(return_value={"token": "x"}) registry = MagicMock() registry.get_plugin = MagicMock(return_value=plugin) manager = self._make_manager(registry=registry, config_manager=config_manager) with patch("yuxi.channel.lifecycle.manager.asyncio.create_task") as create_task: dummy_task = MagicMock() create_task.return_value = dummy_task await manager.start_channel("wechat", "acc-1") assert "wechat:acc-1" in manager._instances instance = manager._instances["wechat:acc-1"] assert instance.channel_type == "wechat" assert instance.account_id == "acc-1" plugin.run_startup_maintenance.assert_awaited_once_with({"token": "x"}, "acc-1") plugin.on_channel_enabled.assert_awaited_once_with({"token": "x"}, "acc-1") async def test_start_channel_webhook_setup(self): plugin = MagicMock() plugin.get_meta.return_value = ChannelMeta( channel_type="wechat", display_name="WeChat", transport_type=TransportType.WEBHOOK ) plugin.run_startup_maintenance = AsyncMock() plugin.setup_webhook = AsyncMock(return_value=True) plugin.on_channel_enabled = AsyncMock() config_manager = MagicMock() config_manager.get_config = AsyncMock(return_value={"token": "x"}) registry = MagicMock() registry.get_plugin = MagicMock(return_value=plugin) manager = self._make_manager(registry=registry, config_manager=config_manager) with patch("yuxi.channel.lifecycle.manager.asyncio.create_task"): await manager.start_channel("wechat", "acc-1") plugin.setup_webhook.assert_awaited_once() plugin.on_channel_enabled.assert_awaited_once() async def test_start_channel_webhook_setup_failure_is_logged(self): plugin = MagicMock() plugin.get_meta.return_value = ChannelMeta( channel_type="wechat", display_name="WeChat", transport_type=TransportType.WEBHOOK ) plugin.run_startup_maintenance = AsyncMock() plugin.setup_webhook = AsyncMock(side_effect=RuntimeError("setup failed")) plugin.on_channel_enabled = AsyncMock() config_manager = MagicMock() config_manager.get_config = AsyncMock(return_value={"token": "x"}) registry = MagicMock() registry.get_plugin = MagicMock(return_value=plugin) manager = self._make_manager(registry=registry, config_manager=config_manager) with patch("yuxi.channel.lifecycle.manager.asyncio.create_task"): await manager.start_channel("wechat", "acc-1") assert "wechat:acc-1" in manager._instances plugin.on_channel_enabled.assert_awaited_once() async def test_stop_channel(self): plugin = MagicMock() plugin.get_meta.return_value = ChannelMeta( channel_type="wechat", display_name="WeChat", transport_type=TransportType.POLLING ) plugin.on_channel_disabled = AsyncMock() instance = MagicMock() instance.channel_type = "wechat" instance.account_id = "acc-1" instance.config = {"token": "x"} instance.plugin = plugin instance.close = MagicMock() instance.stop = AsyncMock() manager = self._make_manager() manager._instances["wechat:acc-1"] = instance task = _DummyTask() manager._tasks["wechat:acc-1"] = task await manager.stop_channel("wechat", "acc-1") instance.close.assert_called_once() instance.stop.assert_awaited_once_with(timeout=5.0) task.cancel.assert_called_once() assert "wechat:acc-1" not in manager._instances plugin.on_channel_disabled.assert_awaited_once_with({"token": "x"}, "acc-1") async def test_stop_channel_webhook_delete(self): plugin = MagicMock() plugin.get_meta.return_value = ChannelMeta( channel_type="wechat", display_name="WeChat", transport_type=TransportType.WEBHOOK ) plugin.delete_webhook = AsyncMock(return_value=True) plugin.on_channel_disabled = AsyncMock() instance = MagicMock() instance.channel_type = "wechat" instance.account_id = "acc-1" instance.config = {"token": "x"} instance.plugin = plugin instance.close = MagicMock() instance.stop = AsyncMock() manager = self._make_manager() manager._instances["wechat:acc-1"] = instance task = _DummyTask() manager._tasks["wechat:acc-1"] = task await manager.stop_channel("wechat", "acc-1") plugin.delete_webhook.assert_awaited_once_with({"token": "x"}) plugin.on_channel_disabled.assert_awaited_once() async def test_stop_channel_missing_instance(self): manager = self._make_manager() await manager.stop_channel("wechat", "acc-1") def test_get_transport_returns_instance_transport(self): manager = self._make_manager() instance = MagicMock() instance.transport = MagicMock() manager._instances["wechat:acc-1"] = instance transport = manager.get_transport("wechat", "acc-1") assert transport is instance.transport def test_get_transport_returns_none_when_instance_missing(self): manager = self._make_manager() assert manager.get_transport("wechat", "acc-1") is None async def test_stop_all(self): manager = self._make_manager() manager._instances["wechat:acc-1"] = MagicMock(channel_type="wechat", account_id="acc-1") manager._instances["slack:acc-2"] = MagicMock(channel_type="slack", account_id="acc-2") manager.stop_channel = AsyncMock() await manager.stop_all() assert manager.stop_channel.await_count == 2 async def test_run_loop_starts_transport_and_waits(self): plugin = MagicMock() plugin.get_meta.return_value = ChannelMeta( channel_type="wechat", display_name="WeChat", transport_type=TransportType.WEBSOCKET ) transport = MagicMock() transport.state = TransportState.CONNECTED instance = ChannelInstance("wechat", "acc-1", {}, plugin) instance.start = AsyncMock() instance._no_transport = False instance._transport = transport instance.wait_for_exit = AsyncMock() instance.stop = AsyncMock() manager = self._make_manager() with patch("yuxi.channel.lifecycle.manager.channel_active_connections") as mock_gauge: with patch("yuxi.channel.lifecycle.manager.channel_reconnect_total") as mock_counter: run_task = asyncio.create_task(manager._run_loop(instance)) await asyncio.sleep(0) instance.close() try: await asyncio.wait_for(run_task, timeout=0.2) except TimeoutError: run_task.cancel() try: await run_task except asyncio.CancelledError: pass instance.start.assert_awaited() instance.wait_for_exit.assert_awaited() async def test_run_loop_no_transport_breaks(self): plugin = MagicMock() plugin.get_meta.return_value = ChannelMeta( channel_type="wechat", display_name="WeChat", transport_type=TransportType.WEBHOOK ) instance = ChannelInstance("wechat", "acc-1", {}, plugin) instance.start = AsyncMock() instance._no_transport = True instance._transport = None instance.wait_for_exit = AsyncMock() instance.stop = AsyncMock() manager = self._make_manager() with patch("yuxi.channel.lifecycle.manager.channel_active_connections") as mock_gauge: await manager._run_loop(instance) instance.start.assert_awaited_once() instance.wait_for_exit.assert_not_awaited() async def test_run_loop_reconnects_after_disconnect(self): plugin = MagicMock() plugin.get_meta.return_value = ChannelMeta( channel_type="wechat", display_name="WeChat", transport_type=TransportType.WEBSOCKET ) transport = MagicMock() transport.state = TransportState.CONNECTED instance = ChannelInstance("wechat", "acc-1", {}, plugin) call_count = {"start": 0} async def start_side_effect(): call_count["start"] += 1 instance._no_transport = False instance._transport = transport instance.start = AsyncMock(side_effect=start_side_effect) instance._no_transport = False instance._transport = transport wait_count = {"n": 0} async def wait_side_effect(): wait_count["n"] += 1 if wait_count["n"] == 1: transport.state = TransportState.DISCONNECTED return instance.close() instance.wait_for_exit = AsyncMock(side_effect=wait_side_effect) instance.stop = AsyncMock() manager = self._make_manager() with patch("yuxi.channel.lifecycle.manager.channel_active_connections") as mock_gauge: with patch("yuxi.channel.lifecycle.manager.channel_reconnect_total") as mock_counter: with patch("yuxi.channel.lifecycle.manager.asyncio.sleep", new=AsyncMock()): await manager._run_loop(instance) assert call_count["start"] == 2 async def test_run_loop_respects_cancel(self): plugin = MagicMock() plugin.get_meta.return_value = ChannelMeta( channel_type="wechat", display_name="WeChat", transport_type=TransportType.WEBSOCKET ) instance = ChannelInstance("wechat", "acc-1", {}, plugin) instance.start = AsyncMock(side_effect=asyncio.CancelledError) instance.stop = AsyncMock() manager = self._make_manager() await manager._run_loop(instance) instance.start.assert_awaited_once() async def test_run_loop_records_error_and_backoff(self): plugin = MagicMock() plugin.get_meta.return_value = ChannelMeta( channel_type="wechat", display_name="WeChat", transport_type=TransportType.WEBSOCKET ) instance = ChannelInstance("wechat", "acc-1", {}, plugin) instance.start = AsyncMock(side_effect=ConnectionError("boom")) instance.stop = AsyncMock() instance.record_error = MagicMock(wraps=instance.record_error) instance.is_closed = MagicMock(side_effect=[False, True]) manager = self._make_manager() with patch("yuxi.channel.lifecycle.manager.channel_reconnect_total") as mock_counter: with patch("yuxi.channel.lifecycle.manager.asyncio.sleep", new=AsyncMock()): await manager._run_loop(instance) instance.record_error.assert_called_once() assert instance.reconnect_attempts == 1 async def test_run_loop_reconnect_requested_skips_backoff(self): plugin = MagicMock() plugin.get_meta.return_value = ChannelMeta( channel_type="wechat", display_name="WeChat", transport_type=TransportType.WEBSOCKET ) transport = MagicMock() transport.state = TransportState.CONNECTED instance = ChannelInstance("wechat", "acc-1", {}, plugin) instance.start = AsyncMock() instance._no_transport = False instance._transport = transport instance.stop = AsyncMock() instance.record_error = MagicMock(wraps=instance.record_error) wait_count = {"n": 0} async def wait_side_effect(): wait_count["n"] += 1 if wait_count["n"] == 1: instance._reconnect_requested = True return instance.close() instance.wait_for_exit = AsyncMock(side_effect=wait_side_effect) manager = self._make_manager() with patch("yuxi.channel.lifecycle.manager.channel_active_connections"): with patch("yuxi.channel.lifecycle.manager.channel_reconnect_total") as mock_counter: await manager._run_loop(instance) instance.record_error.assert_not_called() mock_counter.inc.assert_not_called() assert instance.context.reconnect_attempts == 0 async def test_per_key_lock_allows_concurrent_start(self): """不同 key 的 start_channel 可以并发执行。""" plugin = MagicMock() plugin.get_meta.return_value = ChannelMeta( channel_type="wechat", display_name="WeChat", transport_type=TransportType.POLLING ) plugin.run_startup_maintenance = AsyncMock() plugin.on_channel_enabled = AsyncMock() config_manager = MagicMock() config_manager.get_config = AsyncMock(return_value={"token": "x"}) registry = MagicMock() registry.get_plugin = MagicMock(return_value=plugin) manager = self._make_manager(registry=registry, config_manager=config_manager) with patch("yuxi.channel.lifecycle.manager.asyncio.create_task"): await asyncio.gather( manager.start_channel("wechat", "acc-1"), manager.start_channel("slack", "acc-2"), ) assert "wechat:acc-1" in manager._instances assert "slack:acc-2" in manager._instances