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