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 class TestActionGate: @pytest.fixture def gate(self): from yuxi.channels.adapters.bluebubbles.action_gate import ActionGate return ActionGate() def test_all_actions_allowed_when_both_available(self, gate): gate.set_private_api_available(True) gate.set_macos_26_or_higher(False) assert gate.is_action_allowed("send") is True assert gate.is_action_allowed("unsend") is True assert gate.is_action_allowed("edit") is True assert gate.is_action_allowed("rename_group") is True assert gate.is_action_allowed("send_media") is True assert gate.is_action_allowed("react") is True def test_private_api_actions_blocked_when_private_api_unavailable(self, gate): gate.set_private_api_available(False) gate.set_macos_26_or_higher(False) assert gate.is_action_allowed("send") is True assert gate.is_action_allowed("unsend") is False assert gate.is_action_allowed("rename_group") is False assert gate.is_action_allowed("set_group_icon") is False assert gate.is_action_allowed("add_participant") is False assert gate.is_action_allowed("remove_participant") is False assert gate.is_action_allowed("leave_group") is False def test_edit_blocked_on_macos_26(self, gate): gate.set_private_api_available(True) gate.set_macos_26_or_higher(True) assert gate.is_action_allowed("edit") is False assert gate.is_action_allowed("send") is True assert gate.is_action_allowed("unsend") is True def test_private_api_none_treated_as_unavailable(self, gate): assert gate.is_action_allowed("unsend") is False assert gate.is_action_allowed("rename_group") is False def test_get_available_actions_full(self, gate): gate.set_private_api_available(True) gate.set_macos_26_or_higher(False) actions = gate.get_available_actions() assert "send" in actions assert "unsend" in actions assert "edit" in actions assert "rename_group" in actions assert "react" in actions def test_get_available_actions_private_api_disabled(self, gate): gate.set_private_api_available(False) gate.set_macos_26_or_higher(False) actions = gate.get_available_actions() assert "unsend" not in actions assert "rename_group" not in actions assert "edit" in actions def test_get_available_actions_macos_26(self, gate): gate.set_private_api_available(True) gate.set_macos_26_or_higher(True) actions = gate.get_available_actions() assert "edit" not in actions assert "unsend" in actions def test_get_available_actions_sorted(self, gate): gate.set_private_api_available(True) gate.set_macos_26_or_higher(False) actions = gate.get_available_actions() assert actions == sorted(actions) class TestCatchupCursorStore: @pytest.fixture def temp_dir(self, tmp_path): return str(tmp_path) def test_cursor_default_empty(self, temp_dir): from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore store = CatchupCursorStore(state_dir=temp_dir, account_id="test") assert store.cursor == "" def test_cursor_read_write_consistency(self, temp_dir): from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore store = CatchupCursorStore(state_dir=temp_dir, account_id="test") store.cursor = "1234567890" assert store.cursor == "1234567890" def test_cursor_persistence_across_instances(self, temp_dir): from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore store1 = CatchupCursorStore(state_dir=temp_dir, account_id="test") store1.cursor = "9999999999" store2 = CatchupCursorStore(state_dir=temp_dir, account_id="test") assert store2.cursor == "9999999999" def test_cursor_json_corruption_resets(self, temp_dir): from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore store = CatchupCursorStore(state_dir=temp_dir, account_id="test") store.cursor = "original" cursor_path = store._cursor_path cursor_path.write_text("not valid json {{{", encoding="utf-8") store2 = CatchupCursorStore(state_dir=temp_dir, account_id="test") assert store2.cursor == "" def test_record_failure_increment(self, temp_dir): from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore store = CatchupCursorStore(state_dir=temp_dir, account_id="test") assert store.record_failure("msg_1") == 1 assert store.record_failure("msg_1") == 2 assert store.record_failure("msg_1") == 3 def test_is_given_up_false_initially(self, temp_dir): from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore store = CatchupCursorStore(state_dir=temp_dir, account_id="test") assert store.is_given_up("msg_x") is False def test_mark_given_up_and_check(self, temp_dir): from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore store = CatchupCursorStore(state_dir=temp_dir, account_id="test") store.mark_given_up("msg_y") assert store.is_given_up("msg_y") is True def test_given_up_persists(self, temp_dir): from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore store = CatchupCursorStore(state_dir=temp_dir, account_id="test") store.mark_given_up("msg_z") store2 = CatchupCursorStore(state_dir=temp_dir, account_id="test") assert store2.is_given_up("msg_z") is True def test_reset_failures(self, temp_dir): from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore store = CatchupCursorStore(state_dir=temp_dir, account_id="test") store.record_failure("msg_r") store.record_failure("msg_r") store.reset_failures("msg_r") assert store.record_failure("msg_r") == 1 def test_different_accounts_separate_cursors(self, temp_dir): from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore store_a = CatchupCursorStore(state_dir=temp_dir, account_id="account_a") store_b = CatchupCursorStore(state_dir=temp_dir, account_id="account_b") store_a.cursor = "aaa" store_b.cursor = "bbb" assert CatchupCursorStore(state_dir=temp_dir, account_id="account_a").cursor == "aaa" assert CatchupCursorStore(state_dir=temp_dir, account_id="account_b").cursor == "bbb" class TestRunFullCatchup: @pytest.fixture def temp_dir(self, tmp_path): return str(tmp_path) @pytest.mark.asyncio async def test_catchup_updates_cursor(self, temp_dir): from yuxi.channels.adapters.bluebubbles.catchup import ( CatchupCursorStore, run_full_catchup, ) import time recent_ts = int(time.time() * 1000) - 60000 mock_client = AsyncMock() mock_client.get = AsyncMock( return_value={ "data": [ {"guid": "msg_001", "dateCreated": recent_ts, "isFromMe": False}, ] } ) store = CatchupCursorStore(state_dir=temp_dir, account_id="test") received: list = [] async def on_msg(chat_guid, msg): received.append(msg) summary = await run_full_catchup( mock_client, ["chat_001"], store, max_age_minutes=120, on_message=on_msg, ) assert summary.replayed == 1 assert len(received) == 1 assert summary.cursor_after != "" @pytest.mark.asyncio async def test_catchup_skips_old_messages(self, temp_dir): from yuxi.channels.adapters.bluebubbles.catchup import ( CatchupCursorStore, run_full_catchup, ) now_ms = int(__import__("time").time() * 1000) old_ts = now_ms - 4 * 60 * 60 * 1000 mock_client = AsyncMock() mock_client.get = AsyncMock( return_value={ "data": [ {"guid": "msg_old", "dateCreated": old_ts, "isFromMe": False}, ] } ) store = CatchupCursorStore(state_dir=temp_dir, account_id="test") received: list = [] async def on_msg(chat_guid, msg): received.append(msg) summary = await run_full_catchup( mock_client, ["chat_001"], store, max_age_minutes=120, on_message=on_msg, ) assert summary.skipped_pre_cursor > 0 assert summary.replayed == 0 @pytest.mark.asyncio async def test_catchup_skips_from_me(self, temp_dir): from yuxi.channels.adapters.bluebubbles.catchup import ( CatchupCursorStore, run_full_catchup, ) mock_client = AsyncMock() mock_client.get = AsyncMock( return_value={ "data": [ {"guid": "msg_me", "dateCreated": 9999999999999, "isFromMe": True}, ] } ) store = CatchupCursorStore(state_dir=temp_dir, account_id="test") received: list = [] async def on_msg(chat_guid, msg): received.append(msg) summary = await run_full_catchup( mock_client, ["chat_001"], store, max_age_minutes=120, on_message=on_msg, ) assert summary.skipped_from_me > 0 assert summary.replayed == 0 @pytest.mark.asyncio async def test_catchup_respects_per_run_limit(self, temp_dir): from yuxi.channels.adapters.bluebubbles.catchup import ( CatchupCursorStore, run_full_catchup, ) msgs = [{"guid": f"msg_{i}", "dateCreated": 9999999999999 - i * 1000, "isFromMe": False} for i in range(20)] mock_client = AsyncMock() mock_client.get = AsyncMock(return_value={"data": msgs}) store = CatchupCursorStore(state_dir=temp_dir, account_id="test") received: list = [] async def on_msg(chat_guid, msg): received.append(msg) summary = await run_full_catchup( mock_client, ["chat_001"], store, max_age_minutes=0, per_run_limit=5, on_message=on_msg, ) assert summary.replayed == 5 assert len(received) == 5 @pytest.mark.asyncio async def test_catchup_skips_given_up(self, temp_dir): from yuxi.channels.adapters.bluebubbles.catchup import ( CatchupCursorStore, run_full_catchup, ) mock_client = AsyncMock() mock_client.get = AsyncMock( return_value={ "data": [ {"guid": "msg_givenup", "dateCreated": 9999999999999, "isFromMe": False}, ] } ) store = CatchupCursorStore(state_dir=temp_dir, account_id="test") store.mark_given_up("msg_givenup") received: list = [] async def on_msg(chat_guid, msg): received.append(msg) summary = await run_full_catchup( mock_client, ["chat_001"], store, max_age_minutes=0, on_message=on_msg, ) assert summary.skipped_given_up > 0 assert summary.replayed == 0 @pytest.mark.asyncio async def test_catchup_record_failure_and_retry(self, temp_dir): from yuxi.channels.adapters.bluebubbles.catchup import ( CatchupCursorStore, run_full_catchup, ) mock_client = AsyncMock() mock_client.get = AsyncMock( return_value={ "data": [ {"guid": "msg_fail", "dateCreated": 9999999999999, "isFromMe": False}, ] } ) store = CatchupCursorStore(state_dir=temp_dir, account_id="test") async def failing_handler(chat_guid, msg): raise RuntimeError("simulated failure") summary = await run_full_catchup( mock_client, ["chat_001"], store, max_age_minutes=0, on_message=failing_handler, ) assert summary.failed > 0 assert summary.replayed == 0 @pytest.mark.asyncio async def test_catchup_mark_given_up_after_max_failures(self, temp_dir): from yuxi.channels.adapters.bluebubbles.catchup import ( CatchupCursorStore, run_full_catchup, ) store = CatchupCursorStore(state_dir=temp_dir, account_id="test") for i in range(5): store.record_failure("msg_permfail") mock_client = AsyncMock() mock_client.get = AsyncMock( return_value={ "data": [ {"guid": "msg_permfail", "dateCreated": 9999999999999, "isFromMe": False}, ] } ) async def failing_handler(chat_guid, msg): raise RuntimeError("simulated failure") summary = await run_full_catchup( mock_client, ["chat_001"], store, max_age_minutes=0, max_failure_retries=5, on_message=failing_handler, ) assert summary.given_up > 0 @pytest.mark.asyncio async def test_catchup_client_error_graceful(self, temp_dir): from yuxi.channels.adapters.bluebubbles.catchup import ( CatchupCursorStore, run_full_catchup, ) mock_client = AsyncMock() mock_client.get = AsyncMock(side_effect=Exception("network error")) store = CatchupCursorStore(state_dir=temp_dir, account_id="test") summary = await run_full_catchup( mock_client, ["chat_001"], store, max_age_minutes=120, ) assert summary.replayed == 0 class TestProbeAdvanced: def test_is_macos_26_or_higher_exact_26(self): from yuxi.channels.adapters.bluebubbles.probe import is_macos_26_or_higher assert is_macos_26_or_higher("26.0") is True assert is_macos_26_or_higher("26.1") is True def test_is_macos_26_or_higher_below_26(self): from yuxi.channels.adapters.bluebubbles.probe import is_macos_26_or_higher assert is_macos_26_or_higher("25.3") is False assert is_macos_26_or_higher("14.5") is False def test_is_macos_26_or_higher_unknown(self): from yuxi.channels.adapters.bluebubbles.probe import is_macos_26_or_higher assert is_macos_26_or_higher("unknown") is False def test_is_macos_26_or_higher_invalid_format(self): from yuxi.channels.adapters.bluebubbles.probe import is_macos_26_or_higher assert is_macos_26_or_higher("not.a.version") is False assert is_macos_26_or_higher("") is False @pytest.mark.asyncio async def test_probe_private_api_true(self): from yuxi.channels.adapters.bluebubbles.probe import probe_private_api mock_client = AsyncMock() mock_client.get = AsyncMock( return_value={ "status": "ok", "data": {"private_api": True}, } ) result = await probe_private_api(mock_client) assert result is True @pytest.mark.asyncio async def test_probe_private_api_false(self): from yuxi.channels.adapters.bluebubbles.probe import probe_private_api mock_client = AsyncMock() mock_client.get = AsyncMock( return_value={ "status": "ok", "data": {"private_api": False}, } ) result = await probe_private_api(mock_client) assert result is False @pytest.mark.asyncio async def test_probe_private_api_error_returns_false(self): from yuxi.channels.adapters.bluebubbles.probe import probe_private_api mock_client = AsyncMock() mock_client.get = AsyncMock(side_effect=Exception("boom")) result = await probe_private_api(mock_client) assert result is False @pytest.mark.asyncio async def test_probe_imessage_login_healthy(self): from yuxi.channels.adapters.bluebubbles.probe import probe_imessage_login mock_client = AsyncMock() mock_client.get = AsyncMock( return_value={ "status": "ok", "data": {"iMessageConnected": True}, } ) result = await probe_imessage_login(mock_client) assert result.status == "healthy" @pytest.mark.asyncio async def test_probe_imessage_login_degraded(self): from yuxi.channels.adapters.bluebubbles.probe import probe_imessage_login mock_client = AsyncMock() mock_client.get = AsyncMock( return_value={ "status": "ok", "data": {"iMessageConnected": False}, } ) result = await probe_imessage_login(mock_client) assert result.status == "degraded" def test_server_info_cache_ttl(self): from yuxi.channels.adapters.bluebubbles.probe import ServerInfoCache cache = ServerInfoCache() cache.set("url1", {"data": "test"}) cached = cache.get("url1") assert cached is not None assert cached["data"] == "test" def test_server_info_cache_invalidate(self): from yuxi.channels.adapters.bluebubbles.probe import ServerInfoCache cache = ServerInfoCache() cache.set("url1", {"data": "test"}) cache.invalidate("url1") assert cache.get("url1") is None class TestConfigModelFull: def test_config_all_defaults(self): config = BlueBubblesConfig() assert config.enabled is False assert config.server_url == "http://localhost:1234" assert config.password == "" assert config.request_timeout == 30.0 assert config.send_timeout_ms == 30000 assert config.reconnect_max_delay == 60.0 assert config.dm_policy == "pairing" assert config.group_policy == "allowlist" assert config.dm_allow_from == [] assert config.group_allow_chats == [] assert config.media_max_mb == 100 assert config.enable_stickers is True assert config.streaming_mode == "partial" assert config.edit_interval_ms == 500 assert config.tapback_enabled is True assert config.require_mention is False assert config.send_read_receipts is True assert config.text_chunk_limit == 4000 assert config.block_streaming is False 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 assert config.dm_history_limit == 100 assert config.coalesce_same_sender_dms is False assert config.enrich_group_participants_from_contacts is True assert config.config_writes is True assert config.allow_private_network is False assert config.tts_prefer_caf is True def test_config_sub_models_defaults(self): config = BlueBubblesConfig() assert config.catchup.enabled is True assert config.catchup.max_age_minutes == 120 assert config.catchup.per_run_limit == 50 assert config.catchup.first_run_lookback_minutes == 30 assert config.catchup.max_failure_retries == 10 assert config.health_monitor.enabled is True assert config.network.dangerously_allow_private_network is False assert config.markdown.enabled is True assert config.markdown.code_blocks is True assert config.block_streaming_coalesce.enabled is False assert config.block_streaming_coalesce.max_flush_interval_ms == 500 def test_config_auth_strategy_literal(self): config = BlueBubblesConfig(auth_strategy="both") assert config.auth_strategy == "both" config2 = BlueBubblesConfig(auth_strategy="query") assert config2.auth_strategy == "query" def test_config_dm_policy_literal(self): config = BlueBubblesConfig(dm_policy="allowlist") assert config.dm_policy == "allowlist" assert config.dm_allow_from == [] def test_config_group_policy_literal(self): config = BlueBubblesConfig(group_policy="blocklist") assert config.group_policy == "blocklist" def test_from_env_all_new_fields(self): with patch.dict( "os.environ", { "BLUEBUBBLES_STREAMING_TYPING": "false", "BLUEBUBBLES_MESSAGE_ORDER_CHECK": "false", "BLUEBUBBLES_MAX_CACHE_ENTRIES": "512", "BLUEBUBBLES_DM_HISTORY_LIMIT": "50", "BLUEBUBBLES_COALESCE_DMS": "true", "BLUEBUBBLES_ENRICH_GROUP": "false", "BLUEBUBBLES_CONFIG_WRITES": "false", "BLUEBUBBLES_COALESCE_ENABLED": "true", "BLUEBUBBLES_COALESCE_MAX_FLUSH_MS": "800", "BLUEBUBBLES_NETWORK_DANGEROUSLY_ALLOW_PRIVATE": "true", "BLUEBUBBLES_MARKDOWN_ENABLED": "false", "BLUEBUBBLES_MARKDOWN_CODE_BLOCKS": "false", "BLUEBUBBLES_CATCHUP_ENABLED": "false", "BLUEBUBBLES_CATCHUP_MAX_AGE_MINUTES": "60", "BLUEBUBBLES_CATCHUP_PER_RUN_LIMIT": "25", "BLUEBUBBLES_CATCHUP_FIRST_LOOKBACK": "15", "BLUEBUBBLES_CATCHUP_MAX_FAILURES": "5", "BLUEBUBBLES_HEALTH_MONITOR_ENABLED": "false", }, ): config = BlueBubblesConfig.from_env() assert config.streaming_typing_indicator is False assert config.message_order_check is False assert config.max_cache_entries == 512 assert config.dm_history_limit == 50 assert config.coalesce_same_sender_dms is True assert config.enrich_group_participants_from_contacts is False assert config.config_writes is False assert config.block_streaming_coalesce.enabled is True assert config.block_streaming_coalesce.max_flush_interval_ms == 800 assert config.network.dangerously_allow_private_network is True assert config.markdown.enabled is False assert config.markdown.code_blocks is False assert config.catchup.enabled is False assert config.catchup.max_age_minutes == 60 assert config.catchup.per_run_limit == 25 assert config.catchup.first_run_lookback_minutes == 15 assert config.catchup.max_failure_retries == 5 assert config.health_monitor.enabled is False def test_from_env_validate_literal_valid(self): with patch.dict( "os.environ", { "BLUEBUBBLES_STREAMING_MODE": "block", "BLUEBUBBLES_AUTH_STRATEGY": "both", }, ): config = BlueBubblesConfig.from_env() assert config.streaming_mode == "block" assert config.auth_strategy == "both" def test_from_env_validate_literal_invalid(self): with patch.dict( "os.environ", { "BLUEBUBBLES_STREAMING_MODE": "invalid_mode", "BLUEBUBBLES_AUTH_STRATEGY": "nonexistent", }, ): config = BlueBubblesConfig.from_env() assert config.streaming_mode == "partial" assert config.auth_strategy == "header" def test_config_webhook_path_default(self): config = BlueBubblesConfig() assert config.webhook_path == "/bluebubbles/webhook" assert config.webhook_secret == "" def test_config_group_override(self): config = BlueBubblesConfig(groups={"chat1": {"system_prompt": "Custom prompt"}}) override = config.groups.get("chat1") assert override is not None assert override.system_prompt == "Custom prompt" def test_allow_private_network_warning(self, caplog): config = BlueBubblesConfig(allow_private_network=True) assert config.allow_private_network is True def test_media_local_roots_default(self): config = BlueBubblesConfig() assert config.media_local_roots == [] class TestStreamCoalesce: @pytest.fixture def adapter(self): return BlueBubblesAdapter( { "server_url": "http://localhost:1234", "password": "test", "block_streaming_coalesce": {"enabled": True, "max_flush_interval_ms": 500}, } ) def test_coalesce_buffer_merge(self, adapter): key = "chat_001:msg_001" adapter._stream_buffers[key] = "Hello " adapter._stream_buffers[key] = adapter._stream_buffers.get(key, "") + "World" assert adapter._stream_buffers[key] == "Hello World" def test_coalesce_multiple_chunks_merged(self, adapter): key = "chat_001:msg_002" adapter._stream_buffers[key] = "" for word in ["The", " ", "quick", " ", "brown"]: adapter._stream_buffers[key] = adapter._stream_buffers.get(key, "") + word assert adapter._stream_buffers[key] == "The quick brown" def test_coalesce_finished_clears_buffer(self, adapter): key = "chat_001:msg_003" adapter._stream_buffers[key] = "content" adapter._stream_sent[key] = "sent" adapter._last_edit_at[key] = 1.0 adapter._cleanup_stream_key(key) assert key not in adapter._stream_buffers assert key not in adapter._stream_sent assert key not in adapter._last_edit_at def test_lane_buffer_independent(self, adapter): key = "chat_001:msg_004" adapter._stream_buffers[key] = "reasoning" adapter._lane_buffers[key] = "thinking" assert adapter._stream_buffers[key] == "reasoning" assert adapter._lane_buffers[key] == "thinking" @pytest.mark.asyncio async def test_send_stream_chunk_builds_identity(self, adapter): adapter._client = AsyncMock() adapter._client.post = AsyncMock(return_value={"guid": "new_msg", "messageGuid": "new_msg"}) result = await adapter.send_stream_chunk("chat_001", "", "Hello", False) assert result.success is True @pytest.mark.asyncio async def test_send_stream_chunk_no_client(self, adapter): result = await adapter.send_stream_chunk("chat_001", "msg_001", "chunk", False) assert result.success is False class TestInboundEventsFull: @pytest.fixture def adapter(self): return BlueBubblesAdapter( { "server_url": "http://localhost:1234", "password": "test-password", } ) def test_event_type_new_message(self, adapter): event = make_dm_event(text="Hello") result = adapter.normalize_inbound(event) assert result.event_type == EventType.MESSAGE_RECEIVED assert result.content == "Hello" def test_event_type_typing_indicator(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_event_type_chat_name_change(self, adapter): event = { "type": "chat-name-change", "data": { "chatGuid": "iMessage;-;group001", "newName": "New Name", }, } result = adapter.normalize_inbound(event) assert result.metadata["event"] == "chat_name_change" assert result.metadata["new_name"] == "New Name" def test_event_type_chat_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_event_type_participant_added(self, adapter): event = { "type": "participant-added", "data": { "chatGuid": "iMessage;-;group001", "user": {"address": "+8613900139000"}, }, } result = adapter.normalize_inbound(event) assert result.metadata.get("event") == "member.joined" def test_event_type_participant_removed(self, adapter): event = { "type": "participant-removed", "data": { "chatGuid": "iMessage;-;group001", "user": {"address": "+8613900139000"}, }, } result = adapter.normalize_inbound(event) assert result.metadata.get("event") == "member.left" def test_event_type_participant_left(self, adapter): event = { "type": "participant-left", "data": { "chatGuid": "iMessage;-;group001", "user": {"address": "+8613900139000"}, }, } result = adapter.normalize_inbound(event) assert result.metadata.get("event") == "participant-left" def test_event_type_server_event(self, adapter): event = { "type": "server-event", "data": {"event": "startup"}, } result = adapter.normalize_inbound(event) assert result.metadata["event"] == "server-event" def test_normalize_empty_text_message(self, adapter): event = make_dm_event(text="") result = adapter.normalize_inbound(event) assert isinstance(result, ChannelMessage) def test_normalize_message_with_sender(self, adapter): event = make_dm_event(text="Test", sender="+8613900139000") result = adapter.normalize_inbound(event) assert result.identity.channel_user_id == "+8613900139000" class TestBlueBubblesAdapterTTS: @pytest.fixture def adapter(self): return BlueBubblesAdapter( { "server_url": "http://localhost:1234", "password": "test", } ) def test_effective_capabilities_tts_included(self, adapter): caps = adapter.effective_capabilities assert caps.tts is not None def test_effective_capabilities_action_gate(self, adapter): adapter._action_gate.set_private_api_available(True) adapter._action_gate.set_macos_26_or_higher(False) caps = adapter.effective_capabilities assert caps.edit is True assert caps.unsend is True class TestBlueBubblesStatusIssues: @pytest.fixture def adapter(self): return BlueBubblesAdapter( { "server_url": "http://localhost:1234", "password": "test", } ) def test_status_issues_created(self, adapter): assert adapter._status_issues is not None initial = adapter._status_issues.get_issues() assert initial == [] def test_status_issues_check_connection(self, adapter): adapter._status_issues.check_connection("disconnected", False) issues = adapter._status_issues.get_issues() assert len(issues) > 0 assert any("disconnected" in str(i.get("message", "")).lower() for i in issues) def test_status_issues_clear(self, adapter): adapter._status_issues.check_connection("disconnected", False) adapter._status_issues.clear() assert adapter._status_issues.get_issues() == [] class TestBlueBubblesExecApproval: def test_create_and_get_request(self): from yuxi.channels.adapters.bluebubbles.exec_approval import ExecApproval approval = ExecApproval() req_id = approval.create_approval_request("chat_001", "sudo rm -rf /") req = approval.get_request(req_id) assert req is not None assert req["status"] == "pending" assert req["command"] == "sudo rm -rf /" def test_approve_request(self): from yuxi.channels.adapters.bluebubbles.exec_approval import ExecApproval approval = ExecApproval() req_id = approval.create_approval_request("chat_001", "delete-all") assert approval.approve(req_id) is True req = approval.get_request(req_id) assert req["status"] == "approved" def test_reject_request(self): from yuxi.channels.adapters.bluebubbles.exec_approval import ExecApproval approval = ExecApproval() req_id = approval.create_approval_request("chat_001", "delete-all") assert approval.reject(req_id) is True req = approval.get_request(req_id) assert req["status"] == "rejected" def test_approve_nonexistent_request(self): from yuxi.channels.adapters.bluebubbles.exec_approval import ExecApproval approval = ExecApproval() assert approval.approve("nonexistent") is False def test_cleanup_removes_resolved(self): from yuxi.channels.adapters.bluebubbles.exec_approval import ExecApproval approval = ExecApproval() req_id = approval.create_approval_request("chat_001", "clean") approval.approve(req_id) approval.cleanup() assert approval.get_request(req_id) is None class TestBlueBubblesMarkdownRenderer: def test_basic_bold(self): from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage result = render_markdown_to_imessage("**bold**") assert "bold" in result def test_basic_italic(self): from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage result = render_markdown_to_imessage("*italic*") assert "italic" in result def test_nested_bold_italic(self): from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage result = render_markdown_to_imessage("**bold *italic* text**") assert "bold italic text" in result def test_multiple_nested(self): from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage result = render_markdown_to_imessage("*italic **bold** more*") assert "italic bold more" in result def test_empty_input(self): from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage assert render_markdown_to_imessage("") == "" def test_code_preserved(self): from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage result = render_markdown_to_imessage("`code here` not code") assert "`code here`" in result assert "not code" in result def test_underscore_bold(self): from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage result = render_markdown_to_imessage("__bold__") assert "bold" in result def test_strikethrough(self): from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage result = render_markdown_to_imessage("~~strikethrough~~") assert "strikethrough" in result class TestBlueBubblesStreamingTypingSync: def test_typing_sync_created(self): adapter = BlueBubblesAdapter( { "server_url": "http://localhost:1234", "password": "test", } ) assert adapter._typing_sync is not None def test_typing_sync_no_client(self): adapter = BlueBubblesAdapter( { "server_url": "http://localhost:1234", "password": "test", } ) assert adapter._typing_sync._client is None def test_typing_sync_stop_all_no_chats(self): from yuxi.channels.adapters.bluebubbles.streaming_enhanced import StreamingTypingSync sync = StreamingTypingSync() assert len(sync._active_chats) == 0 class TestBlueBubblesConversationBindings: def test_bind_and_resolve(self): from yuxi.channels.adapters.bluebubbles.conversation_bindings import ConversationBindings bindings = ConversationBindings() bindings.bind("conv_1", "chat_guid_1", {"key": "value"}) resolved = bindings.resolve("conv_1") assert resolved is not None assert resolved["chat_guid"] == "chat_guid_1" assert resolved["context"]["key"] == "value" def test_resolve_nonexistent(self): from yuxi.channels.adapters.bluebubbles.conversation_bindings import ConversationBindings bindings = ConversationBindings() assert bindings.resolve("nonexistent") is None def test_resolve_by_chat_guid(self): from yuxi.channels.adapters.bluebubbles.conversation_bindings import ConversationBindings bindings = ConversationBindings() bindings.bind("conv_2", "chat_guid_2", {}) assert bindings.resolve_by_chat_guid("chat_guid_2") == "conv_2" def test_unbind(self): from yuxi.channels.adapters.bluebubbles.conversation_bindings import ConversationBindings bindings = ConversationBindings() bindings.bind("conv_3", "chat_guid_3", {}) bindings.unbind("conv_3") assert bindings.resolve("conv_3") is None class TestBlueBubblesConversationID: def test_generate_deterministic(self): from yuxi.channels.adapters.bluebubbles.conversation_id import ConversationID gen = ConversationID() id1 = gen.generate("bluebubbles", "chat_001", "") id2 = gen.generate("bluebubbles", "chat_001", "") assert id1 == id2 assert len(id1) == 32 def test_generate_from_context(self): from yuxi.channels.adapters.bluebubbles.conversation_id import ConversationID gen = ConversationID() ctx = {"channel_id": "bb", "chat_id": "c1", "thread_id": "t1"} id1 = gen.generate_from_context(ctx) id2 = gen.generate("bb", "c1", "t1") assert id1 == id2