新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
347 lines
12 KiB
Python
347 lines
12 KiB
Python
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 |