from __future__ import annotations from unittest.mock import AsyncMock, patch import pytest from yuxi.channels.adapters.bluebubbles.adapter import BlueBubblesAdapter from yuxi.channels.adapters.bluebubbles.config import BlueBubblesConfig from yuxi.channels.adapters.bluebubbles.session import ( build_thread_key, extract_chat_display_name, resolve_chat_type, ) from yuxi.channels.adapters.bluebubbles.send import resolve_tapback_value, TAPBACK_MAP from yuxi.channels.models import ( Attachment, ChannelIdentity, ChannelMessage, ChannelResponse, ChannelStatus, ChannelType, ChatType, EventType, MessageType, ) def make_dm_event(text="Hello world", sender="+8613800138000", chat_guid="iMessage;+;chat001", msg_guid="msg_001"): return { "type": "new-message", "data": { "chat": {"guid": chat_guid, "isGroup": False, "displayName": "John"}, "message": { "guid": msg_guid, "text": text, "sender": {"address": sender}, "attachments": [], }, }, } def make_group_event(text="Hello group", sender="+8613800138000", chat_guid="iMessage;-;group001", msg_guid="msg_003"): return { "type": "new-message", "data": { "chat": { "guid": chat_guid, "isGroup": True, "displayName": "ForcePilot Dev", "participants": [ {"address": "+8613800138000"}, {"address": "+8613900139000"}, ], }, "message": { "guid": msg_guid, "text": text, "sender": {"address": sender}, "attachments": [], }, }, } def make_image_event(chat_guid="iMessage;+;chat001", msg_guid="msg_002"): return { "type": "new-message", "data": { "chat": {"guid": chat_guid, "isGroup": False}, "message": { "guid": msg_guid, "text": "Check this photo", "sender": {"address": "+8613800138000"}, "attachments": [ { "mimeType": "image/jpeg", "filePath": "/media/img001.jpg", "fileName": "photo.jpg", "fileSize": 1024000, } ], }, }, } def make_response(chat_guid="iMessage;+;chat001", content="Hello from ForcePilot", msg_type=MessageType.TEXT): return ChannelResponse( identity=ChannelIdentity( channel_id="bluebubbles", channel_type=ChannelType.BLUEBUBBLES, channel_user_id="+8613800138000", channel_chat_id=chat_guid, channel_message_id="msg_001", ), message_type=msg_type, content=content, ) class TestBlueBubblesSession: def test_resolve_direct_chat(self): assert resolve_chat_type("iMessage;+;chat001") == ChatType.DIRECT def test_resolve_group_chat(self): assert resolve_chat_type("iMessage;-;group001") == ChatType.GROUP def test_extract_chat_display_name(self): chat_data = {"displayName": "John Doe"} assert extract_chat_display_name(chat_data) == "John Doe" def test_extract_chat_display_name_missing(self): assert extract_chat_display_name({}) == "" def test_build_thread_key_direct(self): key = build_thread_key("agent_001", "iMessage;+;chat001") assert "direct" in key assert "chat001" in key def test_build_thread_key_group(self): key = build_thread_key("agent_001", "iMessage;-;group001") assert "group" in key assert "group001" in key class TestBlueBubblesSend: def test_tapback_map_complete(self): assert len(TAPBACK_MAP) == 65 assert TAPBACK_MAP["love"] == 0 assert TAPBACK_MAP["heart"] == 0 assert TAPBACK_MAP["like"] == 1 assert TAPBACK_MAP["thumbs_up"] == 1 assert TAPBACK_MAP["dislike"] == 2 assert TAPBACK_MAP["thumbs_down"] == 2 assert TAPBACK_MAP["laugh"] == 3 assert TAPBACK_MAP["exclaim"] == 4 assert TAPBACK_MAP["question"] == 5 assert TAPBACK_MAP["haha"] == 3 assert "celebration" not in TAPBACK_MAP def test_resolve_tapback_value_known(self): assert resolve_tapback_value("love") == 0 assert resolve_tapback_value("heart") == 0 assert resolve_tapback_value("like") == 1 assert resolve_tapback_value("dislike") == 2 assert resolve_tapback_value("laugh") == 3 assert resolve_tapback_value("exclaim") == 4 assert resolve_tapback_value("question") == 5 def test_resolve_tapback_value_unknown(self): assert resolve_tapback_value("rocket") is None assert resolve_tapback_value("fire") is None class TestBlueBubblesConfig: def test_default_config(self): config = BlueBubblesConfig() assert config.enabled is False assert config.server_url == "http://localhost:1234" assert config.dm_policy == "pairing" assert config.group_policy == "allowlist" assert config.streaming_mode == "partial" def test_from_env(self): with patch.dict("os.environ", { "BLUEBUBBLES_SERVER_URL": "http://mac.local:1234", "BLUEBUBBLES_SERVER_PASSWORD": "secret123", }): config = BlueBubblesConfig.from_env() assert config.server_url == "http://mac.local:1234" assert config.password == "secret123" def test_from_env_full(self): with patch.dict("os.environ", { "BLUEBUBBLES_ENABLED": "true", "BLUEBUBBLES_SERVER_URL": "http://mac.local:1234", "BLUEBUBBLES_SERVER_PASSWORD": "s3cret", "BLUEBUBBLES_REQUEST_TIMEOUT": "15.0", "BLUEBUBBLES_RECONNECT_MAX_DELAY": "120.0", "BLUEBUBBLES_DM_POLICY": "allowlist", "BLUEBUBBLES_GROUP_POLICY": "blocklist", "BLUEBUBBLES_DM_ALLOW_FROM": "+8613800138000,+8613900139000", "BLUEBUBBLES_GROUP_ALLOW_CHATS": "chat1,chat2", "BLUEBUBBLES_MEDIA_MAX_MB": "50", "BLUEBUBBLES_ENABLE_STICKERS": "false", "BLUEBUBBLES_STREAMING_MODE": "block", "BLUEBUBBLES_EDIT_INTERVAL_MS": "1000", "BLUEBUBBLES_TAPBACK_ENABLED": "false", }): config = BlueBubblesConfig.from_env() assert config.enabled is True assert config.server_url == "http://mac.local:1234" assert config.password == "s3cret" assert config.request_timeout == 15.0 assert config.reconnect_max_delay == 120.0 assert config.dm_policy == "allowlist" assert config.group_policy == "blocklist" assert config.dm_allow_from == ["+8613800138000", "+8613900139000"] assert config.group_allow_chats == ["chat1", "chat2"] assert config.media_max_mb == 50 assert config.enable_stickers is False assert config.streaming_mode == "block" assert config.edit_interval_ms == 1000 assert config.tapback_enabled is False class TestBlueBubblesAdapter: @pytest.fixture def adapter(self): config = { "server_url": "http://localhost:1234", "password": "test-password", } return BlueBubblesAdapter(config) def test_channel_id(self, adapter): assert adapter.channel_id == "bluebubbles" def test_channel_type(self, adapter): assert adapter.channel_type == ChannelType.BLUEBUBBLES 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 adapter.text_chunk_limit == 4096 assert adapter.max_media_size_mb == 100 def test_normalize_dm_message(self, adapter): event = make_dm_event() result = adapter.normalize_inbound(event) assert isinstance(result, ChannelMessage) assert result.message_type == MessageType.TEXT assert result.content == "Hello world" assert result.identity.channel_id == "bluebubbles" assert result.identity.channel_type == ChannelType.BLUEBUBBLES 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.metadata["is_group"] is False assert result.metadata["chat_display_name"] == "John" def test_normalize_image_message(self, adapter): event = make_image_event() result = 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 def test_normalize_group_message(self, adapter): event = make_group_event() result = adapter.normalize_inbound(event) assert result.chat_type == ChatType.GROUP assert result.metadata["is_group"] is True assert result.metadata["group_name"] == "ForcePilot Dev" assert len(result.metadata["group_participants"]) == 2 def test_normalize_follow_event(self, adapter): event = {"type": "join", "data": { "chatGuid": "iMessage;-;group001", "chat": {"guid": "iMessage;-;group001", "isGroup": True}, "message": {"guid": "msg_001", "sender": {"address": "+8613800138000"}}, }} result = adapter.normalize_inbound(event) assert result.chat_type == ChatType.GROUP def test_normalize_typing_event(self, adapter): event = { "type": "typing-indicator", "data": {"chatGuid": "iMessage;+;chat001", "senderGuid": "+8613800138000", "display": True}, } result = adapter.normalize_inbound(event) assert result.metadata["event"] == "typing" assert result.metadata["display"] is True def test_normalize_chat_name_change(self, adapter): event = { "type": "chat-name-change", "data": {"chatGuid": "iMessage;-;group001", "newName": "New Group Name"}, } result = adapter.normalize_inbound(event) assert result.metadata["event"] == "chat_name_change" assert result.metadata["new_name"] == "New Group Name" def test_normalize_read_receipt(self, adapter): event = { "type": "chat-read-receipt", "data": {"chatGuid": "iMessage;+;chat001", "senderGuid": "+8613800138000"}, } result = adapter.normalize_inbound(event) assert result.event_type == EventType.MESSAGE_UPDATED def test_format_outbound_text(self, adapter): response = make_response(content="Hello from ForcePilot") result = adapter.format_outbound(response) assert result["endpoint"] == "/api/v1/message/text" assert result["payload"]["chatGuid"] == "iMessage;+;chat001" assert result["payload"]["text"] == "Hello from ForcePilot" def test_format_outbound_image(self, adapter): response = make_response(msg_type=MessageType.IMAGE, content="Photo caption") result = adapter.format_outbound(response) assert result["endpoint"] == "/api/v1/message/attachment" assert result["payload"]["chatGuid"] == "iMessage;+;chat001" assert result["payload"]["caption"] == "Photo caption" def test_format_outbound_video(self, adapter): response = make_response(msg_type=MessageType.VIDEO, content="Video caption") result = adapter.format_outbound(response) assert result["endpoint"] == "/api/v1/message/attachment" def test_format_outbound_file(self, adapter): response = make_response(msg_type=MessageType.FILE) result = adapter.format_outbound(response) assert result["endpoint"] == "/api/v1/message/attachment" @pytest.mark.asyncio async def test_health_check_healthy(self, adapter): mock_get = AsyncMock(return_value={ "status": "ok", "data": {"iMessageConnected": True}, }) adapter._client = AsyncMock() adapter._client.get = mock_get result = await adapter.health_check() assert result.status == "healthy" @pytest.mark.asyncio async def test_health_check_imessage_disconnected(self, adapter): mock_get = AsyncMock(return_value={ "status": "ok", "data": {"iMessageConnected": False}, }) adapter._client = AsyncMock() adapter._client.get = mock_get result = await adapter.health_check() assert result.status == "degraded" assert "iMessage not connected" in result.last_error @pytest.mark.asyncio async def test_health_check_server_unhealthy(self, adapter): mock_get = AsyncMock(return_value={ "status": "error", "data": {"iMessageConnected": False}, }) adapter._client = AsyncMock() adapter._client.get = mock_get result = await adapter.health_check() assert result.status == "unhealthy" def test_normalize_with_reply_to(self, adapter): event = make_dm_event(text="Reply text") event["data"]["message"]["replyToGuid"] = "msg_original" event["data"]["replyToGuid"] = "msg_original" result = adapter.normalize_inbound(event) assert result.metadata["reply_to_guid"] == "msg_original" def test_normalize_unknown_event_type(self, adapter): event = { "type": "server-event", "data": {"event": "started", "chatGuid": "unknown"}, } result = adapter.normalize_inbound(event) assert result.content == "" assert result.metadata["event"] == "server-event" @pytest.mark.asyncio async def test_send_media_missing_attachment(self, adapter): response = make_response(msg_type=MessageType.IMAGE, content="") adapter._client = AsyncMock() result = await adapter.send(response) assert result.success is False assert "Missing attachment" in result.error @pytest.mark.asyncio async def test_send_media_no_url(self, adapter): response = ChannelResponse( identity=ChannelIdentity( channel_id="bluebubbles", channel_type=ChannelType.BLUEBUBBLES, channel_user_id="", channel_chat_id="iMessage;+;chat001", ), message_type=MessageType.IMAGE, content="Photo", attachments=[Attachment(type="image", url="")], ) adapter._client = AsyncMock() result = await adapter.send(response) assert result.success is False assert "no URL" in result.error @pytest.mark.asyncio async def test_pre_connect(self, adapter): with patch.object( adapter.__class__, "pre_connect", AsyncMock(return_value={"status": "ok", "imessage_connected": True}), ): result = await adapter.pre_connect() assert result["status"] == "ok" class TestBlueBubblesAdapterTapback: @pytest.fixture def adapter(self): return BlueBubblesAdapter({ "server_url": "http://localhost:1234", "password": "test-password", }) def test_tapback_normalize(self, adapter): event = make_dm_event() event["data"]["isTapback"] = True event["data"]["message"]["tapback"] = 0 result = adapter.normalize_inbound(event) assert "Loved" in result.content @pytest.mark.asyncio async def test_send_reaction_valid(self, adapter): mock_post = AsyncMock(return_value={"status": "ok"}) adapter._client = AsyncMock() adapter._client.post = mock_post result = await adapter.send_reaction("chat_001", "msg_001", "love") assert result.success is True @pytest.mark.asyncio async def test_send_reaction_invalid(self, adapter): adapter._client = AsyncMock() result = await adapter.send_reaction("chat_001", "msg_001", "rocket") assert result.success is False assert "Unsupported" in result.error @pytest.mark.asyncio async def test_send_reaction_no_client(self, adapter): result = await adapter.send_reaction("chat_001", "msg_001", "love") assert result.success is False assert "Client not initialized" in result.error class TestBlueBubblesExceptions: def test_import_exceptions(self): from yuxi.channels.adapters.bluebubbles.exceptions import ( BlueBubblesError, BlueBubblesAuthenticationError, BlueBubblesConnectionError, BlueBubblesHTTPError, TapbackError, ) assert issubclass(BlueBubblesAuthenticationError, BlueBubblesError) assert issubclass(BlueBubblesConnectionError, BlueBubblesError) assert issubclass(BlueBubblesHTTPError, BlueBubblesError) assert issubclass(TapbackError, BlueBubblesError) def test_http_error_attributes(self): from yuxi.channels.adapters.bluebubbles.exceptions import BlueBubblesHTTPError err = BlueBubblesHTTPError(500, "Internal Server Error") assert err.status_code == 500 assert err.retryable is True err2 = BlueBubblesHTTPError(400, "Bad Request") assert err2.status_code == 400 assert err2.retryable is False def test_auth_error_not_retryable(self): from yuxi.channels.adapters.bluebubbles.exceptions import BlueBubblesAuthenticationError err = BlueBubblesAuthenticationError("Bad password") assert err.retryable is False class TestBlueBubblesClient: @pytest.fixture def client(self): from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient return BlueBubblesClient( server_url="http://localhost:1234", password="test-password", ) def test_base_url(self, client): assert client.base_url == "http://localhost:1234" def test_base_url_strips_trailing_slash(self): from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient client = BlueBubblesClient("http://localhost:1234/", "pw") assert client.base_url == "http://localhost:1234" @pytest.mark.asyncio async def test_context_manager(self, client): async with client as c: assert c._client is not None assert client._client is None @pytest.mark.asyncio async def test_request_not_initialized(self, client): with pytest.raises(Exception): await client.get("/api/v1/server/info") def test_append_auth_adds_password_query_param(self, client): assert client._password == "test-password" def test_append_auth_with_existing_query_params(self, client): assert client._password == "test-password" @pytest.mark.asyncio async def test_put_method_exists(self, client): assert hasattr(client, "put") assert callable(client.put) @pytest.mark.asyncio async def test_delete_method_exists(self, client): assert hasattr(client, "delete") assert callable(client.delete) class TestBlueBubblesSendRetry: @pytest.mark.asyncio async def test_send_text_safe_retry_preserves_reply_to_guid(self): from unittest.mock import AsyncMock from yuxi.channels.adapters.bluebubbles.send import send_text_safe from yuxi.channels.adapters.bluebubbles.exceptions import BlueBubblesHTTPError mock_client = AsyncMock() mock_client.post.side_effect = [ BlueBubblesHTTPError(500, "Server Error"), {"guid": "msg_retry_001"}, ] result = await send_text_safe( mock_client, "chat_001", "Hello", reply_to_guid="original_msg_001", max_retries=2, ) assert result.success is True call_args = mock_client.post.call_args_list[1] payload = call_args[1]["json"] assert payload["replyToGuid"] == "original_msg_001" class TestBlueBubblesStreaming: @pytest.fixture def adapter(self): return BlueBubblesAdapter({ "server_url": "http://localhost:1234", "password": "test-password", }) @pytest.mark.asyncio async def test_send_stream_chunk_no_client_sends_via_send(self, adapter): result = await adapter.send_stream_chunk("chat_001", "msg_001", "streaming...", finished=False) assert result.success is False assert "Client not initialized" in result.error @pytest.mark.asyncio async def test_send_stream_chunk_with_client(self, adapter): mock_put = AsyncMock(return_value={"status": "ok"}) adapter._client = AsyncMock() adapter._client.put = mock_put result = await adapter.send_stream_chunk("chat_001", "msg_001", "streaming...", finished=False) assert result.success is True mock_put.assert_called_once() @pytest.mark.asyncio async def test_send_stream_chunk_finished(self, adapter): mock_put = AsyncMock(return_value={"status": "ok"}) adapter._client = AsyncMock() adapter._client.put = mock_put result = await adapter.send_stream_chunk("chat_001", "msg_001", "final", finished=True) assert result.success is True class TestBlueBubblesClientAuth: def test_client_no_bearer_auth_header(self): from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient client = BlueBubblesClient("http://localhost:1234", "pw") assert not hasattr(client, "_headers") assert client._password == "pw" def test_append_auth_url_encoding(self): from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient client = BlueBubblesClient("http://localhost:1234", "p@ss!") assert client._password == "p@ss!" class TestBlueBubblesProbe: @pytest.mark.asyncio async def test_check_server_reachable_unreachable(self): from yuxi.channels.adapters.bluebubbles.probe import check_server_reachable result = await check_server_reachable( server_url="http://nonexistent-host:1234", password="test", timeout=2.0, ) assert result.status == "unhealthy" class TestBlueBubblesAdapterSend: @pytest.fixture def adapter(self): return BlueBubblesAdapter({ "server_url": "http://localhost:1234", "password": "test-password", }) @pytest.mark.asyncio async def test_send_text_success(self, adapter): mock_post = AsyncMock(return_value={"guid": "msg_send_001"}) adapter._client = AsyncMock() adapter._client.post = mock_post response = make_response(content="Hello world") result = await adapter.send(response) assert result.success is True assert result.message_id == "msg_send_001" @pytest.mark.asyncio async def test_send_text_no_client(self, adapter): response = make_response(content="Hello") result = await adapter.send(response) assert result.success is False assert "Client not initialized" in result.error @pytest.mark.asyncio async def test_send_text_with_retry_on_500(self, adapter): from yuxi.channels.adapters.bluebubbles.exceptions import BlueBubblesHTTPError mock_post = AsyncMock(side_effect=[ BlueBubblesHTTPError(500, "Server Error"), {"guid": "msg_retry_001"}, ]) adapter._client = AsyncMock() adapter._client.post = mock_post response = make_response(content="Retry me") result = await adapter.send(response) assert result.success is True assert result.message_id == "msg_retry_001" class TestBlueBubblesSendHtmlMessage: @pytest.mark.asyncio async def test_send_html_returns_delivery_result(self): from yuxi.channels.adapters.bluebubbles.send import send_html_message from yuxi.channels.models import DeliveryResult mock_client = AsyncMock() mock_client.post = AsyncMock(return_value={"guid": "html_msg_001"}) result = await send_html_message(mock_client, "chat_001", "Hello") assert isinstance(result, DeliveryResult) assert result.success is True assert result.message_id == "html_msg_001" @pytest.mark.asyncio async def test_send_html_with_message_guid_fallback(self): from yuxi.channels.adapters.bluebubbles.send import send_html_message mock_client = AsyncMock() mock_client.post = AsyncMock(return_value={"messageGuid": "fallback_001"}) result = await send_html_message(mock_client, "chat_001", "Test") assert result.message_id == "fallback_001" class TestBlueBubblesTapbackEmoji: def test_resolve_tapback_emoji_heart(self): assert resolve_tapback_value("love") == 0 assert resolve_tapback_value("heart") == 0 def test_resolve_tapback_emoji_thumbs(self): assert resolve_tapback_value("like") == 1 assert resolve_tapback_value("dislike") == 2 def test_resolve_tapback_emoji_laugh(self): assert resolve_tapback_value("laugh") == 3 def test_resolve_tapback_emoji_exclaim_question(self): assert resolve_tapback_value("exclaim") == 4 assert resolve_tapback_value("question") == 5 class TestBlueBubblesDownloadMedia: @pytest.fixture def adapter(self): return BlueBubblesAdapter({ "server_url": "http://localhost:1234", "password": "test-password", }) @pytest.mark.asyncio async def test_download_media_no_client(self, adapter): with pytest.raises(Exception): await adapter.download_media("/media/test.jpg") @pytest.mark.asyncio async def test_download_media_with_client(self, adapter): mock_get = AsyncMock() mock_get.content = b"fake_image_data" adapter._client = AsyncMock() adapter._client._client = AsyncMock() adapter._client._client.get = AsyncMock(return_value=mock_get) adapter._client._base_url = "http://localhost:1234" adapter._client._password = "test-password" adapter._client.__class__ = type( adapter._client.__class__.__name__, adapter._client.__class__.__bases__, {"download": AsyncMock(return_value=b"fake_image_data")}, ) adapter._client.download = AsyncMock(return_value=b"fake_image_data") result = await adapter.download_media("/media/test.jpg") assert result == b"fake_image_data" class TestBlueBubblesStreamChunkEdgeCases: @pytest.fixture def adapter(self): return BlueBubblesAdapter({ "server_url": "http://localhost:1234", "password": "test-password", }) @pytest.mark.asyncio async def test_send_stream_chunk_no_client(self, adapter): result = await adapter.send_stream_chunk("chat_001", "msg_001", "streaming...", finished=False) assert result.success is False assert "Client not initialized" in result.error @pytest.mark.asyncio async def test_send_stream_chunk_no_msg_id_creates_new(self, adapter): mock_post = AsyncMock(return_value={"guid": "new_msg_001"}) adapter._client = AsyncMock() adapter._client.post = mock_post result = await adapter.send_stream_chunk("chat_001", "", "first chunk", finished=False) assert result.success is True assert result.message_id == "new_msg_001" class TestBlueBubblesClientUrlEncoding: def test_append_auth_url_encodes_special_chars(self): from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient client = BlueBubblesClient("http://localhost:1234", "p@ss w!rd") assert client._password == "p@ss w!rd" class TestBlueBubblesChunkedSend: @pytest.fixture def adapter(self): return BlueBubblesAdapter({ "server_url": "http://localhost:1234", "password": "test-password", }) @pytest.mark.asyncio async def test_send_within_limit_not_chunked(self, adapter): mock_post = AsyncMock(return_value={"guid": "msg_001"}) adapter._client = AsyncMock() adapter._client.post = mock_post response = make_response(content="Short message") result = await adapter.send(response) assert result.success is True mock_post.assert_called_once() call_payload = mock_post.call_args[1]["json"] assert call_payload["text"] == "Short message" @pytest.mark.asyncio async def test_send_exceeds_limit_chunked(self, adapter): mock_post = AsyncMock(return_value={"guid": "msg_001"}) adapter._client = AsyncMock() adapter._client.post = mock_post long_text = "x" * (adapter.text_chunk_limit + 100) response = make_response(content=long_text) result = await adapter.send(response) assert result.success is True assert mock_post.call_count == 2 @pytest.mark.asyncio async def test_send_chunked_first_chunk_has_reply_to(self, adapter): mock_post = AsyncMock(return_value={"guid": "msg_001"}) adapter._client = AsyncMock() adapter._client.post = mock_post long_text = "x" * (adapter.text_chunk_limit + 100) identity = ChannelIdentity( channel_id="bluebubbles", channel_type=ChannelType.BLUEBUBBLES, channel_user_id="+8613800138000", channel_chat_id="chat_001", ) response = ChannelResponse( identity=identity, content=long_text, reply_to_message_id="original_001", ) await adapter.send(response) first_call_payload = mock_post.call_args_list[0][1]["json"] second_call_payload = mock_post.call_args_list[1][1]["json"] assert first_call_payload["replyToGuid"] == "original_001" assert "replyToGuid" not in second_call_payload @pytest.mark.asyncio async def test_send_chunked_second_chunk_failure(self, adapter): mock_post = AsyncMock(side_effect=[ {"guid": "chunk_1"}, Exception("Chunk 2 failed"), ]) adapter._client = AsyncMock() adapter._client.post = mock_post long_text = "x" * (adapter.text_chunk_limit + 100) response = make_response(content=long_text) result = await adapter.send(response) assert result.success is False assert "Chunk 2 failed" in result.error class TestBlueBubblesTypingReadReceipt: @pytest.fixture def adapter(self): return BlueBubblesAdapter({ "server_url": "http://localhost:1234", "password": "test-password", }) @pytest.mark.asyncio async def test_send_typing_with_client(self, adapter): mock_post = AsyncMock(return_value={"status": "ok"}) adapter._client = AsyncMock() adapter._client.post = mock_post await adapter.send_typing("chat_001", display=True) mock_post.assert_called_once() @pytest.mark.asyncio async def test_send_typing_no_client(self, adapter): await adapter.send_typing("chat_001", display=True) @pytest.mark.asyncio async def test_send_read_with_client(self, adapter): mock_post = AsyncMock(return_value={"status": "ok"}) adapter._client = AsyncMock() adapter._client.post = mock_post await adapter.send_read("chat_001") mock_post.assert_called_once() @pytest.mark.asyncio async def test_send_read_no_client(self, adapter): await adapter.send_read("chat_001") class TestBlueBubblesReconnectStatus: @pytest.fixture def adapter(self): return BlueBubblesAdapter({ "server_url": "http://localhost:1234", "password": "test-password", }) def test_initial_status_disconnected(self, adapter): assert adapter._status == ChannelStatus.DISCONNECTED @pytest.mark.asyncio async def test_on_disconnect_sets_reconnecting(self, adapter): adapter._status = ChannelStatus.CONNECTED await adapter._on_ws_disconnect() assert adapter._status == ChannelStatus.RECONNECTING @pytest.mark.asyncio async def test_on_disconnect_when_not_connected_noop(self, adapter): adapter._status = ChannelStatus.DISCONNECTED await adapter._on_ws_disconnect() assert adapter._status == ChannelStatus.DISCONNECTED @pytest.mark.asyncio async def test_on_reconnect_sets_connected(self, adapter): adapter._status = ChannelStatus.RECONNECTING await adapter._on_ws_reconnect() assert adapter._status == ChannelStatus.CONNECTED class TestBlueBubblesMonitorJitter: def test_monitor_accepts_disconnect_reconnect_callbacks(self): from yuxi.channels.adapters.bluebubbles.monitor import BlueBubblesMonitor async def on_disconnect(): pass async def on_reconnect(): pass monitor = BlueBubblesMonitor( server_url="http://localhost:1234", password="test", on_event=AsyncMock(), on_disconnect=on_disconnect, on_reconnect=on_reconnect, ) assert monitor._on_disconnect is on_disconnect assert monitor._on_reconnect is on_reconnect class TestBlueBubblesResolveMessageType: @pytest.fixture def adapter(self): return BlueBubblesAdapter({ "server_url": "http://localhost:1234", "password": "test-password", }) def test_location_message_type(self, adapter): msg = {"location": {"latitude": 31.23, "longitude": 121.47}} result = adapter._resolve_message_type(msg, []) assert result == MessageType.LOCATION def test_location_missing_coords_falls_through(self, adapter): msg = {"location": {"address": "Shanghai"}} result = adapter._resolve_message_type(msg, []) assert result == MessageType.TEXT def test_sticker_message_type(self, adapter): msg = {"sticker": {"stickerDescription": "smiley"}} result = adapter._resolve_message_type(msg, []) assert result == MessageType.STICKER def test_contact_message_type(self, adapter): msg = {"vcard": {"name": "Alice"}} result = adapter._resolve_message_type(msg, []) assert result == MessageType.CARD def test_location_priority_over_attachment(self, adapter): msg = {"location": {"latitude": 1.0, "longitude": 2.0}} att = [Attachment(url="/img.png", mime_type="image/png")] result = adapter._resolve_message_type(msg, att) assert result == MessageType.LOCATION def test_sticker_priority_over_attachment(self, adapter): msg = {"sticker": {"stickerDescription": "wave"}} att = [Attachment(url="/img.png", mime_type="image/png")] result = adapter._resolve_message_type(msg, att) assert result == MessageType.STICKER class TestBlueBubblesSpecialContent: @pytest.fixture def adapter(self): return BlueBubblesAdapter({ "server_url": "http://localhost:1234", "password": "test-password", }) def test_location_content_with_address(self, adapter): msg = {"location": {"latitude": 22.5, "longitude": 114.1, "address": "HK"}} content = adapter._extract_special_content(msg) assert "22.5" in content assert "114.1" in content assert "HK" in content def test_location_content_without_address(self, adapter): msg = {"location": {"latitude": 10.0, "longitude": 20.0}} content = adapter._extract_special_content(msg) assert "10.0" in content assert "Location:" in content def test_sticker_content(self, adapter): msg = {"sticker": {"stickerDescription": "party popper"}} content = adapter._extract_special_content(msg) assert content == "party popper" def test_sticker_fallback(self, adapter): msg = {"sticker": {}} content = adapter._extract_special_content(msg) assert content == "[Sticker]" def test_contact_content(self, adapter): msg = {"contactDetails": {"name": "Bob"}} content = adapter._extract_special_content(msg) assert content == "Bob" def test_empty_message(self, adapter): content = adapter._extract_special_content({"text": "hello"}) assert content == "" class TestBlueBubblesActivityUpdate: @pytest.mark.asyncio async def test_update_activity_includes_ishtml(self): from yuxi.channels.adapters.bluebubbles.send import update_activity mock_client = AsyncMock() mock_client.put = AsyncMock(return_value={}) await update_activity(mock_client, "chat_001", "act_001", "**bold**") call_kwargs = mock_client.put.call_args assert call_kwargs[1]["json"]["isHTML"] is True class TestBlueBubblesDownloadCircuitBreaker: def test_download_uses_circuit_breaker(self): from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient client = BlueBubblesClient( "http://localhost:1234", "test", allow_private_network=True ) original_call = client._breaker.call call_count = 0 def counting_call(fn): nonlocal call_count call_count += 1 return original_call(fn) client._breaker.call = counting_call client._client = AsyncMock() mock_resp = AsyncMock() mock_resp.content = b"data" client._client.get = AsyncMock(return_value=mock_resp) import asyncio asyncio.run(client.download("/media/test.jpg")) assert call_count == 1 class TestBlueBubblesLaneStreaming: @pytest.fixture def adapter(self): return BlueBubblesAdapter({ "server_url": "http://localhost:1234", "password": "test-password", }) def test_lane_buffers_initialized(self, adapter): assert isinstance(adapter._lane_buffers, dict) assert isinstance(adapter._lane_sent, dict) assert len(adapter._lane_buffers) == 0 assert len(adapter._lane_sent) == 0 def test_compose_stream_content_no_lane(self, adapter): result = adapter._compose_stream_content("Hello", "", False) assert result == "Hello" def test_compose_stream_content_with_lane(self, adapter): result = adapter._compose_stream_content("Answer", "Thinking...", False) assert " 0 class TestBlueBubblesSendSticker: @pytest.mark.asyncio async def test_send_sticker_bytes(self): from yuxi.channels.adapters.bluebubbles.send import send_sticker mock_client = AsyncMock() mock_client.post = AsyncMock(return_value={"guid": "sticker_001"}) result = await send_sticker(mock_client, "chat_001", b"fake_png_data") assert result.success is True assert result.message_id == "sticker_001" @pytest.mark.asyncio async def test_send_sticker_no_valid_data(self): from yuxi.channels.adapters.bluebubbles.send import send_sticker mock_client = AsyncMock() result = await send_sticker(mock_client, "chat_001", {}) assert result.success is False assert "No valid sticker data" in result.error @pytest.mark.asyncio async def test_send_sticker_with_url(self): from yuxi.channels.adapters.bluebubbles.send import send_sticker from unittest.mock import patch, AsyncMock as AsyncMock2 mock_client = AsyncMock() mock_client.post = AsyncMock(return_value={"guid": "sticker_url_001"}) with patch("httpx.AsyncClient") as mock_http: mock_resp = AsyncMock2() mock_resp.content = b"fake_image" mock_resp.raise_for_status = lambda: None mock_http.return_value.__aenter__.return_value.get = AsyncMock2(return_value=mock_resp) result = await send_sticker( mock_client, "chat_001", {"url": "https://example.com/sticker.png"}, ) assert result.success is True class TestBlueBubblesAiVision: def test_vision_disabled_by_default(self): from yuxi.channels.adapters.bluebubbles.ai_vision import BlueBubblesVision vision = BlueBubblesVision() assert vision.enabled is False def test_vision_cache(self): from yuxi.channels.adapters.bluebubbles.ai_vision import VisionCache cache = VisionCache(max_entries=3) cache.put("hash1", {"description": "test"}) assert cache.get("hash1") == {"description": "test"} assert cache.size == 1 cache.clear() assert cache.size == 0 @pytest.mark.asyncio async def test_analyze_message_media_disabled(self): from yuxi.channels.adapters.bluebubbles.ai_vision import analyze_message_media, BlueBubblesVision vision = BlueBubblesVision() result = await analyze_message_media(vision, b"data", "test.jpg", "image/jpeg") assert result is None @pytest.mark.asyncio async def test_analyze_non_image_skipped(self): from yuxi.channels.adapters.bluebubbles.ai_vision import analyze_message_media, BlueBubblesVision vision = BlueBubblesVision({"ai_vision_enabled": True, "ai_vision_api_url": "http://api"}) result = await analyze_message_media(vision, b"data", "test.mp4", "video/mp4") assert result is None class TestBlueBubblesTemplates: def test_render_card_full(self): from yuxi.channels.adapters.bluebubbles.templates import render_card result = render_card( title="Test Card", body="Body text", subtitle="Subtitle", image_url="https://example.com/img.png", footer="Footer", ) assert "Test Card" in result assert "Body text" in result assert "Subtitle" in result assert "img.png" in result assert "Footer" in result def test_render_card_minimal(self): from yuxi.channels.adapters.bluebubbles.templates import render_card result = render_card(body="Just body") assert result == "Just body" def test_render_notification(self): from yuxi.channels.adapters.bluebubbles.templates import render_notification result = render_notification("Alert", "Something happened", "warning") assert "⚠️" in result assert "Alert" in result assert "Something happened" in result def test_render_items(self): from yuxi.channels.adapters.bluebubbles.templates import render_items items = [{"label": "Name", "value": "Alice"}, {"label": "Age", "value": "30"}] result = render_items(items) assert "Name: Alice" in result assert "Age: 30" in result def test_render_button_row(self): from yuxi.channels.adapters.bluebubbles.templates import render_button_row buttons = [ {"label": "Click", "url": "https://example.com"}, {"label": "No URL"}, ] result = render_button_row(buttons) assert "Click" in result assert "https://example.com" in result assert "No URL" in result class TestBlueBubblesRenderer: def test_render_html_strips_script(self): from yuxi.channels.adapters.bluebubbles.renderer import render_html_to_imessage result = render_html_to_imessage( "

Hello

World

" ) assert "Hello" in result assert "World" in result assert "script" not in result.lower() def test_render_html_converts_strong(self): from yuxi.channels.adapters.bluebubbles.renderer import render_html_to_imessage result = render_html_to_imessage("Bold") assert "Bold" in result def test_render_html_converts_em(self): from yuxi.channels.adapters.bluebubbles.renderer import render_html_to_imessage result = render_html_to_imessage("Italic") assert "Italic" in result def test_render_html_converts_li(self): from yuxi.channels.adapters.bluebubbles.renderer import render_html_to_imessage result = render_html_to_imessage("") assert "• Item 1" in result assert "• Item 2" in result def test_render_html_empty(self): from yuxi.channels.adapters.bluebubbles.renderer import render_html_to_imessage assert render_html_to_imessage("") == "" def test_render_markdown_bold(self): from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage result = render_markdown_to_imessage("**bold text**") assert "bold text" in result def test_render_markdown_italic(self): from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage result = render_markdown_to_imessage("*italic text*") assert "italic text" in result class TestBlueBubblesStreamingEnhanced: def test_stream_buffer(self): from yuxi.channels.adapters.bluebubbles.streaming_enhanced import StreamBuffer buf = StreamBuffer(max_buffer_size=100) result = buf.append("s1", "Hello ") assert result == "Hello " result = buf.append("s1", "World") assert result == "Hello World" assert buf.get("s1") == "Hello World" buf.clear("s1") assert buf.get("s1") == "" def test_streaming_typing_sync_init(self): from yuxi.channels.adapters.bluebubbles.streaming_enhanced import StreamingTypingSync sync = StreamingTypingSync() assert sync._active_chats == set() assert not sync._running class TestBlueBubblesDoctor: @pytest.mark.asyncio async def test_run_doctor_enhanced(self): from yuxi.channels.adapters.bluebubbles.doctor import run_doctor mock_client = AsyncMock() mock_client.get = AsyncMock(return_value={ "data": { "iMessageConnected": True, "private_api": True, "os_version": "15.0", "websocket": True, "attachmentRoot": "/media", }, }) with patch( "yuxi.channels.adapters.bluebubbles.doctor._check_websocket_endpoint", AsyncMock(return_value=True), ), patch( "yuxi.channels.adapters.bluebubbles.doctor._check_attachment_storage", AsyncMock(return_value=True), ), patch( "yuxi.channels.adapters.bluebubbles.doctor._check_message_history", AsyncMock(return_value={"available": True, "count": 5}), ): result = await run_doctor(mock_client) assert result["server_reachable"] is True assert result["websocket_reachable"] is True assert result["attachment_storage_ok"] is True assert result["message_history_available"] is True assert result["recent_message_count"] == 5 class TestBlueBubblesCacheManager: def test_cache_manager_register(self): from yuxi.channels.adapters.bluebubbles.cache_manager import CacheManager mgr = CacheManager(max_cache_entries=100) cache = mgr.register("test_cache", ttl_seconds=60) cache.set("key1", "value1") assert cache.get("key1") == "value1" def test_cache_manager_stats(self): from yuxi.channels.adapters.bluebubbles.cache_manager import CacheManager mgr = CacheManager() cache = mgr.register("stats_cache") cache.set("k1", "v1") cache.get("k1") cache.get("missing") stats = mgr.get_all_stats() assert "stats_cache" in stats assert stats["stats_cache"]["hits"] == 1 assert stats["stats_cache"]["misses"] == 1 def test_cache_manager_clear_all(self): from yuxi.channels.adapters.bluebubbles.cache_manager import CacheManager mgr = CacheManager() c1 = mgr.register("c1") c2 = mgr.register("c2") c1.set("k", "v") c2.set("k", "v") mgr.clear_all() assert c1.size == 0 assert c2.size == 0 def test_unified_cache_ttl(self): from yuxi.channels.adapters.bluebubbles.cache_manager import UnifiedCache import time cache = UnifiedCache("test", ttl_seconds=0.01) cache.set("key", "value") time.sleep(0.02) assert cache.get("key") is None def test_unified_cache_eviction(self): from yuxi.channels.adapters.bluebubbles.cache_manager import UnifiedCache cache = UnifiedCache("test", max_entries=3) for i in range(5): cache.set(f"key{i}", f"value{i}") assert cache.size <= 3 class TestBlueBubblesSetupWizard: @pytest.mark.asyncio async def test_run_setup_wizard_basic(self): from yuxi.channels.adapters.bluebubbles.setup_wizard import run_setup_wizard with patch( "yuxi.channels.adapters.bluebubbles.setup_wizard.validate_credentials", AsyncMock(return_value={"valid": True}), ), patch( "yuxi.channels.adapters.bluebubbles.setup_wizard.validate_server_url", AsyncMock(return_value={"reachable": True}), ): result = await run_setup_wizard( server_url="http://mac.local:1234", password="secret", ) assert "config" in result assert result["config"]["server_url"] == "http://mac.local:1234" assert result["complete"] is True def test_wizard_steps_complete(self): from yuxi.channels.adapters.bluebubbles.setup_wizard import BlueBubblesSetupWizard wizard = BlueBubblesSetupWizard() assert not wizard.is_complete wizard.mark_step_complete("server_url") wizard.mark_step_complete("password") assert wizard.is_complete def test_wizard_get_next_step(self): from yuxi.channels.adapters.bluebubbles.setup_wizard import BlueBubblesSetupWizard wizard = BlueBubblesSetupWizard() step = wizard.get_next_step() assert step is not None assert step["id"] == "server_url" class TestBlueBubblesAccountsMonitor: def test_accounts_monitor_register(self): from yuxi.channels.adapters.bluebubbles.accounts_monitor import AccountsMonitor from yuxi.channels.adapters.bluebubbles.accounts import BlueBubblesAccountConfig monitor = AccountsMonitor() config = BlueBubblesAccountConfig( account_id="test_account", server_url="http://localhost:1234", password="secret", ) monitor.register_account("test_account", config) health = monitor.get_health("test_account") assert health is not None assert health.account_id == "test_account" def test_accounts_monitor_summary(self): from yuxi.channels.adapters.bluebubbles.accounts_monitor import AccountsMonitor from yuxi.channels.adapters.bluebubbles.accounts import BlueBubblesAccountConfig monitor = AccountsMonitor() for i in range(3): config = BlueBubblesAccountConfig( account_id=f"account_{i}", server_url="http://localhost:1234", password="secret", ) monitor.register_account(f"account_{i}", config) summary = monitor.get_summary() assert summary["total_accounts"] == 3 assert summary["unhealthy"] == 3 class TestBlueBubblesClientPool: def test_client_pool_acquire(self): from yuxi.channels.adapters.bluebubbles.client_pool import ClientPool pool = ClientPool(pool_size=2) pool.configure("http://localhost:1234", "secret") assert pool.size == 0 assert pool.available == 0 def test_client_pool_initial_config(self): from yuxi.channels.adapters.bluebubbles.client_pool import ClientPool pool = ClientPool() pool.configure("http://localhost:1234", "pw") assert pool._config["server_url"] == "http://localhost:1234" assert pool._config["password"] == "pw" class TestBlueBubblesConfigExtended: def test_new_config_fields(self): config = BlueBubblesConfig() assert config.ai_vision_enabled is False assert config.streaming_typing_indicator is True assert config.message_order_check is True assert config.max_cache_entries == 2048