新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
548 lines
19 KiB
Python
548 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.base import BaseChannelAdapter
|
|
from yuxi.channels.bridge import BridgeAdapter
|
|
from yuxi.channels.models import (
|
|
ChannelAccountSnapshot,
|
|
ChannelIdentity,
|
|
ChannelMessage,
|
|
ChannelResponse,
|
|
ChannelType,
|
|
DeliveryResult,
|
|
HealthStatus,
|
|
build_snapshot_from_adapter,
|
|
)
|
|
from yuxi.channels.protocols.config import ChannelConfigProtocol
|
|
from yuxi.channels.protocols.gateway import ChannelGatewayProtocol
|
|
from yuxi.channels.protocols.heartbeat import ChannelHeartbeatProtocol
|
|
from yuxi.channels.protocols.lifecycle import ChannelLifecycleProtocol
|
|
from yuxi.channels.protocols.messaging import ChannelMessagingProtocol
|
|
from yuxi.channels.protocols.outbound import ChannelOutboundProtocol
|
|
from yuxi.channels.protocols.status import ChannelStatusProtocol
|
|
from yuxi.channels.protocols.threading import ChannelThreadingProtocol
|
|
|
|
|
|
class _MinimalAdapter(BaseChannelAdapter):
|
|
channel_id = "test_minimal"
|
|
channel_type = ChannelType.WEBCHAT
|
|
|
|
async def connect(self) -> None:
|
|
pass
|
|
|
|
async def disconnect(self) -> None:
|
|
pass
|
|
|
|
async def send(self, response: ChannelResponse) -> DeliveryResult:
|
|
return DeliveryResult(success=True)
|
|
|
|
def normalize_inbound(self, raw) -> ChannelMessage:
|
|
identity = ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id="u1",
|
|
channel_chat_id="c1",
|
|
)
|
|
return ChannelMessage(identity=identity, content=str(raw))
|
|
|
|
def format_outbound(self, response: ChannelResponse):
|
|
return {"content": response.content}
|
|
|
|
async def health_check(self) -> HealthStatus:
|
|
return HealthStatus(status="healthy")
|
|
|
|
|
|
class TestProtocolContracts:
|
|
def test_channel_config_protocol_shape(self):
|
|
class Impl:
|
|
@property
|
|
def channel_id(self) -> str:
|
|
return "c1"
|
|
|
|
@property
|
|
def channel_type(self) -> str:
|
|
return "telegram"
|
|
|
|
def resolve_account(self, account_id: str) -> dict | None:
|
|
return {}
|
|
|
|
def is_enabled(self) -> bool:
|
|
return True
|
|
|
|
def is_configured(self) -> bool:
|
|
return True
|
|
|
|
def list_account_ids(self) -> list[str]:
|
|
return ["c1"]
|
|
|
|
def default_account_id(self) -> str:
|
|
return "c1"
|
|
|
|
def disabled_reason(self) -> str:
|
|
return ""
|
|
|
|
def unconfigured_reason(self) -> str:
|
|
return ""
|
|
|
|
def describe_account(self) -> dict:
|
|
return {}
|
|
|
|
def resolve_allow_from(self) -> list[str]:
|
|
return ["*"]
|
|
|
|
def has_configured_state(self) -> bool:
|
|
return True
|
|
|
|
obj = Impl()
|
|
assert isinstance(obj, ChannelConfigProtocol)
|
|
|
|
def test_channel_config_protocol_rejects_missing_methods(self):
|
|
class Incomplete:
|
|
pass
|
|
|
|
assert not isinstance(Incomplete(), ChannelConfigProtocol)
|
|
|
|
def test_channel_gateway_protocol_shape(self):
|
|
class Impl:
|
|
async def connect(self):
|
|
pass
|
|
|
|
async def disconnect(self):
|
|
pass
|
|
|
|
async def health_check(self) -> HealthStatus:
|
|
return HealthStatus(status="healthy")
|
|
|
|
async def pre_connect(self) -> dict:
|
|
return {}
|
|
|
|
async def logout_account(self, ctx) -> None:
|
|
pass
|
|
|
|
async def login_with_qr_start(self, force: bool, timeout_ms: int) -> str:
|
|
return ""
|
|
|
|
obj = Impl()
|
|
assert isinstance(obj, ChannelGatewayProtocol)
|
|
|
|
def test_channel_gateway_protocol_rejects_missing_methods(self):
|
|
class Incomplete:
|
|
async def connect(self):
|
|
pass
|
|
|
|
assert not isinstance(Incomplete(), ChannelGatewayProtocol)
|
|
|
|
def test_channel_outbound_protocol_shape(self):
|
|
class Impl:
|
|
async def send(self, response):
|
|
return DeliveryResult(success=True)
|
|
|
|
async def send_media(self, chat_id, media_type, data):
|
|
return DeliveryResult(success=True)
|
|
|
|
async def edit_message(self, chat_id, msg_id, content):
|
|
return DeliveryResult(success=True)
|
|
|
|
async def delete_message(self, chat_id, msg_id):
|
|
return DeliveryResult(success=True)
|
|
|
|
async def send_reaction(self, chat_id, msg_id, emoji):
|
|
return DeliveryResult(success=True)
|
|
|
|
def chunker(self, text: str, limit: int) -> list[str]:
|
|
return [text]
|
|
|
|
@property
|
|
def chunker_mode(self) -> str:
|
|
return "text"
|
|
|
|
@property
|
|
def text_chunk_limit(self) -> int:
|
|
return 4096
|
|
|
|
@property
|
|
def delivery_mode(self) -> str:
|
|
return "direct"
|
|
|
|
def sanitize_text(self, text: str) -> str:
|
|
return text
|
|
|
|
async def send_text(self, ctx) -> DeliveryResult:
|
|
return DeliveryResult(success=True)
|
|
|
|
async def send_media_url(self, ctx) -> DeliveryResult:
|
|
return DeliveryResult(success=True)
|
|
|
|
async def send_poll(self, ctx) -> DeliveryResult:
|
|
return DeliveryResult(success=True)
|
|
|
|
async def pin_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
|
|
return DeliveryResult(success=True)
|
|
|
|
async def unpin_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
|
|
return DeliveryResult(success=True)
|
|
|
|
obj = Impl()
|
|
assert isinstance(obj, ChannelOutboundProtocol)
|
|
|
|
def test_channel_status_protocol_shape(self):
|
|
class Impl:
|
|
def snapshot(self) -> dict:
|
|
return {}
|
|
|
|
@property
|
|
def status(self) -> str:
|
|
return "connected"
|
|
|
|
async def probe_account(self, account: dict, timeout_ms: int) -> dict:
|
|
return {}
|
|
|
|
async def build_account_snapshot(self, account: dict, probe: dict) -> ChannelAccountSnapshot:
|
|
return ChannelAccountSnapshot(account_id="", channel_id="", channel_type=ChannelType.WEBCHAT)
|
|
|
|
def build_channel_summary(self, account: dict, snapshot: dict) -> dict:
|
|
return {}
|
|
|
|
async def audit_account(self, account: dict, timeout_ms: int) -> dict:
|
|
return {}
|
|
|
|
def resolve_account_state(self, configured: bool, enabled: bool) -> str:
|
|
return "active"
|
|
|
|
def collect_status_issues(self, accounts: list) -> list[str]:
|
|
return []
|
|
|
|
obj = Impl()
|
|
assert isinstance(obj, ChannelStatusProtocol)
|
|
|
|
def test_channel_lifecycle_protocol_shape(self):
|
|
class Impl:
|
|
def on_message(self, handler):
|
|
pass
|
|
|
|
def normalize_inbound(self, raw):
|
|
return ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id="test", channel_type=ChannelType.WEBCHAT,
|
|
channel_user_id="u1", channel_chat_id="c1",
|
|
),
|
|
content=str(raw),
|
|
)
|
|
|
|
def format_outbound(self, response):
|
|
return {"content": response.content}
|
|
|
|
def on_config_changed(self, prev_cfg: dict, next_cfg: dict) -> None:
|
|
pass
|
|
|
|
async def run_startup_maintenance(self) -> None:
|
|
pass
|
|
|
|
obj = Impl()
|
|
assert isinstance(obj, ChannelLifecycleProtocol)
|
|
|
|
def test_channel_messaging_protocol_shape(self):
|
|
class Impl:
|
|
async def receive(self):
|
|
return
|
|
yield # type: ignore[misc]
|
|
|
|
async def download_media(self, file_id: str) -> bytes:
|
|
return b""
|
|
|
|
async def get_user_info(self, channel_user_id: str) -> dict:
|
|
return {}
|
|
|
|
def normalize_target(self, raw) -> str:
|
|
return str(raw)
|
|
|
|
def resolve_inbound_conversation(self, from_: str, to: str, thread_id: str) -> dict:
|
|
return {}
|
|
|
|
def resolve_delivery_target(self, conversation_id: str) -> dict:
|
|
return {}
|
|
|
|
def infer_target_chat_type(self, to: str) -> str:
|
|
return "direct"
|
|
|
|
def parse_explicit_target(self, raw: str) -> dict:
|
|
return {}
|
|
|
|
def transform_reply_payload(self, payload: dict) -> dict:
|
|
return payload
|
|
|
|
def resolve_outbound_session_route(self, target: dict) -> dict:
|
|
return {}
|
|
|
|
obj = Impl()
|
|
assert isinstance(obj, ChannelMessagingProtocol)
|
|
|
|
def test_channel_threading_protocol_shape(self):
|
|
class Impl:
|
|
def resolve_reply_mode(self, config, chat_type: str) -> str:
|
|
return "off"
|
|
|
|
def resolve_thread_id(self, message) -> str | None:
|
|
return None
|
|
|
|
def build_tool_context(self, context: dict) -> dict:
|
|
return context
|
|
|
|
def resolve_auto_thread_id(self, to: str, reply_to_id: str) -> str | None:
|
|
return None
|
|
|
|
def resolve_reply_transport(self, thread_id: str, reply_to_id: str) -> dict:
|
|
return {}
|
|
|
|
obj = Impl()
|
|
assert isinstance(obj, ChannelThreadingProtocol)
|
|
|
|
def test_channel_heartbeat_protocol_shape(self):
|
|
class Impl:
|
|
async def send_typing_indicator(self, chat_id: str):
|
|
pass
|
|
|
|
async def send_stream_chunk(self, chat_id, msg_id, chunk, finished):
|
|
return DeliveryResult(success=True)
|
|
|
|
async def check_ready(self) -> bool:
|
|
return True
|
|
|
|
async def clear_typing_indicator(self, chat_id: str) -> None:
|
|
pass
|
|
|
|
obj = Impl()
|
|
assert isinstance(obj, ChannelHeartbeatProtocol)
|
|
|
|
|
|
class TestBridgeAdapterIsinstance:
|
|
def setup_method(self):
|
|
self.legacy = _MinimalAdapter(config={"enabled": True})
|
|
self.bridge = BridgeAdapter(self.legacy)
|
|
|
|
def test_isinstance_config_protocol(self):
|
|
assert isinstance(self.bridge, ChannelConfigProtocol)
|
|
|
|
def test_isinstance_gateway_protocol(self):
|
|
assert isinstance(self.bridge, ChannelGatewayProtocol)
|
|
|
|
def test_isinstance_outbound_protocol(self):
|
|
assert isinstance(self.bridge, ChannelOutboundProtocol)
|
|
|
|
def test_isinstance_status_protocol(self):
|
|
assert isinstance(self.bridge, ChannelStatusProtocol)
|
|
|
|
def test_isinstance_lifecycle_protocol(self):
|
|
assert isinstance(self.bridge, ChannelLifecycleProtocol)
|
|
|
|
def test_isinstance_messaging_protocol(self):
|
|
assert isinstance(self.bridge, ChannelMessagingProtocol)
|
|
|
|
def test_isinstance_threading_protocol(self):
|
|
assert isinstance(self.bridge, ChannelThreadingProtocol)
|
|
|
|
def test_isinstance_heartbeat_protocol(self):
|
|
assert isinstance(self.bridge, ChannelHeartbeatProtocol)
|
|
|
|
|
|
class TestBridgeAdapterDelegation:
|
|
def setup_method(self):
|
|
self.config = {"enabled": True, "display_name": "Test"}
|
|
self.legacy = _MinimalAdapter(config=self.config)
|
|
self.bridge = BridgeAdapter(self.legacy)
|
|
|
|
def test_config_channel_id(self):
|
|
assert self.bridge.channel_id == "test_minimal"
|
|
|
|
def test_config_channel_type(self):
|
|
assert self.bridge.channel_type == "webchat"
|
|
|
|
def test_config_is_enabled(self):
|
|
assert self.bridge.is_enabled() is True
|
|
|
|
def test_config_is_enabled_default_false(self):
|
|
legacy = _MinimalAdapter(config={})
|
|
bridge = BridgeAdapter(legacy)
|
|
assert bridge.is_enabled() is False
|
|
|
|
def test_config_is_configured(self):
|
|
assert self.bridge.is_configured() is True
|
|
|
|
def test_config_is_configured_empty(self):
|
|
legacy = _MinimalAdapter(config={})
|
|
bridge = BridgeAdapter(legacy)
|
|
assert bridge.is_configured() is False
|
|
|
|
def test_config_list_account_ids(self):
|
|
assert self.bridge.list_account_ids() == ["test_minimal"]
|
|
|
|
def test_config_resolve_account_match(self):
|
|
result = self.bridge.resolve_account("test_minimal")
|
|
assert result == self.config
|
|
|
|
def test_config_resolve_account_mismatch(self):
|
|
result = self.bridge.resolve_account("other")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_connect_delegates(self):
|
|
self.legacy.connect = AsyncMock()
|
|
await self.bridge.connect()
|
|
self.legacy.connect.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_disconnect_delegates(self):
|
|
self.legacy.disconnect = AsyncMock()
|
|
await self.bridge.disconnect()
|
|
self.legacy.disconnect.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_health_check_delegates(self):
|
|
self.legacy.health_check = AsyncMock(return_value=HealthStatus(status="healthy"))
|
|
result = await self.bridge.health_check()
|
|
assert result.status == "healthy"
|
|
self.legacy.health_check.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_pre_connect_delegates(self):
|
|
self.legacy.pre_connect = AsyncMock(return_value={"qr_url": "http://..."})
|
|
result = await self.bridge.pre_connect()
|
|
assert result == {"qr_url": "http://..."}
|
|
self.legacy.pre_connect.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_outbound_send_delegates(self):
|
|
identity = ChannelIdentity(
|
|
channel_id="test_minimal", channel_type=ChannelType.WEBCHAT,
|
|
channel_user_id="u1", channel_chat_id="c1",
|
|
)
|
|
response = ChannelResponse(identity=identity, content="hello")
|
|
self.legacy.send = AsyncMock(return_value=DeliveryResult(success=True, message_id="m1"))
|
|
|
|
result = await self.bridge.send(response)
|
|
assert result.success is True
|
|
assert result.message_id == "m1"
|
|
self.legacy.send.assert_called_once_with(response)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_outbound_send_media_delegates(self):
|
|
self.legacy.send_media = AsyncMock(return_value=DeliveryResult(success=True))
|
|
result = await self.bridge.send_media("c1", "image", b"data")
|
|
assert result.success is True
|
|
self.legacy.send_media.assert_called_once_with("c1", "image", b"data")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_outbound_edit_message_delegates(self):
|
|
self.legacy.edit_message = AsyncMock(return_value=DeliveryResult(success=True))
|
|
result = await self.bridge.edit_message("c1", "m1", "new")
|
|
assert result.success is True
|
|
self.legacy.edit_message.assert_called_once_with("c1", "m1", "new")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_outbound_delete_message_delegates(self):
|
|
self.legacy.delete_message = AsyncMock(return_value=DeliveryResult(success=True))
|
|
result = await self.bridge.delete_message("c1", "m1")
|
|
assert result.success is True
|
|
self.legacy.delete_message.assert_called_once_with("c1", "m1")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_outbound_send_reaction_delegates(self):
|
|
self.legacy.send_reaction = AsyncMock(return_value=DeliveryResult(success=True))
|
|
result = await self.bridge.send_reaction("c1", "m1", "👍")
|
|
assert result.success is True
|
|
self.legacy.send_reaction.assert_called_once_with("c1", "m1", "👍")
|
|
|
|
def test_lifecycle_on_message_delegates(self):
|
|
handler = AsyncMock()
|
|
self.bridge.on_message(handler)
|
|
assert self.legacy._message_handler is handler
|
|
|
|
def test_lifecycle_normalize_inbound_delegates(self):
|
|
msg = self.bridge.normalize_inbound("raw text")
|
|
assert isinstance(msg, ChannelMessage)
|
|
assert msg.content == "raw text"
|
|
|
|
def test_lifecycle_format_outbound_delegates(self):
|
|
identity = ChannelIdentity(
|
|
channel_id="test_minimal", channel_type=ChannelType.WEBCHAT,
|
|
channel_user_id="u1", channel_chat_id="c1",
|
|
)
|
|
response = ChannelResponse(identity=identity, content="hello")
|
|
result = self.bridge.format_outbound(response)
|
|
assert result == {"content": "hello"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_messaging_receive_delegates_empty(self):
|
|
items = []
|
|
async for msg in self.bridge.receive():
|
|
items.append(msg)
|
|
assert items == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_messaging_download_media_delegates(self):
|
|
self.legacy.download_media = AsyncMock(return_value=b"file_data")
|
|
result = await self.bridge.download_media("f1")
|
|
assert result == b"file_data"
|
|
self.legacy.download_media.assert_called_once_with("f1")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_messaging_get_user_info_delegates(self):
|
|
self.legacy.get_user_info = AsyncMock(return_value={"name": "test"})
|
|
result = await self.bridge.get_user_info("u1")
|
|
assert result == {"name": "test"}
|
|
self.legacy.get_user_info.assert_called_once_with("u1")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_heartbeat_send_stream_chunk_delegates(self):
|
|
identity = ChannelIdentity(
|
|
channel_id="test_minimal", channel_type=ChannelType.WEBCHAT,
|
|
channel_user_id="", channel_chat_id="c1", channel_message_id="m1",
|
|
)
|
|
response = ChannelResponse(identity=identity, content="chunk")
|
|
self.legacy.send = AsyncMock(return_value=DeliveryResult(success=True))
|
|
|
|
result = await self.bridge.send_stream_chunk("c1", "m1", "chunk", False)
|
|
assert result.success is True
|
|
self.legacy.send.assert_called_once()
|
|
|
|
|
|
class TestBridgeAdapterDefaults:
|
|
def setup_method(self):
|
|
self.bridge = BridgeAdapter(_MinimalAdapter())
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_typing_indicator_default_noop(self):
|
|
await self.bridge.send_typing_indicator("c1")
|
|
|
|
def test_resolve_reply_mode_default(self):
|
|
assert self.bridge.resolve_reply_mode({}, "direct") == "off"
|
|
|
|
def test_resolve_thread_id_default(self):
|
|
identity = ChannelIdentity(
|
|
channel_id="test_minimal", channel_type=ChannelType.WEBCHAT,
|
|
channel_user_id="u1", channel_chat_id="c1",
|
|
)
|
|
msg = ChannelMessage(identity=identity, content="hello")
|
|
assert self.bridge.resolve_thread_id(msg) is None
|
|
|
|
|
|
class TestBridgeAdapterStatusProtocol:
|
|
def setup_method(self):
|
|
self.bridge = BridgeAdapter(_MinimalAdapter())
|
|
|
|
def test_status_default(self):
|
|
assert self.bridge.status == "unknown"
|
|
|
|
def test_status_from_legacy(self):
|
|
legacy = _MinimalAdapter()
|
|
legacy._status = "connected"
|
|
bridge = BridgeAdapter(legacy)
|
|
assert bridge.status == "connected"
|
|
|
|
def test_snapshot_returns_dict(self):
|
|
result = self.bridge.snapshot()
|
|
assert isinstance(result, dict)
|
|
assert "account_id" in result |