325 lines
11 KiB
Python
325 lines
11 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from yuxi.channels.adapters.yuanbao.adapter import YuanbaoAdapter
|
||
|
|
from yuxi.channels.adapters.yuanbao.dispatch import (
|
||
|
|
DispatchAction,
|
||
|
|
InteractiveDispatcher,
|
||
|
|
)
|
||
|
|
from yuxi.channels.adapters.yuanbao.format import format_outbound, _map_message_type
|
||
|
|
from yuxi.channels.models import (
|
||
|
|
ChannelIdentity,
|
||
|
|
ChannelMessage,
|
||
|
|
ChannelResponse,
|
||
|
|
ChannelType,
|
||
|
|
ChatType,
|
||
|
|
DeliveryResult,
|
||
|
|
MessageType,
|
||
|
|
)
|
||
|
|
|
||
|
|
YUANBAO = ChannelType.YUANBAO
|
||
|
|
|
||
|
|
|
||
|
|
def _make_identity(
|
||
|
|
channel_chat_id: str = "user_openid_001",
|
||
|
|
channel_user_id: str = "user_openid_001",
|
||
|
|
channel_message_id: str = "msg_001",
|
||
|
|
) -> ChannelIdentity:
|
||
|
|
return ChannelIdentity(
|
||
|
|
channel_id="yuanbao",
|
||
|
|
channel_type=YUANBAO,
|
||
|
|
channel_user_id=channel_user_id,
|
||
|
|
channel_chat_id=channel_chat_id,
|
||
|
|
channel_message_id=channel_message_id,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _make_adapter(config: dict | None = None) -> YuanbaoAdapter:
|
||
|
|
cfg = config or {"app_key": "test_key", "app_secret": "test_secret", "dm_policy": "open"}
|
||
|
|
return YuanbaoAdapter(config=cfg)
|
||
|
|
|
||
|
|
|
||
|
|
# ==================== ChannelCapabilities Tests ====================
|
||
|
|
|
||
|
|
|
||
|
|
class TestChannelCapabilitiesOptimization:
|
||
|
|
def test_capabilities_has_explicit_false_fields(self):
|
||
|
|
adapter = _make_adapter()
|
||
|
|
caps = adapter.capabilities
|
||
|
|
assert caps.edit is False
|
||
|
|
assert caps.unsend is False
|
||
|
|
assert caps.pin is False
|
||
|
|
assert caps.polls is False
|
||
|
|
assert caps.typing is False
|
||
|
|
assert caps.send_ephemeral is False
|
||
|
|
assert caps.effects is False
|
||
|
|
|
||
|
|
def test_capabilities_retains_true_fields(self):
|
||
|
|
adapter = _make_adapter()
|
||
|
|
caps = adapter.capabilities
|
||
|
|
assert caps.reply is True
|
||
|
|
assert caps.reactions is True
|
||
|
|
assert caps.media is True
|
||
|
|
assert caps.block_streaming is True
|
||
|
|
assert caps.lane_streaming is True
|
||
|
|
assert caps.reasoning_streaming is True
|
||
|
|
assert caps.native_commands is True
|
||
|
|
assert caps.supports_markdown is True
|
||
|
|
assert caps.supports_streaming is True
|
||
|
|
|
||
|
|
def test_capabilities_chat_types(self):
|
||
|
|
adapter = _make_adapter()
|
||
|
|
caps = adapter.capabilities
|
||
|
|
assert "direct" in caps.chat_types
|
||
|
|
assert "group" in caps.chat_types
|
||
|
|
|
||
|
|
def test_capabilities_streaming_modes(self):
|
||
|
|
adapter = _make_adapter()
|
||
|
|
caps = adapter.capabilities
|
||
|
|
assert "off" in caps.streaming_modes
|
||
|
|
assert "block" in caps.streaming_modes
|
||
|
|
assert "lane" in caps.streaming_modes
|
||
|
|
assert "reasoning" in caps.streaming_modes
|
||
|
|
|
||
|
|
|
||
|
|
# ==================== send_typing_indicator Tests ====================
|
||
|
|
|
||
|
|
|
||
|
|
class TestSendTypingIndicator:
|
||
|
|
@pytest.fixture
|
||
|
|
def adapter(self):
|
||
|
|
return _make_adapter()
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_typing_indicator_no_client(self, adapter):
|
||
|
|
result = await adapter.send_typing_indicator("chat_001")
|
||
|
|
assert result.success is False
|
||
|
|
assert "not initialized" in result.error.lower()
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_typing_indicator_success(self, adapter):
|
||
|
|
adapter._http_client = MagicMock()
|
||
|
|
adapter._token_manager = MagicMock()
|
||
|
|
adapter._token_manager.get_token = AsyncMock(return_value="test_token")
|
||
|
|
adapter._token_manager.api_base = "https://api.yuanbao.test"
|
||
|
|
|
||
|
|
mock_resp = AsyncMock()
|
||
|
|
mock_resp.status = 200
|
||
|
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||
|
|
mock_resp.__aexit__ = AsyncMock(return_value=None)
|
||
|
|
adapter._http_client.post.return_value = mock_resp
|
||
|
|
|
||
|
|
result = await adapter.send_typing_indicator("open_id_001")
|
||
|
|
assert result.success is True
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_typing_indicator_http_error(self, adapter):
|
||
|
|
adapter._http_client = MagicMock()
|
||
|
|
adapter._token_manager = MagicMock()
|
||
|
|
adapter._token_manager.get_token = AsyncMock(return_value="test_token")
|
||
|
|
adapter._token_manager.api_base = "https://api.yuanbao.test"
|
||
|
|
|
||
|
|
mock_resp = AsyncMock()
|
||
|
|
mock_resp.status = 500
|
||
|
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||
|
|
mock_resp.__aexit__ = AsyncMock(return_value=None)
|
||
|
|
adapter._http_client.post.return_value = mock_resp
|
||
|
|
|
||
|
|
result = await adapter.send_typing_indicator("open_id_001")
|
||
|
|
assert result.success is False
|
||
|
|
assert "500" in result.error
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_typing_indicator_network_exception(self, adapter):
|
||
|
|
adapter._http_client = MagicMock()
|
||
|
|
adapter._token_manager = MagicMock()
|
||
|
|
adapter._token_manager.get_token = AsyncMock(return_value="test_token")
|
||
|
|
adapter._token_manager.api_base = "https://api.yuanbao.test"
|
||
|
|
|
||
|
|
adapter._http_client.post.side_effect = Exception("Connection refused")
|
||
|
|
|
||
|
|
result = await adapter.send_typing_indicator("open_id_001")
|
||
|
|
assert result.success is False
|
||
|
|
assert "Connection refused" in result.error
|
||
|
|
|
||
|
|
|
||
|
|
# ==================== edit_message / delete_message Tests ====================
|
||
|
|
|
||
|
|
|
||
|
|
class TestEditDeleteNoSideEffects:
|
||
|
|
@pytest.fixture
|
||
|
|
def adapter(self):
|
||
|
|
return _make_adapter()
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_edit_message_no_cache_side_effect(self, adapter):
|
||
|
|
adapter._send_cache.add("msg_abc", "chat_123", "Original content")
|
||
|
|
|
||
|
|
result = await adapter.edit_message("chat_123", "msg_abc", "Updated content")
|
||
|
|
assert result.success is False
|
||
|
|
assert "not supported" in result.error.lower()
|
||
|
|
|
||
|
|
entry = adapter._send_cache.get("msg_abc")
|
||
|
|
assert entry is not None
|
||
|
|
assert entry.status == "sent"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_delete_message_no_cache_side_effect(self, adapter):
|
||
|
|
adapter._send_cache.add("msg_abc", "chat_123", "Original content")
|
||
|
|
adapter._bot_message_ids.add("msg_abc")
|
||
|
|
|
||
|
|
result = await adapter.delete_message("chat_123", "msg_abc")
|
||
|
|
assert result.success is False
|
||
|
|
assert "not supported" in result.error.lower()
|
||
|
|
|
||
|
|
entry = adapter._send_cache.get("msg_abc")
|
||
|
|
assert entry is not None
|
||
|
|
assert entry.status == "sent"
|
||
|
|
|
||
|
|
assert "msg_abc" in adapter._bot_message_ids
|
||
|
|
|
||
|
|
|
||
|
|
# ==================== SYSTEM_EVENT Handler Tests ====================
|
||
|
|
|
||
|
|
|
||
|
|
class TestSystemEventHandler:
|
||
|
|
@pytest.fixture
|
||
|
|
def adapter(self):
|
||
|
|
return _make_adapter()
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_handle_system_event_typing(self, adapter):
|
||
|
|
event = {
|
||
|
|
"type": "typing",
|
||
|
|
"open_id": "user_123",
|
||
|
|
"group_open_id": "group_abc",
|
||
|
|
}
|
||
|
|
await adapter._handle_system_event(event)
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_handle_system_event_unknown(self, adapter):
|
||
|
|
event = {
|
||
|
|
"type": "some_custom_event",
|
||
|
|
"open_id": "user_123",
|
||
|
|
}
|
||
|
|
await adapter._handle_system_event(event)
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_handle_ws_event_dispatches_system_event(self, adapter):
|
||
|
|
event = {
|
||
|
|
"type": "typing",
|
||
|
|
"msg_id": "msg_typing_001",
|
||
|
|
"open_id": "user_123",
|
||
|
|
}
|
||
|
|
adapter._seen_message_ids.clear()
|
||
|
|
adapter._handle_system_event = AsyncMock()
|
||
|
|
|
||
|
|
await adapter._handle_ws_event(event)
|
||
|
|
|
||
|
|
adapter._handle_system_event.assert_called_once_with(event)
|
||
|
|
|
||
|
|
|
||
|
|
# ==================== LOCATION Type Mapping Tests ====================
|
||
|
|
|
||
|
|
|
||
|
|
class TestLocationTypeMapping:
|
||
|
|
def test_map_message_type_includes_location(self):
|
||
|
|
result = _map_message_type(MessageType.LOCATION, "")
|
||
|
|
assert result == "location"
|
||
|
|
|
||
|
|
def test_format_outbound_with_location_type(self):
|
||
|
|
identity = _make_identity(channel_chat_id="user_001")
|
||
|
|
response = ChannelResponse(
|
||
|
|
identity=identity,
|
||
|
|
content="Location share",
|
||
|
|
message_type=MessageType.LOCATION,
|
||
|
|
)
|
||
|
|
payload = format_outbound(response)
|
||
|
|
assert payload["msg_type"] == "location"
|
||
|
|
assert payload["content"] == "Location share"
|
||
|
|
|
||
|
|
|
||
|
|
# ==================== Dispatch Classification Tests ====================
|
||
|
|
|
||
|
|
|
||
|
|
class TestDispatchClassificationOptimization:
|
||
|
|
def test_channel_post_classified_as_message(self):
|
||
|
|
event = {
|
||
|
|
"type": "channel_post",
|
||
|
|
"open_id": "user_123",
|
||
|
|
"channel_id": "ch_001",
|
||
|
|
"content": "Channel broadcast",
|
||
|
|
}
|
||
|
|
action = InteractiveDispatcher.classify_event(event)
|
||
|
|
assert action == DispatchAction.MESSAGE
|
||
|
|
|
||
|
|
def test_edited_channel_post_classified_as_message(self):
|
||
|
|
event = {
|
||
|
|
"type": "edited_channel_post",
|
||
|
|
"open_id": "user_123",
|
||
|
|
"channel_id": "ch_001",
|
||
|
|
"content": "Edited channel broadcast",
|
||
|
|
}
|
||
|
|
action = InteractiveDispatcher.classify_event(event)
|
||
|
|
assert action == DispatchAction.MESSAGE
|
||
|
|
|
||
|
|
def test_normal_message_still_classified_correctly(self):
|
||
|
|
event = {
|
||
|
|
"type": "message",
|
||
|
|
"open_id": "user_123",
|
||
|
|
"content": "Hello",
|
||
|
|
}
|
||
|
|
action = InteractiveDispatcher.classify_event(event)
|
||
|
|
assert action == DispatchAction.MESSAGE
|
||
|
|
|
||
|
|
def test_command_still_classified_correctly(self):
|
||
|
|
event = {
|
||
|
|
"type": "message",
|
||
|
|
"open_id": "user_123",
|
||
|
|
"content": "/start",
|
||
|
|
}
|
||
|
|
action = InteractiveDispatcher.classify_event(event)
|
||
|
|
assert action == DispatchAction.COMMAND
|
||
|
|
|
||
|
|
def test_edited_message_still_classified_correctly(self):
|
||
|
|
event = {
|
||
|
|
"type": "edited_message",
|
||
|
|
"open_id": "user_123",
|
||
|
|
"content": "Updated",
|
||
|
|
}
|
||
|
|
action = InteractiveDispatcher.classify_event(event)
|
||
|
|
assert action == DispatchAction.MESSAGE
|
||
|
|
|
||
|
|
def test_reaction_still_classified_correctly(self):
|
||
|
|
event = {"type": "reaction_added", "open_id": "user_123"}
|
||
|
|
action = InteractiveDispatcher.classify_event(event)
|
||
|
|
assert action == DispatchAction.REACTION
|
||
|
|
|
||
|
|
def test_bot_menu_still_classified_correctly(self):
|
||
|
|
event = {"type": "bot_menu", "open_id": "user_123"}
|
||
|
|
action = InteractiveDispatcher.classify_event(event)
|
||
|
|
assert action == DispatchAction.BOT_MENU
|
||
|
|
|
||
|
|
def test_member_event_still_classified_correctly(self):
|
||
|
|
event = {"type": "member_joined", "open_id": "user_123"}
|
||
|
|
action = InteractiveDispatcher.classify_event(event)
|
||
|
|
assert action == DispatchAction.MEMBER_EVENT
|
||
|
|
|
||
|
|
def test_card_action_still_classified_correctly(self):
|
||
|
|
event = {"type": "card_action", "open_id": "user_123"}
|
||
|
|
action = InteractiveDispatcher.classify_event(event)
|
||
|
|
assert action == DispatchAction.CARD_ACTION
|
||
|
|
|
||
|
|
def test_read_receipt_still_classified_correctly(self):
|
||
|
|
event = {"type": "read_receipt", "open_id": "user_123"}
|
||
|
|
action = InteractiveDispatcher.classify_event(event)
|
||
|
|
assert action == DispatchAction.READ_RECEIPT
|
||
|
|
|
||
|
|
def test_unknown_event_type(self):
|
||
|
|
event = {"type": "unknown_type", "open_id": "user_123"}
|
||
|
|
action = InteractiveDispatcher.classify_event(event)
|
||
|
|
assert action == DispatchAction.UNKNOWN
|