from __future__ import annotations import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest from yuxi.channels.base import BaseChannelAdapter from yuxi.channels.infra.circuit_breaker import CircuitBreaker from yuxi.channels.exceptions import ChannelException from yuxi.channels.manager import ChannelManager from yuxi.channels.models import ( ChannelIdentity, ChannelMessage, ChannelResponse, ChannelStatus, ChannelType, DeliveryResult, HealthStatus, ) from yuxi.channels.registry import ChannelRegistry, _BUILTIN_ADAPTERS def _make_adapter_cls(channel_id: str = "test_chan", channel_type: ChannelType = ChannelType.WEBCHAT): cid = channel_id ctype = channel_type class _TestAdapter(BaseChannelAdapter): channel_id = cid channel_type = ctype text_chunk_limit = 4096 supports_markdown = False supports_streaming = False max_media_size_mb = 100 async def connect(self) -> None: ... async def disconnect(self) -> None: ... async def send(self, response) -> DeliveryResult: ... def normalize_inbound(self, raw) -> ChannelMessage: ... def format_outbound(self, response): ... async def health_check(self) -> HealthStatus: return HealthStatus(status="healthy") return _TestAdapter def _make_adapter_instance(): adapter = AsyncMock(spec=BaseChannelAdapter) adapter.channel_id = "test_chan" adapter.channel_type = ChannelType.WEBCHAT adapter.text_chunk_limit = 4096 adapter.supports_markdown = False adapter.supports_streaming = False adapter.max_media_size_mb = 100 adapter.config = {"enabled": True, "display_name": "Test"} adapter.pre_connect = AsyncMock(return_value={}) adapter.connect = AsyncMock(return_value=None) adapter.disconnect = AsyncMock(return_value=None) adapter.send = AsyncMock(return_value=DeliveryResult(success=True)) adapter.health_check = AsyncMock(return_value=HealthStatus(status="healthy")) adapter.on_message = MagicMock() return adapter class TestChannelManagerInit: def test_init_with_defaults(self): manager = ChannelManager() assert isinstance(manager._registry, ChannelRegistry) assert manager._router is not None assert manager._adapters == {} assert manager._initialized is False def test_init_with_custom_registry(self): registry = ChannelRegistry() manager = ChannelManager(registry=registry) assert manager._registry is registry def test_init_with_custom_router(self): from yuxi.channels.router import MessageRouter router = MessageRouter() manager = ChannelManager(router=router) assert manager._router is router def test_manager_not_initialized_after_init(self): manager = ChannelManager() assert manager._initialized is False class TestChannelLifecycle: @pytest.mark.asyncio async def test_start_channel_success(self): manager = ChannelManager() TestCls = _make_adapter_cls("test_chan") manager._registry.register("test_chan", TestCls) manager._health_check_loop = AsyncMock() await manager.start_channel("test_chan", {"enabled": True}) assert "test_chan" in manager._adapters manager._health_check_loop.assert_called_once() await manager.shutdown() @pytest.mark.asyncio async def test_start_channel_already_running(self): manager = ChannelManager() TestCls = _make_adapter_cls("test_chan") manager._registry.register("test_chan", TestCls) manager._health_check_loop = AsyncMock() await manager.start_channel("test_chan", {"enabled": True}) assert "test_chan" in manager._adapters await manager.start_channel("test_chan", {"enabled": True}) assert "test_chan" in manager._adapters await manager.shutdown() @pytest.mark.asyncio async def test_start_channel_unregistered(self): manager = ChannelManager() with pytest.raises(ValueError, match="No adapter registered"): await manager.start_channel("nonexistent") @pytest.mark.asyncio async def test_stop_channel_success(self): manager = ChannelManager() TestCls = _make_adapter_cls("test_chan") manager._registry.register("test_chan", TestCls) manager._health_check_loop = AsyncMock() await manager.start_channel("test_chan", {"enabled": True}) assert "test_chan" in manager._adapters await manager.stop_channel("test_chan") assert "test_chan" not in manager._adapters @pytest.mark.asyncio async def test_stop_channel_not_running(self): manager = ChannelManager() await manager.stop_channel("nonexistent") @pytest.mark.asyncio async def test_restart_channel(self): manager = ChannelManager() TestCls = _make_adapter_cls("test_chan") manager._registry.register("test_chan", TestCls) manager._health_check_loop = AsyncMock() await manager.start_channel("test_chan", {"enabled": True}) await manager.restart_channel("test_chan") assert "test_chan" in manager._adapters await manager.shutdown() class TestHealthCheck: @pytest.mark.asyncio async def test_health_check_healthy(self): manager = ChannelManager() TestCls = _make_adapter_cls("test_chan") manager._registry.register("test_chan", TestCls) manager._health_check_loop = AsyncMock() await manager.start_channel("test_chan", {"enabled": True}) manager._health_check_loop.assert_called_once() await manager.shutdown() @pytest.mark.asyncio async def test_health_check_loop_runs(self): manager = ChannelManager() TestCls = _make_adapter_cls("test_chan") manager._registry.register("test_chan", TestCls) loop_count = 0 async def mock_health_loop(channel_id): nonlocal loop_count loop_count += 1 manager._health_check_loop = mock_health_loop await manager.start_channel("test_chan", {"enabled": True}) await asyncio.sleep(0) assert loop_count == 1 await manager.shutdown() class TestChannelStatus: @pytest.mark.asyncio async def test_get_all_channels_status(self): manager = ChannelManager() TestCls = _make_adapter_cls("test_chan") manager._registry.register("test_chan", TestCls) status = await manager.get_channel_status() assert "channels" in status assert "test_chan" in status["channels"] @pytest.mark.asyncio async def test_get_single_channel_status(self): manager = ChannelManager() TestCls = _make_adapter_cls("test_chan") manager._registry.register("test_chan", TestCls) status = await manager.get_channel_status("test_chan") assert status["channel_id"] == "test_chan" assert "channel_type" in status @pytest.mark.asyncio async def test_get_not_found_channel(self): manager = ChannelManager() status = await manager.get_channel_status("nonexistent") assert status["status"] == "not_found" @pytest.mark.asyncio async def test_status_includes_circuit_state(self): manager = ChannelManager() TestCls = _make_adapter_cls("test_chan") manager._registry.register("test_chan", TestCls) manager._health_check_loop = AsyncMock() await manager.start_channel("test_chan", {"enabled": True}) status = await manager.get_channel_status("test_chan") assert "circuit_state" in status await manager.shutdown() class TestRateLimiting: @pytest.mark.asyncio async def test_rate_limit_allows(self): manager = ChannelManager() result = await manager.check_rate_limit("key1", 5, 60) assert result is True @pytest.mark.asyncio async def test_rate_limit_blocks(self): manager = ChannelManager() max_req = 3 for _ in range(max_req): assert await manager.check_rate_limit("key2", max_req, 60) is True assert await manager.check_rate_limit("key2", max_req, 60) is False @pytest.mark.asyncio async def test_rate_limit_different_keys_independent(self): manager = ChannelManager() for _ in range(5): assert await manager.check_rate_limit("key_a", 5, 60) is True assert await manager.check_rate_limit("key_b", 5, 60) is True class TestConfig: @pytest.mark.asyncio async def test_update_channel_config(self): manager = ChannelManager() TestCls = _make_adapter_cls("test_chan") manager._registry.register("test_chan", TestCls) manager._health_check_loop = AsyncMock() await manager.start_channel("test_chan", {"enabled": True}) result = await manager.update_channel_config("test_chan", {"display_name": "NewName"}) assert result["config_updated"] is True await manager.shutdown() @pytest.mark.asyncio async def test_update_config_channel_not_found(self): manager = ChannelManager() with pytest.raises(ChannelException, match="not found"): await manager.update_channel_config("nonexistent", {}) @pytest.mark.asyncio async def test_test_channel_healthy(self): manager = ChannelManager() TestCls = _make_adapter_cls("test_chan") manager._registry.register("test_chan", TestCls) manager._health_check_loop = AsyncMock() await manager.start_channel("test_chan", {"enabled": True}) result = await manager.test_channel("test_chan") assert result["test_result"] == "success" assert "latency_ms" in result await manager.shutdown() @pytest.mark.asyncio async def test_test_channel_not_running(self): manager = ChannelManager() result = await manager.test_channel("nonexistent") assert result["test_result"] == "failure" assert "not running" in result["error"] class TestSendOutbound: @pytest.mark.asyncio async def test_send_outbound_success(self): manager = ChannelManager() TestCls = _make_adapter_cls("test_chan") manager._registry.register("test_chan", TestCls) manager._health_check_loop = AsyncMock() await manager.start_channel("test_chan", {"enabled": True}) adapter = manager._adapters["test_chan"] adapter.send = AsyncMock(return_value=DeliveryResult(success=True)) identity = ChannelIdentity( channel_id="test_chan", channel_type=ChannelType.WEBCHAT, channel_user_id="u1", channel_chat_id="c1", ) response = ChannelResponse(identity=identity, content="ok") await manager.send_outbound("test_chan", response) adapter.send.assert_called_once() await manager.shutdown() @pytest.mark.asyncio async def test_send_outbound_channel_not_found(self): manager = ChannelManager() identity = ChannelIdentity( channel_id="test_chan", channel_type=ChannelType.WEBCHAT, channel_user_id="u1", channel_chat_id="c1", ) response = ChannelResponse(identity=identity, content="ok") with pytest.raises(ChannelException, match="not found"): await manager.send_outbound("nonexistent", response) class TestShutdown: @pytest.mark.asyncio async def test_shutdown_all_channels(self): manager = ChannelManager() TestCls = _make_adapter_cls("test_chan") manager._registry.register("test_chan", TestCls) manager._health_check_loop = AsyncMock() await manager.start_channel("test_chan", {"enabled": True}) assert "test_chan" in manager._adapters await manager.shutdown() assert "test_chan" not in manager._adapters assert manager._initialized is False