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

1543 lines
55 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