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 == "" 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"