2026-05-12 00:56:47 +08:00
|
|
|
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):
|
2026-05-13 16:43:01 +08:00
|
|
|
with patch.dict(
|
|
|
|
|
"os.environ",
|
|
|
|
|
{
|
|
|
|
|
"BLUEBUBBLES_SERVER_URL": "http://mac.local:1234",
|
|
|
|
|
"BLUEBUBBLES_SERVER_PASSWORD": "secret123",
|
|
|
|
|
},
|
|
|
|
|
):
|
2026-05-12 00:56:47 +08:00
|
|
|
config = BlueBubblesConfig.from_env()
|
|
|
|
|
assert config.server_url == "http://mac.local:1234"
|
|
|
|
|
assert config.password == "secret123"
|
|
|
|
|
|
|
|
|
|
def test_from_env_full(self):
|
2026-05-13 16:43:01 +08:00
|
|
|
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",
|
|
|
|
|
},
|
|
|
|
|
):
|
2026-05-12 00:56:47 +08:00
|
|
|
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):
|
2026-05-13 16:43:01 +08:00
|
|
|
event = {
|
|
|
|
|
"type": "join",
|
|
|
|
|
"data": {
|
|
|
|
|
"chatGuid": "iMessage;-;group001",
|
|
|
|
|
"chat": {"guid": "iMessage;-;group001", "isGroup": True},
|
|
|
|
|
"message": {"guid": "msg_001", "sender": {"address": "+8613800138000"}},
|
|
|
|
|
},
|
|
|
|
|
}
|
2026-05-12 00:56:47 +08:00
|
|
|
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):
|
2026-05-13 16:43:01 +08:00
|
|
|
mock_get = AsyncMock(
|
|
|
|
|
return_value={
|
|
|
|
|
"status": "ok",
|
|
|
|
|
"data": {"iMessageConnected": True},
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-12 00:56:47 +08:00
|
|
|
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):
|
2026-05-13 16:43:01 +08:00
|
|
|
mock_get = AsyncMock(
|
|
|
|
|
return_value={
|
|
|
|
|
"status": "ok",
|
|
|
|
|
"data": {"iMessageConnected": False},
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-12 00:56:47 +08:00
|
|
|
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):
|
2026-05-13 16:43:01 +08:00
|
|
|
mock_get = AsyncMock(
|
|
|
|
|
return_value={
|
|
|
|
|
"status": "error",
|
|
|
|
|
"data": {"iMessageConnected": False},
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-12 00:56:47 +08:00
|
|
|
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(
|
2026-05-13 16:43:01 +08:00
|
|
|
adapter.__class__,
|
|
|
|
|
"pre_connect",
|
2026-05-12 00:56:47 +08:00
|
|
|
AsyncMock(return_value={"status": "ok", "imessage_connected": True}),
|
|
|
|
|
):
|
|
|
|
|
result = await adapter.pre_connect()
|
|
|
|
|
assert result["status"] == "ok"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestBlueBubblesAdapterTapback:
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def adapter(self):
|
2026-05-13 16:43:01 +08:00
|
|
|
return BlueBubblesAdapter(
|
|
|
|
|
{
|
|
|
|
|
"server_url": "http://localhost:1234",
|
|
|
|
|
"password": "test-password",
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-12 00:56:47 +08:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
)
|
2026-05-13 16:43:01 +08:00
|
|
|
|
2026-05-12 00:56:47 +08:00
|
|
|
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):
|
2026-05-13 16:43:01 +08:00
|
|
|
return BlueBubblesAdapter(
|
|
|
|
|
{
|
|
|
|
|
"server_url": "http://localhost:1234",
|
|
|
|
|
"password": "test-password",
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-12 00:56:47 +08:00
|
|
|
|
|
|
|
|
@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):
|
2026-05-13 16:43:01 +08:00
|
|
|
return BlueBubblesAdapter(
|
|
|
|
|
{
|
|
|
|
|
"server_url": "http://localhost:1234",
|
|
|
|
|
"password": "test-password",
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-12 00:56:47 +08:00
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
mock_post = AsyncMock(
|
|
|
|
|
side_effect=[
|
|
|
|
|
BlueBubblesHTTPError(500, "Server Error"),
|
|
|
|
|
{"guid": "msg_retry_001"},
|
|
|
|
|
]
|
|
|
|
|
)
|
2026-05-12 00:56:47 +08:00
|
|
|
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):
|
2026-05-13 16:43:01 +08:00
|
|
|
return BlueBubblesAdapter(
|
|
|
|
|
{
|
|
|
|
|
"server_url": "http://localhost:1234",
|
|
|
|
|
"password": "test-password",
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-12 00:56:47 +08:00
|
|
|
|
|
|
|
|
@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):
|
2026-05-13 16:43:01 +08:00
|
|
|
return BlueBubblesAdapter(
|
|
|
|
|
{
|
|
|
|
|
"server_url": "http://localhost:1234",
|
|
|
|
|
"password": "test-password",
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-12 00:56:47 +08:00
|
|
|
|
|
|
|
|
@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):
|
2026-05-13 16:43:01 +08:00
|
|
|
return BlueBubblesAdapter(
|
|
|
|
|
{
|
|
|
|
|
"server_url": "http://localhost:1234",
|
|
|
|
|
"password": "test-password",
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-12 00:56:47 +08:00
|
|
|
|
|
|
|
|
@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):
|
2026-05-13 16:43:01 +08:00
|
|
|
mock_post = AsyncMock(
|
|
|
|
|
side_effect=[
|
|
|
|
|
{"guid": "chunk_1"},
|
|
|
|
|
Exception("Chunk 2 failed"),
|
|
|
|
|
]
|
|
|
|
|
)
|
2026-05-12 00:56:47 +08:00
|
|
|
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):
|
2026-05-13 16:43:01 +08:00
|
|
|
return BlueBubblesAdapter(
|
|
|
|
|
{
|
|
|
|
|
"server_url": "http://localhost:1234",
|
|
|
|
|
"password": "test-password",
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-12 00:56:47 +08:00
|
|
|
|
|
|
|
|
@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):
|
2026-05-13 16:43:01 +08:00
|
|
|
return BlueBubblesAdapter(
|
|
|
|
|
{
|
|
|
|
|
"server_url": "http://localhost:1234",
|
|
|
|
|
"password": "test-password",
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-12 00:56:47 +08:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
async def on_disconnect():
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
async def on_reconnect():
|
|
|
|
|
pass
|
2026-05-12 00:56:47 +08:00
|
|
|
|
|
|
|
|
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):
|
2026-05-13 16:43:01 +08:00
|
|
|
return BlueBubblesAdapter(
|
|
|
|
|
{
|
|
|
|
|
"server_url": "http://localhost:1234",
|
|
|
|
|
"password": "test-password",
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-12 00:56:47 +08:00
|
|
|
|
|
|
|
|
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):
|
2026-05-13 16:43:01 +08:00
|
|
|
return BlueBubblesAdapter(
|
|
|
|
|
{
|
|
|
|
|
"server_url": "http://localhost:1234",
|
|
|
|
|
"password": "test-password",
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-12 00:56:47 +08:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
client = BlueBubblesClient("http://localhost:1234", "test", allow_private_network=True)
|
2026-05-12 00:56:47 +08:00
|
|
|
|
|
|
|
|
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
|
2026-05-13 16:43:01 +08:00
|
|
|
|
2026-05-12 00:56:47 +08:00
|
|
|
asyncio.run(client.download("/media/test.jpg"))
|
|
|
|
|
|
|
|
|
|
assert call_count == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestBlueBubblesLaneStreaming:
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def adapter(self):
|
2026-05-13 16:43:01 +08:00
|
|
|
return BlueBubblesAdapter(
|
|
|
|
|
{
|
|
|
|
|
"server_url": "http://localhost:1234",
|
|
|
|
|
"password": "test-password",
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-12 00:56:47 +08:00
|
|
|
|
|
|
|
|
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):
|
2026-05-13 16:43:01 +08:00
|
|
|
return BlueBubblesAdapter(
|
|
|
|
|
{
|
|
|
|
|
"server_url": "http://localhost:1234",
|
|
|
|
|
"password": "test-password",
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-12 00:56:47 +08:00
|
|
|
|
|
|
|
|
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):
|
2026-05-13 16:43:01 +08:00
|
|
|
adapter = BlueBubblesAdapter(
|
|
|
|
|
{
|
|
|
|
|
"server_url": "http://localhost:1234",
|
|
|
|
|
"password": "test-password",
|
|
|
|
|
"message_order_check": False,
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-12 00:56:47 +08:00
|
|
|
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
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
result = render_html_to_imessage("<p>Hello</p><script>alert('xss')</script><p>World</p>")
|
2026-05-12 00:56:47 +08:00
|
|
|
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()
|
2026-05-13 16:43:01 +08:00
|
|
|
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}),
|
|
|
|
|
),
|
2026-05-12 00:56:47 +08:00
|
|
|
):
|
|
|
|
|
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
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
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}),
|
|
|
|
|
),
|
2026-05-12 00:56:47 +08:00
|
|
|
):
|
|
|
|
|
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
|
2026-05-13 16:43:01 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|