ForcePilot/backend/test/unit/channels/test_channels_bluebubbles.py
Kris 69fe97a90d test: 批量修复并新增单元测试用例
1. 移除Telegram格式化测试中未使用的导入项
2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关
3. 更新钉钉适配器测试,替换弃用的流属性检查
4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑
5. 重构会话映射测试,完善数据库执行结果模拟
6. 格式化Slack块构建测试的长参数调用
7. 修复LINE适配器测试,更新能力断言和异步锁使用
8. 修正Slack会话解析测试,修复聊天类型判断错误
9. 更新能力测试,补充缺失的字段检查
10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑
11. 为飞书分析模块测试添加跳过标记
12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试
13. 修复Twitch适配器导入路径和测试断言
14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试
15. 修复Manager阶段测试的导入路径
16. 新增iMessage异常和命令处理的单元测试
17. 新增Nostr健康检查和相关模块的单元测试
18. 新增Signal守护进程和SSE重连相关测试
2026-05-13 16:43:01 +08:00

2639 lines
93 KiB
Python

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", "<b>Hello</b>")
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", "<b>Test</b>")
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 "<details" in result
assert "Thinking..." in result
assert "Answer" in result
def test_compose_stream_content_finished(self, adapter):
result = adapter._compose_stream_content("Answer", "Thinking...", True)
assert "<details " in result
assert "open" not in result
def test_cleanup_stream_key(self, adapter):
key = "chat:msg"
adapter._stream_buffers[key] = "test"
adapter._lane_buffers[key] = "lane"
adapter._stream_sent[key] = "sent"
adapter._lane_sent[key] = "lane_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._lane_buffers
assert key not in adapter._stream_sent
assert key not in adapter._lane_sent
assert key not in adapter._last_edit_at
@pytest.mark.asyncio
async def test_send_lane_chunk_no_client(self, adapter):
result = await adapter.send_lane_chunk("chat_001", "msg_001", "thinking...")
assert result.success is False
class TestBlueBubblesReorderBuffer:
@pytest.fixture
def adapter(self):
return BlueBubblesAdapter(
{
"server_url": "http://localhost:1234",
"password": "test-password",
}
)
def test_reorder_buffer_created_when_enabled(self, adapter):
assert adapter._reorder_buffer is not None
assert adapter._message_order_check is True
def test_reorder_buffer_disabled(self):
adapter = BlueBubblesAdapter(
{
"server_url": "http://localhost:1234",
"password": "test-password",
"message_order_check": False,
}
)
assert adapter._reorder_buffer is None
def test_inbound_reorder_buffer_extract_timestamp(self):
from yuxi.channels.adapters.bluebubbles.monitor import InboundReorderBuffer
event = {
"type": "new-message",
"data": {
"message": {"dateCreated": 1000000},
},
}
ts = InboundReorderBuffer._extract_timestamp(event)
assert ts == 1000000.0
def test_inbound_reorder_buffer_no_timestamp(self):
from yuxi.channels.adapters.bluebubbles.monitor import InboundReorderBuffer
event = {"type": "new-message", "data": {}}
ts = InboundReorderBuffer._extract_timestamp(event)
assert ts > 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 "<b>Test Card</b>" in result
assert "Body text" in result
assert "<i>Subtitle</i>" 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 "<b>Alert</b>" 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("<p>Hello</p><script>alert('xss')</script><p>World</p>")
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("<strong>Bold</strong>")
assert "<b>Bold</b>" 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("<em>Italic</em>")
assert "<i>Italic</i>" 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("<ul><li>Item 1</li><li>Item 2</li></ul>")
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 "<b>bold text</b>" 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 "<i>italic text</i>" 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 "<b>bold</b>" 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 "<i>italic</i>" 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 "<b>bold <i>italic</i> text</b>" 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 "<i>italic <b>bold</b> more</i>" 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 "<b>bold</b>" in result
def test_strikethrough(self):
from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage
result = render_markdown_to_imessage("~~strikethrough~~")
assert "<s>strikethrough</s>" 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