ForcePilot/backend/test/unit/channels/test_protocols_bridge.py
Kris 3264900bc9 test: 新增多渠道单元测试用例并配置测试环境变量
新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块
同时在测试配置中添加了测试用的OpenAI API密钥环境变量
2026-05-12 00:56:47 +08:00

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