新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
1543 lines
55 KiB
Python
1543 lines
55 KiB
Python
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.bluebubbles.adapter import BlueBubblesAdapter
|
|
from yuxi.channels.adapters.bluebubbles.config import BlueBubblesConfig
|
|
from yuxi.channels.adapters.bluebubbles.session import (
|
|
build_thread_key,
|
|
extract_chat_display_name,
|
|
resolve_chat_type,
|
|
)
|
|
from yuxi.channels.adapters.bluebubbles.send import resolve_tapback_value, TAPBACK_MAP
|
|
from yuxi.channels.models import (
|
|
Attachment,
|
|
ChannelIdentity,
|
|
ChannelMessage,
|
|
ChannelResponse,
|
|
ChannelStatus,
|
|
ChannelType,
|
|
ChatType,
|
|
EventType,
|
|
MessageType,
|
|
)
|
|
|
|
|
|
def make_dm_event(text="Hello world", sender="+8613800138000", chat_guid="iMessage;+;chat001", msg_guid="msg_001"):
|
|
return {
|
|
"type": "new-message",
|
|
"data": {
|
|
"chat": {"guid": chat_guid, "isGroup": False, "displayName": "John"},
|
|
"message": {
|
|
"guid": msg_guid,
|
|
"text": text,
|
|
"sender": {"address": sender},
|
|
"attachments": [],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def make_group_event(text="Hello group", sender="+8613800138000", chat_guid="iMessage;-;group001", msg_guid="msg_003"):
|
|
return {
|
|
"type": "new-message",
|
|
"data": {
|
|
"chat": {
|
|
"guid": chat_guid,
|
|
"isGroup": True,
|
|
"displayName": "ForcePilot Dev",
|
|
"participants": [
|
|
{"address": "+8613800138000"},
|
|
{"address": "+8613900139000"},
|
|
],
|
|
},
|
|
"message": {
|
|
"guid": msg_guid,
|
|
"text": text,
|
|
"sender": {"address": sender},
|
|
"attachments": [],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def make_image_event(chat_guid="iMessage;+;chat001", msg_guid="msg_002"):
|
|
return {
|
|
"type": "new-message",
|
|
"data": {
|
|
"chat": {"guid": chat_guid, "isGroup": False},
|
|
"message": {
|
|
"guid": msg_guid,
|
|
"text": "Check this photo",
|
|
"sender": {"address": "+8613800138000"},
|
|
"attachments": [
|
|
{
|
|
"mimeType": "image/jpeg",
|
|
"filePath": "/media/img001.jpg",
|
|
"fileName": "photo.jpg",
|
|
"fileSize": 1024000,
|
|
}
|
|
],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def make_response(chat_guid="iMessage;+;chat001", content="Hello from ForcePilot", msg_type=MessageType.TEXT):
|
|
return ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="bluebubbles",
|
|
channel_type=ChannelType.BLUEBUBBLES,
|
|
channel_user_id="+8613800138000",
|
|
channel_chat_id=chat_guid,
|
|
channel_message_id="msg_001",
|
|
),
|
|
message_type=msg_type,
|
|
content=content,
|
|
)
|
|
|
|
|
|
class TestBlueBubblesSession:
|
|
def test_resolve_direct_chat(self):
|
|
assert resolve_chat_type("iMessage;+;chat001") == ChatType.DIRECT
|
|
|
|
def test_resolve_group_chat(self):
|
|
assert resolve_chat_type("iMessage;-;group001") == ChatType.GROUP
|
|
|
|
def test_extract_chat_display_name(self):
|
|
chat_data = {"displayName": "John Doe"}
|
|
assert extract_chat_display_name(chat_data) == "John Doe"
|
|
|
|
def test_extract_chat_display_name_missing(self):
|
|
assert extract_chat_display_name({}) == ""
|
|
|
|
def test_build_thread_key_direct(self):
|
|
key = build_thread_key("agent_001", "iMessage;+;chat001")
|
|
assert "direct" in key
|
|
assert "chat001" in key
|
|
|
|
def test_build_thread_key_group(self):
|
|
key = build_thread_key("agent_001", "iMessage;-;group001")
|
|
assert "group" in key
|
|
assert "group001" in key
|
|
|
|
|
|
class TestBlueBubblesSend:
|
|
def test_tapback_map_complete(self):
|
|
assert len(TAPBACK_MAP) == 65
|
|
assert TAPBACK_MAP["love"] == 0
|
|
assert TAPBACK_MAP["heart"] == 0
|
|
assert TAPBACK_MAP["like"] == 1
|
|
assert TAPBACK_MAP["thumbs_up"] == 1
|
|
assert TAPBACK_MAP["dislike"] == 2
|
|
assert TAPBACK_MAP["thumbs_down"] == 2
|
|
assert TAPBACK_MAP["laugh"] == 3
|
|
assert TAPBACK_MAP["exclaim"] == 4
|
|
assert TAPBACK_MAP["question"] == 5
|
|
assert TAPBACK_MAP["haha"] == 3
|
|
assert "celebration" not in TAPBACK_MAP
|
|
|
|
def test_resolve_tapback_value_known(self):
|
|
assert resolve_tapback_value("love") == 0
|
|
assert resolve_tapback_value("heart") == 0
|
|
assert resolve_tapback_value("like") == 1
|
|
assert resolve_tapback_value("dislike") == 2
|
|
assert resolve_tapback_value("laugh") == 3
|
|
assert resolve_tapback_value("exclaim") == 4
|
|
assert resolve_tapback_value("question") == 5
|
|
|
|
def test_resolve_tapback_value_unknown(self):
|
|
assert resolve_tapback_value("rocket") is None
|
|
assert resolve_tapback_value("fire") is None
|
|
|
|
|
|
class TestBlueBubblesConfig:
|
|
def test_default_config(self):
|
|
config = BlueBubblesConfig()
|
|
assert config.enabled is False
|
|
assert config.server_url == "http://localhost:1234"
|
|
assert config.dm_policy == "pairing"
|
|
assert config.group_policy == "allowlist"
|
|
assert config.streaming_mode == "partial"
|
|
|
|
def test_from_env(self):
|
|
with patch.dict("os.environ", {
|
|
"BLUEBUBBLES_SERVER_URL": "http://mac.local:1234",
|
|
"BLUEBUBBLES_SERVER_PASSWORD": "secret123",
|
|
}):
|
|
config = BlueBubblesConfig.from_env()
|
|
assert config.server_url == "http://mac.local:1234"
|
|
assert config.password == "secret123"
|
|
|
|
def test_from_env_full(self):
|
|
with patch.dict("os.environ", {
|
|
"BLUEBUBBLES_ENABLED": "true",
|
|
"BLUEBUBBLES_SERVER_URL": "http://mac.local:1234",
|
|
"BLUEBUBBLES_SERVER_PASSWORD": "s3cret",
|
|
"BLUEBUBBLES_REQUEST_TIMEOUT": "15.0",
|
|
"BLUEBUBBLES_RECONNECT_MAX_DELAY": "120.0",
|
|
"BLUEBUBBLES_DM_POLICY": "allowlist",
|
|
"BLUEBUBBLES_GROUP_POLICY": "blocklist",
|
|
"BLUEBUBBLES_DM_ALLOW_FROM": "+8613800138000,+8613900139000",
|
|
"BLUEBUBBLES_GROUP_ALLOW_CHATS": "chat1,chat2",
|
|
"BLUEBUBBLES_MEDIA_MAX_MB": "50",
|
|
"BLUEBUBBLES_ENABLE_STICKERS": "false",
|
|
"BLUEBUBBLES_STREAMING_MODE": "block",
|
|
"BLUEBUBBLES_EDIT_INTERVAL_MS": "1000",
|
|
"BLUEBUBBLES_TAPBACK_ENABLED": "false",
|
|
}):
|
|
config = BlueBubblesConfig.from_env()
|
|
assert config.enabled is True
|
|
assert config.server_url == "http://mac.local:1234"
|
|
assert config.password == "s3cret"
|
|
assert config.request_timeout == 15.0
|
|
assert config.reconnect_max_delay == 120.0
|
|
assert config.dm_policy == "allowlist"
|
|
assert config.group_policy == "blocklist"
|
|
assert config.dm_allow_from == ["+8613800138000", "+8613900139000"]
|
|
assert config.group_allow_chats == ["chat1", "chat2"]
|
|
assert config.media_max_mb == 50
|
|
assert config.enable_stickers is False
|
|
assert config.streaming_mode == "block"
|
|
assert config.edit_interval_ms == 1000
|
|
assert config.tapback_enabled is False
|
|
|
|
|
|
class TestBlueBubblesAdapter:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
config = {
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
}
|
|
return BlueBubblesAdapter(config)
|
|
|
|
def test_channel_id(self, adapter):
|
|
assert adapter.channel_id == "bluebubbles"
|
|
|
|
def test_channel_type(self, adapter):
|
|
assert adapter.channel_type == ChannelType.BLUEBUBBLES
|
|
|
|
def test_capabilities(self, adapter):
|
|
assert adapter.supports_streaming is True
|
|
assert adapter.supports_markdown is True
|
|
assert "off" in adapter.streaming_modes
|
|
assert "partial" in adapter.streaming_modes
|
|
assert adapter.text_chunk_limit == 4096
|
|
assert adapter.max_media_size_mb == 100
|
|
|
|
def test_normalize_dm_message(self, adapter):
|
|
event = make_dm_event()
|
|
result = adapter.normalize_inbound(event)
|
|
|
|
assert isinstance(result, ChannelMessage)
|
|
assert result.message_type == MessageType.TEXT
|
|
assert result.content == "Hello world"
|
|
assert result.identity.channel_id == "bluebubbles"
|
|
assert result.identity.channel_type == ChannelType.BLUEBUBBLES
|
|
assert result.identity.channel_user_id == "+8613800138000"
|
|
assert result.identity.channel_chat_id == "iMessage;+;chat001"
|
|
assert result.identity.channel_message_id == "msg_001"
|
|
assert result.chat_type == ChatType.DIRECT
|
|
assert result.metadata["is_group"] is False
|
|
assert result.metadata["chat_display_name"] == "John"
|
|
|
|
def test_normalize_image_message(self, adapter):
|
|
event = make_image_event()
|
|
result = adapter.normalize_inbound(event)
|
|
|
|
assert result.message_type == MessageType.IMAGE
|
|
assert len(result.attachments) == 1
|
|
assert result.attachments[0].mime_type == "image/jpeg"
|
|
assert result.attachments[0].filename == "photo.jpg"
|
|
assert result.attachments[0].size_bytes == 1024000
|
|
|
|
def test_normalize_group_message(self, adapter):
|
|
event = make_group_event()
|
|
result = adapter.normalize_inbound(event)
|
|
|
|
assert result.chat_type == ChatType.GROUP
|
|
assert result.metadata["is_group"] is True
|
|
assert result.metadata["group_name"] == "ForcePilot Dev"
|
|
assert len(result.metadata["group_participants"]) == 2
|
|
|
|
def test_normalize_follow_event(self, adapter):
|
|
event = {"type": "join", "data": {
|
|
"chatGuid": "iMessage;-;group001",
|
|
"chat": {"guid": "iMessage;-;group001", "isGroup": True},
|
|
"message": {"guid": "msg_001", "sender": {"address": "+8613800138000"}},
|
|
}}
|
|
result = adapter.normalize_inbound(event)
|
|
assert result.chat_type == ChatType.GROUP
|
|
|
|
def test_normalize_typing_event(self, adapter):
|
|
event = {
|
|
"type": "typing-indicator",
|
|
"data": {"chatGuid": "iMessage;+;chat001", "senderGuid": "+8613800138000", "display": True},
|
|
}
|
|
result = adapter.normalize_inbound(event)
|
|
assert result.metadata["event"] == "typing"
|
|
assert result.metadata["display"] is True
|
|
|
|
def test_normalize_chat_name_change(self, adapter):
|
|
event = {
|
|
"type": "chat-name-change",
|
|
"data": {"chatGuid": "iMessage;-;group001", "newName": "New Group Name"},
|
|
}
|
|
result = adapter.normalize_inbound(event)
|
|
assert result.metadata["event"] == "chat_name_change"
|
|
assert result.metadata["new_name"] == "New Group Name"
|
|
|
|
def test_normalize_read_receipt(self, adapter):
|
|
event = {
|
|
"type": "chat-read-receipt",
|
|
"data": {"chatGuid": "iMessage;+;chat001", "senderGuid": "+8613800138000"},
|
|
}
|
|
result = adapter.normalize_inbound(event)
|
|
assert result.event_type == EventType.MESSAGE_UPDATED
|
|
|
|
def test_format_outbound_text(self, adapter):
|
|
response = make_response(content="Hello from ForcePilot")
|
|
result = adapter.format_outbound(response)
|
|
|
|
assert result["endpoint"] == "/api/v1/message/text"
|
|
assert result["payload"]["chatGuid"] == "iMessage;+;chat001"
|
|
assert result["payload"]["text"] == "Hello from ForcePilot"
|
|
|
|
def test_format_outbound_image(self, adapter):
|
|
response = make_response(msg_type=MessageType.IMAGE, content="Photo caption")
|
|
result = adapter.format_outbound(response)
|
|
|
|
assert result["endpoint"] == "/api/v1/message/attachment"
|
|
assert result["payload"]["chatGuid"] == "iMessage;+;chat001"
|
|
assert result["payload"]["caption"] == "Photo caption"
|
|
|
|
def test_format_outbound_video(self, adapter):
|
|
response = make_response(msg_type=MessageType.VIDEO, content="Video caption")
|
|
result = adapter.format_outbound(response)
|
|
|
|
assert result["endpoint"] == "/api/v1/message/attachment"
|
|
|
|
def test_format_outbound_file(self, adapter):
|
|
response = make_response(msg_type=MessageType.FILE)
|
|
result = adapter.format_outbound(response)
|
|
|
|
assert result["endpoint"] == "/api/v1/message/attachment"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_healthy(self, adapter):
|
|
mock_get = AsyncMock(return_value={
|
|
"status": "ok",
|
|
"data": {"iMessageConnected": True},
|
|
})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.get = mock_get
|
|
|
|
result = await adapter.health_check()
|
|
assert result.status == "healthy"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_imessage_disconnected(self, adapter):
|
|
mock_get = AsyncMock(return_value={
|
|
"status": "ok",
|
|
"data": {"iMessageConnected": False},
|
|
})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.get = mock_get
|
|
|
|
result = await adapter.health_check()
|
|
assert result.status == "degraded"
|
|
assert "iMessage not connected" in result.last_error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_server_unhealthy(self, adapter):
|
|
mock_get = AsyncMock(return_value={
|
|
"status": "error",
|
|
"data": {"iMessageConnected": False},
|
|
})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.get = mock_get
|
|
|
|
result = await adapter.health_check()
|
|
assert result.status == "unhealthy"
|
|
|
|
def test_normalize_with_reply_to(self, adapter):
|
|
event = make_dm_event(text="Reply text")
|
|
event["data"]["message"]["replyToGuid"] = "msg_original"
|
|
event["data"]["replyToGuid"] = "msg_original"
|
|
|
|
result = adapter.normalize_inbound(event)
|
|
assert result.metadata["reply_to_guid"] == "msg_original"
|
|
|
|
def test_normalize_unknown_event_type(self, adapter):
|
|
event = {
|
|
"type": "server-event",
|
|
"data": {"event": "started", "chatGuid": "unknown"},
|
|
}
|
|
result = adapter.normalize_inbound(event)
|
|
assert result.content == ""
|
|
assert result.metadata["event"] == "server-event"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_media_missing_attachment(self, adapter):
|
|
response = make_response(msg_type=MessageType.IMAGE, content="")
|
|
adapter._client = AsyncMock()
|
|
|
|
result = await adapter.send(response)
|
|
assert result.success is False
|
|
assert "Missing attachment" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_media_no_url(self, adapter):
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="bluebubbles",
|
|
channel_type=ChannelType.BLUEBUBBLES,
|
|
channel_user_id="",
|
|
channel_chat_id="iMessage;+;chat001",
|
|
),
|
|
message_type=MessageType.IMAGE,
|
|
content="Photo",
|
|
attachments=[Attachment(type="image", url="")],
|
|
)
|
|
adapter._client = AsyncMock()
|
|
|
|
result = await adapter.send(response)
|
|
assert result.success is False
|
|
assert "no URL" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pre_connect(self, adapter):
|
|
with patch.object(
|
|
adapter.__class__, "pre_connect",
|
|
AsyncMock(return_value={"status": "ok", "imessage_connected": True}),
|
|
):
|
|
result = await adapter.pre_connect()
|
|
assert result["status"] == "ok"
|
|
|
|
|
|
class TestBlueBubblesAdapterTapback:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter({
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
})
|
|
|
|
def test_tapback_normalize(self, adapter):
|
|
event = make_dm_event()
|
|
event["data"]["isTapback"] = True
|
|
event["data"]["message"]["tapback"] = 0
|
|
|
|
result = adapter.normalize_inbound(event)
|
|
assert "Loved" in result.content
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_reaction_valid(self, adapter):
|
|
mock_post = AsyncMock(return_value={"status": "ok"})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.post = mock_post
|
|
|
|
result = await adapter.send_reaction("chat_001", "msg_001", "love")
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_reaction_invalid(self, adapter):
|
|
adapter._client = AsyncMock()
|
|
|
|
result = await adapter.send_reaction("chat_001", "msg_001", "rocket")
|
|
assert result.success is False
|
|
assert "Unsupported" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_reaction_no_client(self, adapter):
|
|
result = await adapter.send_reaction("chat_001", "msg_001", "love")
|
|
assert result.success is False
|
|
assert "Client not initialized" in result.error
|
|
|
|
|
|
class TestBlueBubblesExceptions:
|
|
def test_import_exceptions(self):
|
|
from yuxi.channels.adapters.bluebubbles.exceptions import (
|
|
BlueBubblesError,
|
|
BlueBubblesAuthenticationError,
|
|
BlueBubblesConnectionError,
|
|
BlueBubblesHTTPError,
|
|
TapbackError,
|
|
)
|
|
assert issubclass(BlueBubblesAuthenticationError, BlueBubblesError)
|
|
assert issubclass(BlueBubblesConnectionError, BlueBubblesError)
|
|
assert issubclass(BlueBubblesHTTPError, BlueBubblesError)
|
|
assert issubclass(TapbackError, BlueBubblesError)
|
|
|
|
def test_http_error_attributes(self):
|
|
from yuxi.channels.adapters.bluebubbles.exceptions import BlueBubblesHTTPError
|
|
|
|
err = BlueBubblesHTTPError(500, "Internal Server Error")
|
|
assert err.status_code == 500
|
|
assert err.retryable is True
|
|
|
|
err2 = BlueBubblesHTTPError(400, "Bad Request")
|
|
assert err2.status_code == 400
|
|
assert err2.retryable is False
|
|
|
|
def test_auth_error_not_retryable(self):
|
|
from yuxi.channels.adapters.bluebubbles.exceptions import BlueBubblesAuthenticationError
|
|
|
|
err = BlueBubblesAuthenticationError("Bad password")
|
|
assert err.retryable is False
|
|
|
|
|
|
class TestBlueBubblesClient:
|
|
@pytest.fixture
|
|
def client(self):
|
|
from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient
|
|
|
|
return BlueBubblesClient(
|
|
server_url="http://localhost:1234",
|
|
password="test-password",
|
|
)
|
|
|
|
def test_base_url(self, client):
|
|
assert client.base_url == "http://localhost:1234"
|
|
|
|
def test_base_url_strips_trailing_slash(self):
|
|
from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient
|
|
|
|
client = BlueBubblesClient("http://localhost:1234/", "pw")
|
|
assert client.base_url == "http://localhost:1234"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_context_manager(self, client):
|
|
async with client as c:
|
|
assert c._client is not None
|
|
assert client._client is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_request_not_initialized(self, client):
|
|
with pytest.raises(Exception):
|
|
await client.get("/api/v1/server/info")
|
|
|
|
def test_append_auth_adds_password_query_param(self, client):
|
|
assert client._password == "test-password"
|
|
|
|
def test_append_auth_with_existing_query_params(self, client):
|
|
assert client._password == "test-password"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_put_method_exists(self, client):
|
|
assert hasattr(client, "put")
|
|
assert callable(client.put)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_method_exists(self, client):
|
|
assert hasattr(client, "delete")
|
|
assert callable(client.delete)
|
|
|
|
|
|
class TestBlueBubblesSendRetry:
|
|
@pytest.mark.asyncio
|
|
async def test_send_text_safe_retry_preserves_reply_to_guid(self):
|
|
from unittest.mock import AsyncMock
|
|
from yuxi.channels.adapters.bluebubbles.send import send_text_safe
|
|
from yuxi.channels.adapters.bluebubbles.exceptions import BlueBubblesHTTPError
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post.side_effect = [
|
|
BlueBubblesHTTPError(500, "Server Error"),
|
|
{"guid": "msg_retry_001"},
|
|
]
|
|
|
|
result = await send_text_safe(
|
|
mock_client,
|
|
"chat_001",
|
|
"Hello",
|
|
reply_to_guid="original_msg_001",
|
|
max_retries=2,
|
|
)
|
|
assert result.success is True
|
|
call_args = mock_client.post.call_args_list[1]
|
|
payload = call_args[1]["json"]
|
|
assert payload["replyToGuid"] == "original_msg_001"
|
|
|
|
|
|
class TestBlueBubblesStreaming:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter({
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_stream_chunk_no_client_sends_via_send(self, adapter):
|
|
result = await adapter.send_stream_chunk("chat_001", "msg_001", "streaming...", finished=False)
|
|
assert result.success is False
|
|
assert "Client not initialized" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_stream_chunk_with_client(self, adapter):
|
|
mock_put = AsyncMock(return_value={"status": "ok"})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.put = mock_put
|
|
|
|
result = await adapter.send_stream_chunk("chat_001", "msg_001", "streaming...", finished=False)
|
|
assert result.success is True
|
|
mock_put.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_stream_chunk_finished(self, adapter):
|
|
mock_put = AsyncMock(return_value={"status": "ok"})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.put = mock_put
|
|
|
|
result = await adapter.send_stream_chunk("chat_001", "msg_001", "final", finished=True)
|
|
assert result.success is True
|
|
|
|
|
|
class TestBlueBubblesClientAuth:
|
|
def test_client_no_bearer_auth_header(self):
|
|
from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient
|
|
|
|
client = BlueBubblesClient("http://localhost:1234", "pw")
|
|
assert not hasattr(client, "_headers")
|
|
assert client._password == "pw"
|
|
|
|
def test_append_auth_url_encoding(self):
|
|
from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient
|
|
|
|
client = BlueBubblesClient("http://localhost:1234", "p@ss!")
|
|
assert client._password == "p@ss!"
|
|
|
|
|
|
class TestBlueBubblesProbe:
|
|
@pytest.mark.asyncio
|
|
async def test_check_server_reachable_unreachable(self):
|
|
from yuxi.channels.adapters.bluebubbles.probe import check_server_reachable
|
|
|
|
result = await check_server_reachable(
|
|
server_url="http://nonexistent-host:1234",
|
|
password="test",
|
|
timeout=2.0,
|
|
)
|
|
assert result.status == "unhealthy"
|
|
|
|
|
|
class TestBlueBubblesAdapterSend:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter({
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_text_success(self, adapter):
|
|
mock_post = AsyncMock(return_value={"guid": "msg_send_001"})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.post = mock_post
|
|
|
|
response = make_response(content="Hello world")
|
|
result = await adapter.send(response)
|
|
|
|
assert result.success is True
|
|
assert result.message_id == "msg_send_001"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_text_no_client(self, adapter):
|
|
response = make_response(content="Hello")
|
|
result = await adapter.send(response)
|
|
assert result.success is False
|
|
assert "Client not initialized" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_text_with_retry_on_500(self, adapter):
|
|
from yuxi.channels.adapters.bluebubbles.exceptions import BlueBubblesHTTPError
|
|
|
|
mock_post = AsyncMock(side_effect=[
|
|
BlueBubblesHTTPError(500, "Server Error"),
|
|
{"guid": "msg_retry_001"},
|
|
])
|
|
adapter._client = AsyncMock()
|
|
adapter._client.post = mock_post
|
|
|
|
response = make_response(content="Retry me")
|
|
result = await adapter.send(response)
|
|
|
|
assert result.success is True
|
|
assert result.message_id == "msg_retry_001"
|
|
|
|
|
|
class TestBlueBubblesSendHtmlMessage:
|
|
@pytest.mark.asyncio
|
|
async def test_send_html_returns_delivery_result(self):
|
|
from yuxi.channels.adapters.bluebubbles.send import send_html_message
|
|
from yuxi.channels.models import DeliveryResult
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(return_value={"guid": "html_msg_001"})
|
|
|
|
result = await send_html_message(mock_client, "chat_001", "<b>Hello</b>")
|
|
assert isinstance(result, DeliveryResult)
|
|
assert result.success is True
|
|
assert result.message_id == "html_msg_001"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_html_with_message_guid_fallback(self):
|
|
from yuxi.channels.adapters.bluebubbles.send import send_html_message
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(return_value={"messageGuid": "fallback_001"})
|
|
|
|
result = await send_html_message(mock_client, "chat_001", "<b>Test</b>")
|
|
assert result.message_id == "fallback_001"
|
|
|
|
|
|
class TestBlueBubblesTapbackEmoji:
|
|
def test_resolve_tapback_emoji_heart(self):
|
|
assert resolve_tapback_value("love") == 0
|
|
assert resolve_tapback_value("heart") == 0
|
|
|
|
def test_resolve_tapback_emoji_thumbs(self):
|
|
assert resolve_tapback_value("like") == 1
|
|
assert resolve_tapback_value("dislike") == 2
|
|
|
|
def test_resolve_tapback_emoji_laugh(self):
|
|
assert resolve_tapback_value("laugh") == 3
|
|
|
|
def test_resolve_tapback_emoji_exclaim_question(self):
|
|
assert resolve_tapback_value("exclaim") == 4
|
|
assert resolve_tapback_value("question") == 5
|
|
|
|
|
|
class TestBlueBubblesDownloadMedia:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter({
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_media_no_client(self, adapter):
|
|
with pytest.raises(Exception):
|
|
await adapter.download_media("/media/test.jpg")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_media_with_client(self, adapter):
|
|
mock_get = AsyncMock()
|
|
mock_get.content = b"fake_image_data"
|
|
adapter._client = AsyncMock()
|
|
adapter._client._client = AsyncMock()
|
|
adapter._client._client.get = AsyncMock(return_value=mock_get)
|
|
adapter._client._base_url = "http://localhost:1234"
|
|
adapter._client._password = "test-password"
|
|
adapter._client.__class__ = type(
|
|
adapter._client.__class__.__name__,
|
|
adapter._client.__class__.__bases__,
|
|
{"download": AsyncMock(return_value=b"fake_image_data")},
|
|
)
|
|
adapter._client.download = AsyncMock(return_value=b"fake_image_data")
|
|
|
|
result = await adapter.download_media("/media/test.jpg")
|
|
assert result == b"fake_image_data"
|
|
|
|
|
|
class TestBlueBubblesStreamChunkEdgeCases:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter({
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_stream_chunk_no_client(self, adapter):
|
|
result = await adapter.send_stream_chunk("chat_001", "msg_001", "streaming...", finished=False)
|
|
assert result.success is False
|
|
assert "Client not initialized" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_stream_chunk_no_msg_id_creates_new(self, adapter):
|
|
mock_post = AsyncMock(return_value={"guid": "new_msg_001"})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.post = mock_post
|
|
|
|
result = await adapter.send_stream_chunk("chat_001", "", "first chunk", finished=False)
|
|
assert result.success is True
|
|
assert result.message_id == "new_msg_001"
|
|
|
|
|
|
class TestBlueBubblesClientUrlEncoding:
|
|
def test_append_auth_url_encodes_special_chars(self):
|
|
from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient
|
|
|
|
client = BlueBubblesClient("http://localhost:1234", "p@ss w!rd")
|
|
assert client._password == "p@ss w!rd"
|
|
|
|
|
|
class TestBlueBubblesChunkedSend:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter({
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_within_limit_not_chunked(self, adapter):
|
|
mock_post = AsyncMock(return_value={"guid": "msg_001"})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.post = mock_post
|
|
|
|
response = make_response(content="Short message")
|
|
result = await adapter.send(response)
|
|
|
|
assert result.success is True
|
|
mock_post.assert_called_once()
|
|
call_payload = mock_post.call_args[1]["json"]
|
|
assert call_payload["text"] == "Short message"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_exceeds_limit_chunked(self, adapter):
|
|
mock_post = AsyncMock(return_value={"guid": "msg_001"})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.post = mock_post
|
|
|
|
long_text = "x" * (adapter.text_chunk_limit + 100)
|
|
response = make_response(content=long_text)
|
|
result = await adapter.send(response)
|
|
|
|
assert result.success is True
|
|
assert mock_post.call_count == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_chunked_first_chunk_has_reply_to(self, adapter):
|
|
mock_post = AsyncMock(return_value={"guid": "msg_001"})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.post = mock_post
|
|
|
|
long_text = "x" * (adapter.text_chunk_limit + 100)
|
|
identity = ChannelIdentity(
|
|
channel_id="bluebubbles",
|
|
channel_type=ChannelType.BLUEBUBBLES,
|
|
channel_user_id="+8613800138000",
|
|
channel_chat_id="chat_001",
|
|
)
|
|
response = ChannelResponse(
|
|
identity=identity,
|
|
content=long_text,
|
|
reply_to_message_id="original_001",
|
|
)
|
|
await adapter.send(response)
|
|
|
|
first_call_payload = mock_post.call_args_list[0][1]["json"]
|
|
second_call_payload = mock_post.call_args_list[1][1]["json"]
|
|
assert first_call_payload["replyToGuid"] == "original_001"
|
|
assert "replyToGuid" not in second_call_payload
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_chunked_second_chunk_failure(self, adapter):
|
|
mock_post = AsyncMock(side_effect=[
|
|
{"guid": "chunk_1"},
|
|
Exception("Chunk 2 failed"),
|
|
])
|
|
adapter._client = AsyncMock()
|
|
adapter._client.post = mock_post
|
|
|
|
long_text = "x" * (adapter.text_chunk_limit + 100)
|
|
response = make_response(content=long_text)
|
|
result = await adapter.send(response)
|
|
|
|
assert result.success is False
|
|
assert "Chunk 2 failed" in result.error
|
|
|
|
|
|
class TestBlueBubblesTypingReadReceipt:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter({
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_typing_with_client(self, adapter):
|
|
mock_post = AsyncMock(return_value={"status": "ok"})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.post = mock_post
|
|
|
|
await adapter.send_typing("chat_001", display=True)
|
|
mock_post.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_typing_no_client(self, adapter):
|
|
await adapter.send_typing("chat_001", display=True)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_read_with_client(self, adapter):
|
|
mock_post = AsyncMock(return_value={"status": "ok"})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.post = mock_post
|
|
|
|
await adapter.send_read("chat_001")
|
|
mock_post.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_read_no_client(self, adapter):
|
|
await adapter.send_read("chat_001")
|
|
|
|
|
|
class TestBlueBubblesReconnectStatus:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter({
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
})
|
|
|
|
def test_initial_status_disconnected(self, adapter):
|
|
assert adapter._status == ChannelStatus.DISCONNECTED
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_disconnect_sets_reconnecting(self, adapter):
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
await adapter._on_ws_disconnect()
|
|
assert adapter._status == ChannelStatus.RECONNECTING
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_disconnect_when_not_connected_noop(self, adapter):
|
|
adapter._status = ChannelStatus.DISCONNECTED
|
|
await adapter._on_ws_disconnect()
|
|
assert adapter._status == ChannelStatus.DISCONNECTED
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_reconnect_sets_connected(self, adapter):
|
|
adapter._status = ChannelStatus.RECONNECTING
|
|
await adapter._on_ws_reconnect()
|
|
assert adapter._status == ChannelStatus.CONNECTED
|
|
|
|
|
|
class TestBlueBubblesMonitorJitter:
|
|
def test_monitor_accepts_disconnect_reconnect_callbacks(self):
|
|
from yuxi.channels.adapters.bluebubbles.monitor import BlueBubblesMonitor
|
|
|
|
async def on_disconnect(): pass
|
|
async def on_reconnect(): pass
|
|
|
|
monitor = BlueBubblesMonitor(
|
|
server_url="http://localhost:1234",
|
|
password="test",
|
|
on_event=AsyncMock(),
|
|
on_disconnect=on_disconnect,
|
|
on_reconnect=on_reconnect,
|
|
)
|
|
assert monitor._on_disconnect is on_disconnect
|
|
assert monitor._on_reconnect is on_reconnect
|
|
|
|
|
|
class TestBlueBubblesResolveMessageType:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter({
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
})
|
|
|
|
def test_location_message_type(self, adapter):
|
|
msg = {"location": {"latitude": 31.23, "longitude": 121.47}}
|
|
result = adapter._resolve_message_type(msg, [])
|
|
assert result == MessageType.LOCATION
|
|
|
|
def test_location_missing_coords_falls_through(self, adapter):
|
|
msg = {"location": {"address": "Shanghai"}}
|
|
result = adapter._resolve_message_type(msg, [])
|
|
assert result == MessageType.TEXT
|
|
|
|
def test_sticker_message_type(self, adapter):
|
|
msg = {"sticker": {"stickerDescription": "smiley"}}
|
|
result = adapter._resolve_message_type(msg, [])
|
|
assert result == MessageType.STICKER
|
|
|
|
def test_contact_message_type(self, adapter):
|
|
msg = {"vcard": {"name": "Alice"}}
|
|
result = adapter._resolve_message_type(msg, [])
|
|
assert result == MessageType.CARD
|
|
|
|
def test_location_priority_over_attachment(self, adapter):
|
|
msg = {"location": {"latitude": 1.0, "longitude": 2.0}}
|
|
att = [Attachment(url="/img.png", mime_type="image/png")]
|
|
result = adapter._resolve_message_type(msg, att)
|
|
assert result == MessageType.LOCATION
|
|
|
|
def test_sticker_priority_over_attachment(self, adapter):
|
|
msg = {"sticker": {"stickerDescription": "wave"}}
|
|
att = [Attachment(url="/img.png", mime_type="image/png")]
|
|
result = adapter._resolve_message_type(msg, att)
|
|
assert result == MessageType.STICKER
|
|
|
|
|
|
class TestBlueBubblesSpecialContent:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter({
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
})
|
|
|
|
def test_location_content_with_address(self, adapter):
|
|
msg = {"location": {"latitude": 22.5, "longitude": 114.1, "address": "HK"}}
|
|
content = adapter._extract_special_content(msg)
|
|
assert "22.5" in content
|
|
assert "114.1" in content
|
|
assert "HK" in content
|
|
|
|
def test_location_content_without_address(self, adapter):
|
|
msg = {"location": {"latitude": 10.0, "longitude": 20.0}}
|
|
content = adapter._extract_special_content(msg)
|
|
assert "10.0" in content
|
|
assert "Location:" in content
|
|
|
|
def test_sticker_content(self, adapter):
|
|
msg = {"sticker": {"stickerDescription": "party popper"}}
|
|
content = adapter._extract_special_content(msg)
|
|
assert content == "party popper"
|
|
|
|
def test_sticker_fallback(self, adapter):
|
|
msg = {"sticker": {}}
|
|
content = adapter._extract_special_content(msg)
|
|
assert content == "[Sticker]"
|
|
|
|
def test_contact_content(self, adapter):
|
|
msg = {"contactDetails": {"name": "Bob"}}
|
|
content = adapter._extract_special_content(msg)
|
|
assert content == "Bob"
|
|
|
|
def test_empty_message(self, adapter):
|
|
content = adapter._extract_special_content({"text": "hello"})
|
|
assert content == ""
|
|
|
|
|
|
class TestBlueBubblesActivityUpdate:
|
|
@pytest.mark.asyncio
|
|
async def test_update_activity_includes_ishtml(self):
|
|
from yuxi.channels.adapters.bluebubbles.send import update_activity
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.put = AsyncMock(return_value={})
|
|
|
|
await update_activity(mock_client, "chat_001", "act_001", "**bold**")
|
|
|
|
call_kwargs = mock_client.put.call_args
|
|
assert call_kwargs[1]["json"]["isHTML"] is True
|
|
|
|
|
|
class TestBlueBubblesDownloadCircuitBreaker:
|
|
def test_download_uses_circuit_breaker(self):
|
|
from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient
|
|
|
|
client = BlueBubblesClient(
|
|
"http://localhost:1234", "test", allow_private_network=True
|
|
)
|
|
|
|
original_call = client._breaker.call
|
|
|
|
call_count = 0
|
|
|
|
def counting_call(fn):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return original_call(fn)
|
|
|
|
client._breaker.call = counting_call
|
|
|
|
client._client = AsyncMock()
|
|
mock_resp = AsyncMock()
|
|
mock_resp.content = b"data"
|
|
client._client.get = AsyncMock(return_value=mock_resp)
|
|
|
|
import asyncio
|
|
asyncio.run(client.download("/media/test.jpg"))
|
|
|
|
assert call_count == 1
|
|
|
|
|
|
class TestBlueBubblesLaneStreaming:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter({
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
})
|
|
|
|
def test_lane_buffers_initialized(self, adapter):
|
|
assert isinstance(adapter._lane_buffers, dict)
|
|
assert isinstance(adapter._lane_sent, dict)
|
|
assert len(adapter._lane_buffers) == 0
|
|
assert len(adapter._lane_sent) == 0
|
|
|
|
def test_compose_stream_content_no_lane(self, adapter):
|
|
result = adapter._compose_stream_content("Hello", "", False)
|
|
assert result == "Hello"
|
|
|
|
def test_compose_stream_content_with_lane(self, adapter):
|
|
result = adapter._compose_stream_content("Answer", "Thinking...", False)
|
|
assert "<details" in result
|
|
assert "Thinking..." in result
|
|
assert "Answer" in result
|
|
|
|
def test_compose_stream_content_finished(self, adapter):
|
|
result = adapter._compose_stream_content("Answer", "Thinking...", True)
|
|
assert "<details " in result
|
|
assert "open" not in result
|
|
|
|
def test_cleanup_stream_key(self, adapter):
|
|
key = "chat:msg"
|
|
adapter._stream_buffers[key] = "test"
|
|
adapter._lane_buffers[key] = "lane"
|
|
adapter._stream_sent[key] = "sent"
|
|
adapter._lane_sent[key] = "lane_sent"
|
|
adapter._last_edit_at[key] = 1.0
|
|
|
|
adapter._cleanup_stream_key(key)
|
|
assert key not in adapter._stream_buffers
|
|
assert key not in adapter._lane_buffers
|
|
assert key not in adapter._stream_sent
|
|
assert key not in adapter._lane_sent
|
|
assert key not in adapter._last_edit_at
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_lane_chunk_no_client(self, adapter):
|
|
result = await adapter.send_lane_chunk("chat_001", "msg_001", "thinking...")
|
|
assert result.success is False
|
|
|
|
|
|
class TestBlueBubblesReorderBuffer:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter({
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
})
|
|
|
|
def test_reorder_buffer_created_when_enabled(self, adapter):
|
|
assert adapter._reorder_buffer is not None
|
|
assert adapter._message_order_check is True
|
|
|
|
def test_reorder_buffer_disabled(self):
|
|
adapter = BlueBubblesAdapter({
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
"message_order_check": False,
|
|
})
|
|
assert adapter._reorder_buffer is None
|
|
|
|
def test_inbound_reorder_buffer_extract_timestamp(self):
|
|
from yuxi.channels.adapters.bluebubbles.monitor import InboundReorderBuffer
|
|
|
|
event = {
|
|
"type": "new-message",
|
|
"data": {
|
|
"message": {"dateCreated": 1000000},
|
|
},
|
|
}
|
|
ts = InboundReorderBuffer._extract_timestamp(event)
|
|
assert ts == 1000000.0
|
|
|
|
def test_inbound_reorder_buffer_no_timestamp(self):
|
|
from yuxi.channels.adapters.bluebubbles.monitor import InboundReorderBuffer
|
|
|
|
event = {"type": "new-message", "data": {}}
|
|
ts = InboundReorderBuffer._extract_timestamp(event)
|
|
assert ts > 0
|
|
|
|
|
|
class TestBlueBubblesSendSticker:
|
|
@pytest.mark.asyncio
|
|
async def test_send_sticker_bytes(self):
|
|
from yuxi.channels.adapters.bluebubbles.send import send_sticker
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(return_value={"guid": "sticker_001"})
|
|
|
|
result = await send_sticker(mock_client, "chat_001", b"fake_png_data")
|
|
assert result.success is True
|
|
assert result.message_id == "sticker_001"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_sticker_no_valid_data(self):
|
|
from yuxi.channels.adapters.bluebubbles.send import send_sticker
|
|
|
|
mock_client = AsyncMock()
|
|
result = await send_sticker(mock_client, "chat_001", {})
|
|
assert result.success is False
|
|
assert "No valid sticker data" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_sticker_with_url(self):
|
|
from yuxi.channels.adapters.bluebubbles.send import send_sticker
|
|
from unittest.mock import patch, AsyncMock as AsyncMock2
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(return_value={"guid": "sticker_url_001"})
|
|
|
|
with patch("httpx.AsyncClient") as mock_http:
|
|
mock_resp = AsyncMock2()
|
|
mock_resp.content = b"fake_image"
|
|
mock_resp.raise_for_status = lambda: None
|
|
mock_http.return_value.__aenter__.return_value.get = AsyncMock2(return_value=mock_resp)
|
|
|
|
result = await send_sticker(
|
|
mock_client,
|
|
"chat_001",
|
|
{"url": "https://example.com/sticker.png"},
|
|
)
|
|
assert result.success is True
|
|
|
|
|
|
class TestBlueBubblesAiVision:
|
|
def test_vision_disabled_by_default(self):
|
|
from yuxi.channels.adapters.bluebubbles.ai_vision import BlueBubblesVision
|
|
|
|
vision = BlueBubblesVision()
|
|
assert vision.enabled is False
|
|
|
|
def test_vision_cache(self):
|
|
from yuxi.channels.adapters.bluebubbles.ai_vision import VisionCache
|
|
|
|
cache = VisionCache(max_entries=3)
|
|
cache.put("hash1", {"description": "test"})
|
|
assert cache.get("hash1") == {"description": "test"}
|
|
assert cache.size == 1
|
|
|
|
cache.clear()
|
|
assert cache.size == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_analyze_message_media_disabled(self):
|
|
from yuxi.channels.adapters.bluebubbles.ai_vision import analyze_message_media, BlueBubblesVision
|
|
|
|
vision = BlueBubblesVision()
|
|
result = await analyze_message_media(vision, b"data", "test.jpg", "image/jpeg")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_analyze_non_image_skipped(self):
|
|
from yuxi.channels.adapters.bluebubbles.ai_vision import analyze_message_media, BlueBubblesVision
|
|
|
|
vision = BlueBubblesVision({"ai_vision_enabled": True, "ai_vision_api_url": "http://api"})
|
|
result = await analyze_message_media(vision, b"data", "test.mp4", "video/mp4")
|
|
assert result is None
|
|
|
|
|
|
class TestBlueBubblesTemplates:
|
|
def test_render_card_full(self):
|
|
from yuxi.channels.adapters.bluebubbles.templates import render_card
|
|
|
|
result = render_card(
|
|
title="Test Card",
|
|
body="Body text",
|
|
subtitle="Subtitle",
|
|
image_url="https://example.com/img.png",
|
|
footer="Footer",
|
|
)
|
|
assert "<b>Test Card</b>" in result
|
|
assert "Body text" in result
|
|
assert "<i>Subtitle</i>" in result
|
|
assert "img.png" in result
|
|
assert "Footer" in result
|
|
|
|
def test_render_card_minimal(self):
|
|
from yuxi.channels.adapters.bluebubbles.templates import render_card
|
|
|
|
result = render_card(body="Just body")
|
|
assert result == "Just body"
|
|
|
|
def test_render_notification(self):
|
|
from yuxi.channels.adapters.bluebubbles.templates import render_notification
|
|
|
|
result = render_notification("Alert", "Something happened", "warning")
|
|
assert "⚠️" in result
|
|
assert "<b>Alert</b>" in result
|
|
assert "Something happened" in result
|
|
|
|
def test_render_items(self):
|
|
from yuxi.channels.adapters.bluebubbles.templates import render_items
|
|
|
|
items = [{"label": "Name", "value": "Alice"}, {"label": "Age", "value": "30"}]
|
|
result = render_items(items)
|
|
assert "Name: Alice" in result
|
|
assert "Age: 30" in result
|
|
|
|
def test_render_button_row(self):
|
|
from yuxi.channels.adapters.bluebubbles.templates import render_button_row
|
|
|
|
buttons = [
|
|
{"label": "Click", "url": "https://example.com"},
|
|
{"label": "No URL"},
|
|
]
|
|
result = render_button_row(buttons)
|
|
assert "Click" in result
|
|
assert "https://example.com" in result
|
|
assert "No URL" in result
|
|
|
|
|
|
class TestBlueBubblesRenderer:
|
|
def test_render_html_strips_script(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_html_to_imessage
|
|
|
|
result = render_html_to_imessage(
|
|
"<p>Hello</p><script>alert('xss')</script><p>World</p>"
|
|
)
|
|
assert "Hello" in result
|
|
assert "World" in result
|
|
assert "script" not in result.lower()
|
|
|
|
def test_render_html_converts_strong(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_html_to_imessage
|
|
|
|
result = render_html_to_imessage("<strong>Bold</strong>")
|
|
assert "<b>Bold</b>" in result
|
|
|
|
def test_render_html_converts_em(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_html_to_imessage
|
|
|
|
result = render_html_to_imessage("<em>Italic</em>")
|
|
assert "<i>Italic</i>" in result
|
|
|
|
def test_render_html_converts_li(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_html_to_imessage
|
|
|
|
result = render_html_to_imessage("<ul><li>Item 1</li><li>Item 2</li></ul>")
|
|
assert "• Item 1" in result
|
|
assert "• Item 2" in result
|
|
|
|
def test_render_html_empty(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_html_to_imessage
|
|
|
|
assert render_html_to_imessage("") == ""
|
|
|
|
def test_render_markdown_bold(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage
|
|
|
|
result = render_markdown_to_imessage("**bold text**")
|
|
assert "<b>bold text</b>" in result
|
|
|
|
def test_render_markdown_italic(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage
|
|
|
|
result = render_markdown_to_imessage("*italic text*")
|
|
assert "<i>italic text</i>" in result
|
|
|
|
|
|
class TestBlueBubblesStreamingEnhanced:
|
|
def test_stream_buffer(self):
|
|
from yuxi.channels.adapters.bluebubbles.streaming_enhanced import StreamBuffer
|
|
|
|
buf = StreamBuffer(max_buffer_size=100)
|
|
result = buf.append("s1", "Hello ")
|
|
assert result == "Hello "
|
|
result = buf.append("s1", "World")
|
|
assert result == "Hello World"
|
|
assert buf.get("s1") == "Hello World"
|
|
|
|
buf.clear("s1")
|
|
assert buf.get("s1") == ""
|
|
|
|
def test_streaming_typing_sync_init(self):
|
|
from yuxi.channels.adapters.bluebubbles.streaming_enhanced import StreamingTypingSync
|
|
|
|
sync = StreamingTypingSync()
|
|
assert sync._active_chats == set()
|
|
assert not sync._running
|
|
|
|
|
|
class TestBlueBubblesDoctor:
|
|
@pytest.mark.asyncio
|
|
async def test_run_doctor_enhanced(self):
|
|
from yuxi.channels.adapters.bluebubbles.doctor import run_doctor
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(return_value={
|
|
"data": {
|
|
"iMessageConnected": True,
|
|
"private_api": True,
|
|
"os_version": "15.0",
|
|
"websocket": True,
|
|
"attachmentRoot": "/media",
|
|
},
|
|
})
|
|
|
|
with patch(
|
|
"yuxi.channels.adapters.bluebubbles.doctor._check_websocket_endpoint",
|
|
AsyncMock(return_value=True),
|
|
), patch(
|
|
"yuxi.channels.adapters.bluebubbles.doctor._check_attachment_storage",
|
|
AsyncMock(return_value=True),
|
|
), patch(
|
|
"yuxi.channels.adapters.bluebubbles.doctor._check_message_history",
|
|
AsyncMock(return_value={"available": True, "count": 5}),
|
|
):
|
|
result = await run_doctor(mock_client)
|
|
assert result["server_reachable"] is True
|
|
assert result["websocket_reachable"] is True
|
|
assert result["attachment_storage_ok"] is True
|
|
assert result["message_history_available"] is True
|
|
assert result["recent_message_count"] == 5
|
|
|
|
|
|
class TestBlueBubblesCacheManager:
|
|
def test_cache_manager_register(self):
|
|
from yuxi.channels.adapters.bluebubbles.cache_manager import CacheManager
|
|
|
|
mgr = CacheManager(max_cache_entries=100)
|
|
cache = mgr.register("test_cache", ttl_seconds=60)
|
|
cache.set("key1", "value1")
|
|
assert cache.get("key1") == "value1"
|
|
|
|
def test_cache_manager_stats(self):
|
|
from yuxi.channels.adapters.bluebubbles.cache_manager import CacheManager
|
|
|
|
mgr = CacheManager()
|
|
cache = mgr.register("stats_cache")
|
|
cache.set("k1", "v1")
|
|
cache.get("k1")
|
|
cache.get("missing")
|
|
|
|
stats = mgr.get_all_stats()
|
|
assert "stats_cache" in stats
|
|
assert stats["stats_cache"]["hits"] == 1
|
|
assert stats["stats_cache"]["misses"] == 1
|
|
|
|
def test_cache_manager_clear_all(self):
|
|
from yuxi.channels.adapters.bluebubbles.cache_manager import CacheManager
|
|
|
|
mgr = CacheManager()
|
|
c1 = mgr.register("c1")
|
|
c2 = mgr.register("c2")
|
|
c1.set("k", "v")
|
|
c2.set("k", "v")
|
|
|
|
mgr.clear_all()
|
|
assert c1.size == 0
|
|
assert c2.size == 0
|
|
|
|
def test_unified_cache_ttl(self):
|
|
from yuxi.channels.adapters.bluebubbles.cache_manager import UnifiedCache
|
|
import time
|
|
|
|
cache = UnifiedCache("test", ttl_seconds=0.01)
|
|
cache.set("key", "value")
|
|
time.sleep(0.02)
|
|
assert cache.get("key") is None
|
|
|
|
def test_unified_cache_eviction(self):
|
|
from yuxi.channels.adapters.bluebubbles.cache_manager import UnifiedCache
|
|
|
|
cache = UnifiedCache("test", max_entries=3)
|
|
for i in range(5):
|
|
cache.set(f"key{i}", f"value{i}")
|
|
assert cache.size <= 3
|
|
|
|
|
|
class TestBlueBubblesSetupWizard:
|
|
@pytest.mark.asyncio
|
|
async def test_run_setup_wizard_basic(self):
|
|
from yuxi.channels.adapters.bluebubbles.setup_wizard import run_setup_wizard
|
|
|
|
with patch(
|
|
"yuxi.channels.adapters.bluebubbles.setup_wizard.validate_credentials",
|
|
AsyncMock(return_value={"valid": True}),
|
|
), patch(
|
|
"yuxi.channels.adapters.bluebubbles.setup_wizard.validate_server_url",
|
|
AsyncMock(return_value={"reachable": True}),
|
|
):
|
|
result = await run_setup_wizard(
|
|
server_url="http://mac.local:1234",
|
|
password="secret",
|
|
)
|
|
assert "config" in result
|
|
assert result["config"]["server_url"] == "http://mac.local:1234"
|
|
assert result["complete"] is True
|
|
|
|
def test_wizard_steps_complete(self):
|
|
from yuxi.channels.adapters.bluebubbles.setup_wizard import BlueBubblesSetupWizard
|
|
|
|
wizard = BlueBubblesSetupWizard()
|
|
assert not wizard.is_complete
|
|
|
|
wizard.mark_step_complete("server_url")
|
|
wizard.mark_step_complete("password")
|
|
assert wizard.is_complete
|
|
|
|
def test_wizard_get_next_step(self):
|
|
from yuxi.channels.adapters.bluebubbles.setup_wizard import BlueBubblesSetupWizard
|
|
|
|
wizard = BlueBubblesSetupWizard()
|
|
step = wizard.get_next_step()
|
|
assert step is not None
|
|
assert step["id"] == "server_url"
|
|
|
|
|
|
class TestBlueBubblesAccountsMonitor:
|
|
def test_accounts_monitor_register(self):
|
|
from yuxi.channels.adapters.bluebubbles.accounts_monitor import AccountsMonitor
|
|
from yuxi.channels.adapters.bluebubbles.accounts import BlueBubblesAccountConfig
|
|
|
|
monitor = AccountsMonitor()
|
|
config = BlueBubblesAccountConfig(
|
|
account_id="test_account",
|
|
server_url="http://localhost:1234",
|
|
password="secret",
|
|
)
|
|
monitor.register_account("test_account", config)
|
|
health = monitor.get_health("test_account")
|
|
assert health is not None
|
|
assert health.account_id == "test_account"
|
|
|
|
def test_accounts_monitor_summary(self):
|
|
from yuxi.channels.adapters.bluebubbles.accounts_monitor import AccountsMonitor
|
|
from yuxi.channels.adapters.bluebubbles.accounts import BlueBubblesAccountConfig
|
|
|
|
monitor = AccountsMonitor()
|
|
for i in range(3):
|
|
config = BlueBubblesAccountConfig(
|
|
account_id=f"account_{i}",
|
|
server_url="http://localhost:1234",
|
|
password="secret",
|
|
)
|
|
monitor.register_account(f"account_{i}", config)
|
|
|
|
summary = monitor.get_summary()
|
|
assert summary["total_accounts"] == 3
|
|
assert summary["unhealthy"] == 3
|
|
|
|
|
|
class TestBlueBubblesClientPool:
|
|
def test_client_pool_acquire(self):
|
|
from yuxi.channels.adapters.bluebubbles.client_pool import ClientPool
|
|
|
|
pool = ClientPool(pool_size=2)
|
|
pool.configure("http://localhost:1234", "secret")
|
|
assert pool.size == 0
|
|
assert pool.available == 0
|
|
|
|
def test_client_pool_initial_config(self):
|
|
from yuxi.channels.adapters.bluebubbles.client_pool import ClientPool
|
|
|
|
pool = ClientPool()
|
|
pool.configure("http://localhost:1234", "pw")
|
|
assert pool._config["server_url"] == "http://localhost:1234"
|
|
assert pool._config["password"] == "pw"
|
|
|
|
|
|
class TestBlueBubblesConfigExtended:
|
|
def test_new_config_fields(self):
|
|
config = BlueBubblesConfig()
|
|
assert config.ai_vision_enabled is False
|
|
assert config.streaming_typing_indicator is True
|
|
assert config.message_order_check is True
|
|
assert config.max_cache_entries == 2048
|