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

602 lines
24 KiB
Python

from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from yuxi.channels.adapters.imessage.adapter import IMessageAdapter
from yuxi.channels.adapters.imessage.session import resolve_chat_type, resolve_thread_key
from yuxi.channels.adapters.imessage.tapback import (
describe_tapback,
emoji_to_tapback,
is_valid_tapback,
resolve_tapback_value,
tapback_to_emoji,
)
from yuxi.channels.models import (
ChannelIdentity,
ChannelMessage,
ChannelResponse,
ChannelType,
ChatType,
DeliveryResult,
EventType,
MessageType,
)
def make_dm_event(text="Hello world", sender="+8613800138000", chat_guid="iMessage;+;chat001", msg_guid="msg_001"):
return {
"type": "new-message",
"data": {
"chatGuid": chat_guid,
"guid": msg_guid,
"text": text,
"sender": sender,
"isFromMe": False,
"attachments": [],
"dateCreated": 1700000000000,
},
}
def make_group_event(text="Hello group", sender="+8613800138000", chat_guid="iMessage;-;group001", msg_guid="msg_003"):
return {
"type": "new-message",
"data": {
"chatGuid": chat_guid,
"guid": msg_guid,
"text": text,
"sender": sender,
"isFromMe": False,
"attachments": [],
"dateCreated": 1700000000000,
},
}
def make_image_event(chat_guid="iMessage;+;chat001", msg_guid="msg_002"):
return {
"type": "new-message",
"data": {
"chatGuid": chat_guid,
"guid": msg_guid,
"text": "Check this photo",
"sender": "+8613800138000",
"isFromMe": False,
"attachments": [
{
"mimeType": "image/jpeg",
"url": "/media/img001.jpg",
"transferName": "photo.jpg",
"totalBytes": 1024000,
}
],
"dateCreated": 1700000000000,
},
}
def make_response(chat_guid="iMessage;+;chat001", content="Hello from ForcePilot"):
return ChannelResponse(
identity=ChannelIdentity(
channel_id="imessage",
channel_type=ChannelType.IMESSAGE,
channel_user_id="+8613800138000",
channel_chat_id=chat_guid,
channel_message_id="msg_001",
),
content=content,
)
class TestIMessageSession:
def test_resolve_direct_chat(self):
assert resolve_chat_type("iMessage;+;chat001") == ChatType.DIRECT
def test_resolve_group_chat_separator(self):
assert resolve_chat_type("iMessage;-;group001") == ChatType.GROUP
def test_resolve_group_chat_suffix(self):
assert resolve_chat_type("chat123@chat") == ChatType.GROUP
def test_resolve_thread_key_direct(self):
identity = ChannelIdentity(
channel_id="imessage",
channel_type=ChannelType.IMESSAGE,
channel_user_id="+8613800138000",
channel_chat_id="iMessage;+;chat001",
)
key = resolve_thread_key(identity)
assert "dm" in key
assert "8613800138000" in key
def test_resolve_thread_key_group(self):
identity = ChannelIdentity(
channel_id="imessage",
channel_type=ChannelType.IMESSAGE,
channel_user_id="+8613800138000",
channel_chat_id="iMessage;-;group001",
)
key = resolve_thread_key(identity)
assert "group" in key
assert "group001" in key
class TestIMessageTapback:
def test_emoji_to_tapback_known(self):
assert emoji_to_tapback("❤️") == "love"
assert emoji_to_tapback("👍") == "like"
assert emoji_to_tapback("👎") == "dislike"
assert emoji_to_tapback("😂") == "laugh"
assert emoji_to_tapback("") == "exclaim"
assert emoji_to_tapback("") == "question"
def test_emoji_to_tapback_unknown(self):
assert emoji_to_tapback("🚀") is None
def test_resolve_tapback_value(self):
assert resolve_tapback_value("love") == 0
assert resolve_tapback_value("heart") == 0
assert resolve_tapback_value("like") == 1
assert resolve_tapback_value("thumbs_up") == 1
assert resolve_tapback_value("dislike") == 2
assert resolve_tapback_value("thumbs_down") == 2
assert resolve_tapback_value("laugh") == 3
assert resolve_tapback_value("exclaim") == 4
assert resolve_tapback_value("question") == 5
def test_resolve_tapback_value_case_insensitive(self):
assert resolve_tapback_value("LOVE") == 0
assert resolve_tapback_value("Love") == 0
def test_resolve_tapback_value_unknown(self):
assert resolve_tapback_value("rocket") is None
assert resolve_tapback_value("fire") is None
def test_tapback_to_emoji(self):
assert tapback_to_emoji(0) == "❤️"
assert tapback_to_emoji(1) == "👍"
assert tapback_to_emoji(2) == "👎"
assert tapback_to_emoji(5) == ""
def test_tapback_to_emoji_invalid(self):
assert tapback_to_emoji(99) is None
def test_describe_tapback(self):
assert "Loved" in describe_tapback(0)
assert "Liked" in describe_tapback(1)
assert "Laughed" in describe_tapback(3)
def test_describe_tapback_unknown(self):
assert describe_tapback(99) == "Reacted"
def test_is_valid_tapback(self):
assert is_valid_tapback("❤️") is True
assert is_valid_tapback("👍") is True
assert is_valid_tapback("🚀") is False
class TestIMessageAdapter:
@pytest.fixture
def adapter(self):
config = {
"server_url": "http://localhost:1234",
"password": "test-password",
"name": "test-imessage",
"group_allow_from": ["iMessage;-;group001"],
}
return IMessageAdapter(config)
def test_channel_id(self, adapter):
assert adapter.channel_id == "imessage"
def test_channel_type(self, adapter):
assert adapter.channel_type == ChannelType.IMESSAGE
def test_capabilities(self, adapter):
assert adapter.supports_streaming is True
assert adapter.supports_markdown is True
assert "off" in adapter.streaming_modes
assert "partial" in adapter.streaming_modes
assert "block" in adapter.streaming_modes
assert adapter.text_chunk_limit == 4096
assert adapter.max_media_size_mb == 100
async def test_normalize_dm_message(self, adapter):
event = make_dm_event()
result = await adapter.normalize_inbound(event)
assert isinstance(result, ChannelMessage)
assert result.message_type == MessageType.TEXT
assert result.content == "Hello world"
assert result.identity.channel_id == "imessage"
assert result.identity.channel_type == ChannelType.IMESSAGE
assert result.identity.channel_user_id == "8613800138000"
assert result.identity.channel_chat_id == "iMessage;+;chat001"
assert result.identity.channel_message_id == "msg_001"
assert result.chat_type == ChatType.DIRECT
assert result.event_type == EventType.MESSAGE_RECEIVED
assert result.metadata["is_from_me"] is False
assert result.metadata["sender_handle"] == "+8613800138000"
async def test_normalize_image_message(self, adapter):
event = make_image_event()
adapter._include_attachments = True
result = await adapter.normalize_inbound(event)
assert result.message_type == MessageType.IMAGE
assert len(result.attachments) == 1
assert result.attachments[0].mime_type == "image/jpeg"
assert result.attachments[0].filename == "photo.jpg"
assert result.attachments[0].size_bytes == 1024000
async def test_normalize_group_message(self, adapter):
event = make_group_event()
result = await adapter.normalize_inbound(event)
assert result.chat_type == ChatType.GROUP
assert result.content == "Hello group"
assert result.identity.channel_user_id == "8613800138000"
async def test_normalize_self_sent_message(self, adapter):
event = make_dm_event()
event["data"]["isFromMe"] = True
result = await adapter.normalize_inbound(event)
assert result.content == "(self-sent message)"
assert result.metadata["is_from_me"] is True
async def test_normalize_typing_event(self, adapter):
event = {
"type": "typing-indicator",
"data": {"chatGuid": "iMessage;+;chat001", "senderGuid": "+8613800138000", "display": True},
}
result = await adapter.normalize_inbound(event)
assert result.event_type == EventType.TYPING
assert result.metadata["display"] is True
async def test_normalize_read_receipt_event(self, adapter):
event = {
"type": "chat-read-receipt",
"data": {"chatGuid": "iMessage;+;chat001", "senderGuid": "+8613800138000"},
}
result = await adapter.normalize_inbound(event)
assert result.event_type == EventType.READ_RECEIPT
async def test_normalize_chat_name_change(self, adapter):
event = {
"type": "chat-name-change",
"data": {"chatGuid": "iMessage;-;group001", "newName": "New Group Name"},
}
result = await adapter.normalize_inbound(event)
assert result.metadata["event"] == "chat_name_change"
assert result.metadata["new_name"] == "New Group Name"
async def test_normalize_message_without_text_has_attachments(self, adapter):
event = make_dm_event(text="")
event["data"]["attachments"] = [
{"mimeType": "image/png", "url": "/img/test.png", "transferName": "test.png", "totalBytes": 500}
]
adapter._include_attachments = True
result = await adapter.normalize_inbound(event)
assert result.message_type == MessageType.IMAGE
assert result.content == "<media:image>"
assert len(result.attachments) == 1
def test_format_outbound_text(self, adapter):
response = make_response(content="Hello from ForcePilot")
result = adapter.format_outbound(response)
assert result["chatGuid"] == "iMessage;+;chat001"
assert result["content"] == "Hello from ForcePilot"
def test_format_outbound_with_reply(self, adapter):
response = make_response(content="Reply text")
response.reply_to_message_id = "msg_reply"
result = adapter.format_outbound(response)
assert result["replyTo"] == "msg_reply"
def test_clean_handle(self, adapter):
assert adapter._clean_handle(" +8613800138000 ") == "8613800138000"
assert adapter._clean_handle("+12345") == "12345"
assert adapter._clean_handle("test@example.com") == "test@example.com"
def test_map_attachment_type(self, adapter):
assert adapter._map_attachment_type("image/jpeg") == MessageType.IMAGE
assert adapter._map_attachment_type("video/mp4") == MessageType.VIDEO
assert adapter._map_attachment_type("audio/mp3") == MessageType.AUDIO
assert adapter._map_attachment_type("application/pdf") == MessageType.FILE
def test_map_event_type(self, adapter):
assert adapter._map_event_type("new-message") == EventType.MESSAGE_RECEIVED
assert adapter._map_event_type("message-updated") == EventType.MESSAGE_UPDATED
assert adapter._map_event_type("message-deleted") == EventType.MESSAGE_DELETED
assert adapter._map_event_type("typing-indicator") == EventType.TYPING
assert adapter._map_event_type("read-receipt") == EventType.READ_RECEIPT
assert adapter._map_event_type("unknown-type") == EventType.MESSAGE_RECEIVED
async def test_normalize_unknown_event_type(self, adapter):
event = {
"type": "server-event",
"data": {"chatGuid": "unknown", "guid": "evt_001", "sender": "system", "isFromMe": False},
}
result = await adapter.normalize_inbound(event)
assert result.event_type == EventType.MESSAGE_RECEIVED
async def test_normalize_message_deleted_event(self, adapter):
event = {
"type": "message-deleted",
"data": {"chatGuid": "iMessage;+;chat001", "guid": "msg_deleted_001"},
}
result = await adapter.normalize_inbound(event)
assert result.event_type == EventType.MESSAGE_DELETED
assert result.metadata["event"] == "message_deleted"
assert result.metadata["deleted_guid"] == "msg_deleted_001"
async def test_normalize_participant_added_event(self, adapter):
event = {
"type": "participant-added",
"data": {"chatGuid": "iMessage;-;group001", "participant": "+8613900139000"},
}
result = await adapter.normalize_inbound(event)
assert result.event_type == EventType.MEMBER_JOINED
assert result.metadata["event"] == "member_joined"
assert result.metadata["participant"] == "+8613900139000"
async def test_normalize_participant_removed_event(self, adapter):
event = {
"type": "participant-removed",
"data": {"chatGuid": "iMessage;-;group001", "participant": "+8613900139000"},
}
result = await adapter.normalize_inbound(event)
assert result.event_type == EventType.MEMBER_LEFT
assert result.metadata["event"] == "member_left"
assert result.metadata["participant"] == "+8613900139000"
@pytest.mark.asyncio
async def test_send_reaction_valid(self, adapter):
mock_result = DeliveryResult(success=True, message_id="msg_001")
adapter._primary_bridge = AsyncMock()
adapter._primary_bridge.send_reaction = AsyncMock(return_value=mock_result)
result = await adapter.send_reaction("iMessage;+;chat001", "msg_001", "love")
assert result.success is True
@pytest.mark.asyncio
async def test_send_reaction_invalid(self, adapter):
result = await adapter.send_reaction("iMessage;+;chat001", "msg_001", "🚀")
assert result.success is False
assert "Unsupported" in result.error
@pytest.mark.asyncio
async def test_send_reaction_by_emoji(self, adapter):
mock_result = DeliveryResult(success=True, message_id="msg_001")
adapter._primary_bridge = AsyncMock()
adapter._primary_bridge.send_reaction = AsyncMock(return_value=mock_result)
result = await adapter.send_reaction("iMessage;+;chat001", "msg_001", "👍")
assert result.success is True
@pytest.mark.asyncio
async def test_health_check_healthy(self, adapter):
adapter._primary_bridge = AsyncMock()
adapter._primary_bridge.probe_server = AsyncMock(return_value={"version": "1.0"})
adapter._primary_bridge.get_handle = AsyncMock(return_value={"connected": True, "handle": "test@icloud.com"})
result = await adapter.health_check()
assert result.status == "healthy"
assert result.metadata["handle"] == "test@icloud.com"
@pytest.mark.asyncio
async def test_health_check_degraded(self, adapter):
adapter._primary_bridge = AsyncMock()
adapter._primary_bridge.probe_server = AsyncMock(return_value={"version": "1.0"})
adapter._primary_bridge.get_handle = AsyncMock(return_value={"connected": False})
result = await adapter.health_check()
assert result.status == "degraded"
@pytest.mark.asyncio
async def test_health_check_unhealthy(self, adapter):
adapter._primary_bridge = AsyncMock()
adapter._primary_bridge.probe_server = AsyncMock(side_effect=Exception("Connection refused"))
result = await adapter.health_check()
assert result.status == "unhealthy"
@pytest.mark.asyncio
async def test_send_text_message(self, adapter):
mock_result = DeliveryResult(success=True, message_id="sent_001")
adapter._primary_bridge = AsyncMock()
adapter._primary_bridge.send_text_message = AsyncMock(return_value=mock_result)
response = make_response()
result = await adapter.send(response)
assert result.success is True
assert result.message_id == "sent_001"
assert adapter._pending_messages["sent_001"] == "Hello from ForcePilot"
@pytest.mark.asyncio
async def test_send_typing_indicator(self, adapter):
mock_result = DeliveryResult(success=True)
adapter._primary_bridge = AsyncMock()
adapter._primary_bridge.send_typing_indicator = AsyncMock(return_value=mock_result)
result = await adapter.send_typing_indicator("iMessage;+;chat001", display=True)
assert result.success is True
adapter._primary_bridge.send_typing_indicator.assert_called_once_with("iMessage;+;chat001", True)
@pytest.mark.asyncio
async def test_send_read_receipt(self, adapter):
mock_result = DeliveryResult(success=True)
adapter._primary_bridge = AsyncMock()
adapter._primary_bridge.send_read_receipt = AsyncMock(return_value=mock_result)
result = await adapter.send_read_receipt("iMessage;+;chat001")
assert result.success is True
adapter._primary_bridge.send_read_receipt.assert_called_once_with("iMessage;+;chat001")
@pytest.mark.asyncio
async def test_edit_message(self, adapter):
mock_result = DeliveryResult(success=True, message_id="msg_001")
adapter._primary_bridge = AsyncMock()
adapter._primary_bridge.edit_message = AsyncMock(return_value=mock_result)
result = await adapter.edit_message("chat_001", "msg_001", "Edited text")
assert result.success is True
assert adapter._pending_messages["msg_001"] == "Edited text"
@pytest.mark.asyncio
async def test_send_stream_chunk_finished(self, adapter):
mock_result = DeliveryResult(success=True, message_id="msg_001")
adapter._primary_bridge = AsyncMock()
adapter._primary_bridge.edit_message = AsyncMock(return_value=mock_result)
adapter._pending_messages["msg_001"] = "partial text"
result = await adapter.send_stream_chunk("chat_001", "msg_001", "final text", finished=True)
assert result.success is True
assert "msg_001" not in adapter._pending_messages
@pytest.mark.asyncio
async def test_send_stream_chunk_accumulating(self, adapter):
mock_result = DeliveryResult(success=True, message_id="msg_001")
adapter._primary_bridge = AsyncMock()
adapter._primary_bridge.edit_message = AsyncMock(return_value=mock_result)
result = await adapter.send_stream_chunk("chat_001", "msg_001", "hello", finished=False)
assert result.success is True
assert adapter._pending_messages["msg_001"] == "hello"
await adapter.send_stream_chunk("chat_001", "msg_001", " world", finished=False)
assert adapter._pending_messages["msg_001"] == "hello world"
@pytest.mark.asyncio
async def test_receive_returns_empty(self, adapter):
messages = []
async for msg in adapter.receive():
messages.append(msg)
assert len(messages) == 0
async def test_normalize_tapback_uses_module_function(self, adapter):
event = {
"type": "new-message",
"data": {
"chatGuid": "iMessage;+;chat001",
"guid": "msg_tapback",
"sender": "+8613800138000",
"isFromMe": False,
"isTapback": True,
"tapback": 0,
"attachments": [],
},
}
result = await adapter.normalize_inbound(event)
assert "Loved" in result.content
assert result.metadata["event"] == "tapback"
assert result.metadata["tapback_value"] == 0
class TestBlueBubblesClient:
@pytest.fixture
def client(self):
from yuxi.channels.adapters.imessage.bridge_client import BlueBubblesClient
return BlueBubblesClient({
"server_url": "http://localhost:1234",
"password": "test-password",
})
def test_base_url(self, client):
assert client._server_url == "http://localhost:1234"
def test_base_url_strips_trailing_slash(self):
from yuxi.channels.adapters.imessage.bridge_client import BlueBubblesClient
c = BlueBubblesClient({"server_url": "http://localhost:1234/", "password": "pw"})
assert c._server_url == "http://localhost:1234"
def test_ws_url_http(self, client):
assert client.ws_url == "ws://localhost:1234/ws"
def test_ws_url_https(self):
from yuxi.channels.adapters.imessage.bridge_client import BlueBubblesClient
c = BlueBubblesClient({"server_url": "https://mac.local:1234", "password": "pw"})
assert c.ws_url == "wss://mac.local:1234/ws"
@pytest.mark.asyncio
async def test_context_manager(self, client):
async with client as c:
assert c._session is not None
assert client._session is None
@pytest.mark.asyncio
@pytest.mark.skip(reason="Requires proper async context manager mock setup")
async def test_query_contacts(self, client):
mock_resp = AsyncMock()
mock_resp.json = AsyncMock(return_value={"data": [
{"address": "+8613800138000", "displayName": "Test User"}
]})
mock_resp.status = 200
client._session = AsyncMock()
client._session.post = AsyncMock(return_value=mock_resp)
result = await client.query_contacts(["+8613800138000"])
assert len(result) == 1
assert result[0]["displayName"] == "Test User"
def test_download_attachment_url_validation_same_origin(self):
from yuxi.channels.adapters.imessage.bridge_client import BlueBubblesClient
c = BlueBubblesClient({"server_url": "http://localhost:1234", "password": "pw"})
assert c._server_url == "http://localhost:1234"
def test_download_attachment_relative_path(self):
from yuxi.channels.adapters.imessage.bridge_client import BlueBubblesClient
c = BlueBubblesClient({"server_url": "http://localhost:1234", "password": "pw"})
c._session = MagicMock()
url = c._server_url + "/" + "/media/test.jpg".lstrip("/")
assert url == "http://localhost:1234/media/test.jpg"
class TestIMessageMonitor:
def test_monitor_creation(self):
from yuxi.channels.adapters.imessage.monitor import IMessageMonitor
from yuxi.channels.adapters.imessage.bridge_client import BlueBubblesClient
bridge = BlueBubblesClient({"server_url": "http://localhost:1234", "password": "pw"})
monitor = IMessageMonitor({"name": "test"}, bridge)
assert monitor._message_handler is None
def test_monitor_on_message(self):
from yuxi.channels.adapters.imessage.monitor import IMessageMonitor
from yuxi.channels.adapters.imessage.bridge_client import BlueBubblesClient
bridge = BlueBubblesClient({"server_url": "http://localhost:1234", "password": "pw"})
async def handler(data):
pass
monitor = IMessageMonitor({"name": "test"}, bridge)
monitor.on_message(handler)
assert monitor._message_handler is not None
class TestIMessageProbe:
@pytest.mark.asyncio
async def test_probe_bridge(self):
from yuxi.channels.adapters.imessage.probe import probe_bridge
mock_bridge = AsyncMock()
mock_bridge.probe_server = AsyncMock(return_value={"version": "1.5.0", "os_version": "15.0"})
mock_bridge.get_handle = AsyncMock(return_value={"connected": True, "handle": "test@icloud.com"})
result = await probe_bridge(mock_bridge)
assert result["bridge_version"] == "1.5.0"
assert result["imessage_connected"] is True
assert result["imessage_handle"] == "test@icloud.com"