1. 移除Telegram格式化测试中未使用的导入项 2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关 3. 更新钉钉适配器测试,替换弃用的流属性检查 4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑 5. 重构会话映射测试,完善数据库执行结果模拟 6. 格式化Slack块构建测试的长参数调用 7. 修复LINE适配器测试,更新能力断言和异步锁使用 8. 修正Slack会话解析测试,修复聊天类型判断错误 9. 更新能力测试,补充缺失的字段检查 10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑 11. 为飞书分析模块测试添加跳过标记 12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试 13. 修复Twitch适配器导入路径和测试断言 14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试 15. 修复Manager阶段测试的导入路径 16. 新增iMessage异常和命令处理的单元测试 17. 新增Nostr健康检查和相关模块的单元测试 18. 新增Signal守护进程和SSE重连相关测试
2639 lines
93 KiB
Python
2639 lines
93 KiB
Python
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.bluebubbles.adapter import BlueBubblesAdapter
|
|
from yuxi.channels.adapters.bluebubbles.config import BlueBubblesConfig
|
|
from yuxi.channels.adapters.bluebubbles.session import (
|
|
build_thread_key,
|
|
extract_chat_display_name,
|
|
resolve_chat_type,
|
|
)
|
|
from yuxi.channels.adapters.bluebubbles.send import resolve_tapback_value, TAPBACK_MAP
|
|
from yuxi.channels.models import (
|
|
Attachment,
|
|
ChannelIdentity,
|
|
ChannelMessage,
|
|
ChannelResponse,
|
|
ChannelStatus,
|
|
ChannelType,
|
|
ChatType,
|
|
EventType,
|
|
MessageType,
|
|
)
|
|
|
|
|
|
def make_dm_event(text="Hello world", sender="+8613800138000", chat_guid="iMessage;+;chat001", msg_guid="msg_001"):
|
|
return {
|
|
"type": "new-message",
|
|
"data": {
|
|
"chat": {"guid": chat_guid, "isGroup": False, "displayName": "John"},
|
|
"message": {
|
|
"guid": msg_guid,
|
|
"text": text,
|
|
"sender": {"address": sender},
|
|
"attachments": [],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def make_group_event(text="Hello group", sender="+8613800138000", chat_guid="iMessage;-;group001", msg_guid="msg_003"):
|
|
return {
|
|
"type": "new-message",
|
|
"data": {
|
|
"chat": {
|
|
"guid": chat_guid,
|
|
"isGroup": True,
|
|
"displayName": "ForcePilot Dev",
|
|
"participants": [
|
|
{"address": "+8613800138000"},
|
|
{"address": "+8613900139000"},
|
|
],
|
|
},
|
|
"message": {
|
|
"guid": msg_guid,
|
|
"text": text,
|
|
"sender": {"address": sender},
|
|
"attachments": [],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def make_image_event(chat_guid="iMessage;+;chat001", msg_guid="msg_002"):
|
|
return {
|
|
"type": "new-message",
|
|
"data": {
|
|
"chat": {"guid": chat_guid, "isGroup": False},
|
|
"message": {
|
|
"guid": msg_guid,
|
|
"text": "Check this photo",
|
|
"sender": {"address": "+8613800138000"},
|
|
"attachments": [
|
|
{
|
|
"mimeType": "image/jpeg",
|
|
"filePath": "/media/img001.jpg",
|
|
"fileName": "photo.jpg",
|
|
"fileSize": 1024000,
|
|
}
|
|
],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def make_response(chat_guid="iMessage;+;chat001", content="Hello from ForcePilot", msg_type=MessageType.TEXT):
|
|
return ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="bluebubbles",
|
|
channel_type=ChannelType.BLUEBUBBLES,
|
|
channel_user_id="+8613800138000",
|
|
channel_chat_id=chat_guid,
|
|
channel_message_id="msg_001",
|
|
),
|
|
message_type=msg_type,
|
|
content=content,
|
|
)
|
|
|
|
|
|
class TestBlueBubblesSession:
|
|
def test_resolve_direct_chat(self):
|
|
assert resolve_chat_type("iMessage;+;chat001") == ChatType.DIRECT
|
|
|
|
def test_resolve_group_chat(self):
|
|
assert resolve_chat_type("iMessage;-;group001") == ChatType.GROUP
|
|
|
|
def test_extract_chat_display_name(self):
|
|
chat_data = {"displayName": "John Doe"}
|
|
assert extract_chat_display_name(chat_data) == "John Doe"
|
|
|
|
def test_extract_chat_display_name_missing(self):
|
|
assert extract_chat_display_name({}) == ""
|
|
|
|
def test_build_thread_key_direct(self):
|
|
key = build_thread_key("agent_001", "iMessage;+;chat001")
|
|
assert "direct" in key
|
|
assert "chat001" in key
|
|
|
|
def test_build_thread_key_group(self):
|
|
key = build_thread_key("agent_001", "iMessage;-;group001")
|
|
assert "group" in key
|
|
assert "group001" in key
|
|
|
|
|
|
class TestBlueBubblesSend:
|
|
def test_tapback_map_complete(self):
|
|
assert len(TAPBACK_MAP) == 65
|
|
assert TAPBACK_MAP["love"] == 0
|
|
assert TAPBACK_MAP["heart"] == 0
|
|
assert TAPBACK_MAP["like"] == 1
|
|
assert TAPBACK_MAP["thumbs_up"] == 1
|
|
assert TAPBACK_MAP["dislike"] == 2
|
|
assert TAPBACK_MAP["thumbs_down"] == 2
|
|
assert TAPBACK_MAP["laugh"] == 3
|
|
assert TAPBACK_MAP["exclaim"] == 4
|
|
assert TAPBACK_MAP["question"] == 5
|
|
assert TAPBACK_MAP["haha"] == 3
|
|
assert "celebration" not in TAPBACK_MAP
|
|
|
|
def test_resolve_tapback_value_known(self):
|
|
assert resolve_tapback_value("love") == 0
|
|
assert resolve_tapback_value("heart") == 0
|
|
assert resolve_tapback_value("like") == 1
|
|
assert resolve_tapback_value("dislike") == 2
|
|
assert resolve_tapback_value("laugh") == 3
|
|
assert resolve_tapback_value("exclaim") == 4
|
|
assert resolve_tapback_value("question") == 5
|
|
|
|
def test_resolve_tapback_value_unknown(self):
|
|
assert resolve_tapback_value("rocket") is None
|
|
assert resolve_tapback_value("fire") is None
|
|
|
|
|
|
class TestBlueBubblesConfig:
|
|
def test_default_config(self):
|
|
config = BlueBubblesConfig()
|
|
assert config.enabled is False
|
|
assert config.server_url == "http://localhost:1234"
|
|
assert config.dm_policy == "pairing"
|
|
assert config.group_policy == "allowlist"
|
|
assert config.streaming_mode == "partial"
|
|
|
|
def test_from_env(self):
|
|
with patch.dict(
|
|
"os.environ",
|
|
{
|
|
"BLUEBUBBLES_SERVER_URL": "http://mac.local:1234",
|
|
"BLUEBUBBLES_SERVER_PASSWORD": "secret123",
|
|
},
|
|
):
|
|
config = BlueBubblesConfig.from_env()
|
|
assert config.server_url == "http://mac.local:1234"
|
|
assert config.password == "secret123"
|
|
|
|
def test_from_env_full(self):
|
|
with patch.dict(
|
|
"os.environ",
|
|
{
|
|
"BLUEBUBBLES_ENABLED": "true",
|
|
"BLUEBUBBLES_SERVER_URL": "http://mac.local:1234",
|
|
"BLUEBUBBLES_SERVER_PASSWORD": "s3cret",
|
|
"BLUEBUBBLES_REQUEST_TIMEOUT": "15.0",
|
|
"BLUEBUBBLES_RECONNECT_MAX_DELAY": "120.0",
|
|
"BLUEBUBBLES_DM_POLICY": "allowlist",
|
|
"BLUEBUBBLES_GROUP_POLICY": "blocklist",
|
|
"BLUEBUBBLES_DM_ALLOW_FROM": "+8613800138000,+8613900139000",
|
|
"BLUEBUBBLES_GROUP_ALLOW_CHATS": "chat1,chat2",
|
|
"BLUEBUBBLES_MEDIA_MAX_MB": "50",
|
|
"BLUEBUBBLES_ENABLE_STICKERS": "false",
|
|
"BLUEBUBBLES_STREAMING_MODE": "block",
|
|
"BLUEBUBBLES_EDIT_INTERVAL_MS": "1000",
|
|
"BLUEBUBBLES_TAPBACK_ENABLED": "false",
|
|
},
|
|
):
|
|
config = BlueBubblesConfig.from_env()
|
|
assert config.enabled is True
|
|
assert config.server_url == "http://mac.local:1234"
|
|
assert config.password == "s3cret"
|
|
assert config.request_timeout == 15.0
|
|
assert config.reconnect_max_delay == 120.0
|
|
assert config.dm_policy == "allowlist"
|
|
assert config.group_policy == "blocklist"
|
|
assert config.dm_allow_from == ["+8613800138000", "+8613900139000"]
|
|
assert config.group_allow_chats == ["chat1", "chat2"]
|
|
assert config.media_max_mb == 50
|
|
assert config.enable_stickers is False
|
|
assert config.streaming_mode == "block"
|
|
assert config.edit_interval_ms == 1000
|
|
assert config.tapback_enabled is False
|
|
|
|
|
|
class TestBlueBubblesAdapter:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
config = {
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
}
|
|
return BlueBubblesAdapter(config)
|
|
|
|
def test_channel_id(self, adapter):
|
|
assert adapter.channel_id == "bluebubbles"
|
|
|
|
def test_channel_type(self, adapter):
|
|
assert adapter.channel_type == ChannelType.BLUEBUBBLES
|
|
|
|
def test_capabilities(self, adapter):
|
|
assert adapter.supports_streaming is True
|
|
assert adapter.supports_markdown is True
|
|
assert "off" in adapter.streaming_modes
|
|
assert "partial" in adapter.streaming_modes
|
|
assert adapter.text_chunk_limit == 4096
|
|
assert adapter.max_media_size_mb == 100
|
|
|
|
def test_normalize_dm_message(self, adapter):
|
|
event = make_dm_event()
|
|
result = adapter.normalize_inbound(event)
|
|
|
|
assert isinstance(result, ChannelMessage)
|
|
assert result.message_type == MessageType.TEXT
|
|
assert result.content == "Hello world"
|
|
assert result.identity.channel_id == "bluebubbles"
|
|
assert result.identity.channel_type == ChannelType.BLUEBUBBLES
|
|
assert result.identity.channel_user_id == "+8613800138000"
|
|
assert result.identity.channel_chat_id == "iMessage;+;chat001"
|
|
assert result.identity.channel_message_id == "msg_001"
|
|
assert result.chat_type == ChatType.DIRECT
|
|
assert result.metadata["is_group"] is False
|
|
assert result.metadata["chat_display_name"] == "John"
|
|
|
|
def test_normalize_image_message(self, adapter):
|
|
event = make_image_event()
|
|
result = adapter.normalize_inbound(event)
|
|
|
|
assert result.message_type == MessageType.IMAGE
|
|
assert len(result.attachments) == 1
|
|
assert result.attachments[0].mime_type == "image/jpeg"
|
|
assert result.attachments[0].filename == "photo.jpg"
|
|
assert result.attachments[0].size_bytes == 1024000
|
|
|
|
def test_normalize_group_message(self, adapter):
|
|
event = make_group_event()
|
|
result = adapter.normalize_inbound(event)
|
|
|
|
assert result.chat_type == ChatType.GROUP
|
|
assert result.metadata["is_group"] is True
|
|
assert result.metadata["group_name"] == "ForcePilot Dev"
|
|
assert len(result.metadata["group_participants"]) == 2
|
|
|
|
def test_normalize_follow_event(self, adapter):
|
|
event = {
|
|
"type": "join",
|
|
"data": {
|
|
"chatGuid": "iMessage;-;group001",
|
|
"chat": {"guid": "iMessage;-;group001", "isGroup": True},
|
|
"message": {"guid": "msg_001", "sender": {"address": "+8613800138000"}},
|
|
},
|
|
}
|
|
result = adapter.normalize_inbound(event)
|
|
assert result.chat_type == ChatType.GROUP
|
|
|
|
def test_normalize_typing_event(self, adapter):
|
|
event = {
|
|
"type": "typing-indicator",
|
|
"data": {"chatGuid": "iMessage;+;chat001", "senderGuid": "+8613800138000", "display": True},
|
|
}
|
|
result = adapter.normalize_inbound(event)
|
|
assert result.metadata["event"] == "typing"
|
|
assert result.metadata["display"] is True
|
|
|
|
def test_normalize_chat_name_change(self, adapter):
|
|
event = {
|
|
"type": "chat-name-change",
|
|
"data": {"chatGuid": "iMessage;-;group001", "newName": "New Group Name"},
|
|
}
|
|
result = adapter.normalize_inbound(event)
|
|
assert result.metadata["event"] == "chat_name_change"
|
|
assert result.metadata["new_name"] == "New Group Name"
|
|
|
|
def test_normalize_read_receipt(self, adapter):
|
|
event = {
|
|
"type": "chat-read-receipt",
|
|
"data": {"chatGuid": "iMessage;+;chat001", "senderGuid": "+8613800138000"},
|
|
}
|
|
result = adapter.normalize_inbound(event)
|
|
assert result.event_type == EventType.MESSAGE_UPDATED
|
|
|
|
def test_format_outbound_text(self, adapter):
|
|
response = make_response(content="Hello from ForcePilot")
|
|
result = adapter.format_outbound(response)
|
|
|
|
assert result["endpoint"] == "/api/v1/message/text"
|
|
assert result["payload"]["chatGuid"] == "iMessage;+;chat001"
|
|
assert result["payload"]["text"] == "Hello from ForcePilot"
|
|
|
|
def test_format_outbound_image(self, adapter):
|
|
response = make_response(msg_type=MessageType.IMAGE, content="Photo caption")
|
|
result = adapter.format_outbound(response)
|
|
|
|
assert result["endpoint"] == "/api/v1/message/attachment"
|
|
assert result["payload"]["chatGuid"] == "iMessage;+;chat001"
|
|
assert result["payload"]["caption"] == "Photo caption"
|
|
|
|
def test_format_outbound_video(self, adapter):
|
|
response = make_response(msg_type=MessageType.VIDEO, content="Video caption")
|
|
result = adapter.format_outbound(response)
|
|
|
|
assert result["endpoint"] == "/api/v1/message/attachment"
|
|
|
|
def test_format_outbound_file(self, adapter):
|
|
response = make_response(msg_type=MessageType.FILE)
|
|
result = adapter.format_outbound(response)
|
|
|
|
assert result["endpoint"] == "/api/v1/message/attachment"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_healthy(self, adapter):
|
|
mock_get = AsyncMock(
|
|
return_value={
|
|
"status": "ok",
|
|
"data": {"iMessageConnected": True},
|
|
}
|
|
)
|
|
adapter._client = AsyncMock()
|
|
adapter._client.get = mock_get
|
|
|
|
result = await adapter.health_check()
|
|
assert result.status == "healthy"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_imessage_disconnected(self, adapter):
|
|
mock_get = AsyncMock(
|
|
return_value={
|
|
"status": "ok",
|
|
"data": {"iMessageConnected": False},
|
|
}
|
|
)
|
|
adapter._client = AsyncMock()
|
|
adapter._client.get = mock_get
|
|
|
|
result = await adapter.health_check()
|
|
assert result.status == "degraded"
|
|
assert "iMessage not connected" in result.last_error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_server_unhealthy(self, adapter):
|
|
mock_get = AsyncMock(
|
|
return_value={
|
|
"status": "error",
|
|
"data": {"iMessageConnected": False},
|
|
}
|
|
)
|
|
adapter._client = AsyncMock()
|
|
adapter._client.get = mock_get
|
|
|
|
result = await adapter.health_check()
|
|
assert result.status == "unhealthy"
|
|
|
|
def test_normalize_with_reply_to(self, adapter):
|
|
event = make_dm_event(text="Reply text")
|
|
event["data"]["message"]["replyToGuid"] = "msg_original"
|
|
event["data"]["replyToGuid"] = "msg_original"
|
|
|
|
result = adapter.normalize_inbound(event)
|
|
assert result.metadata["reply_to_guid"] == "msg_original"
|
|
|
|
def test_normalize_unknown_event_type(self, adapter):
|
|
event = {
|
|
"type": "server-event",
|
|
"data": {"event": "started", "chatGuid": "unknown"},
|
|
}
|
|
result = adapter.normalize_inbound(event)
|
|
assert result.content == ""
|
|
assert result.metadata["event"] == "server-event"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_media_missing_attachment(self, adapter):
|
|
response = make_response(msg_type=MessageType.IMAGE, content="")
|
|
adapter._client = AsyncMock()
|
|
|
|
result = await adapter.send(response)
|
|
assert result.success is False
|
|
assert "Missing attachment" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_media_no_url(self, adapter):
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="bluebubbles",
|
|
channel_type=ChannelType.BLUEBUBBLES,
|
|
channel_user_id="",
|
|
channel_chat_id="iMessage;+;chat001",
|
|
),
|
|
message_type=MessageType.IMAGE,
|
|
content="Photo",
|
|
attachments=[Attachment(type="image", url="")],
|
|
)
|
|
adapter._client = AsyncMock()
|
|
|
|
result = await adapter.send(response)
|
|
assert result.success is False
|
|
assert "no URL" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pre_connect(self, adapter):
|
|
with patch.object(
|
|
adapter.__class__,
|
|
"pre_connect",
|
|
AsyncMock(return_value={"status": "ok", "imessage_connected": True}),
|
|
):
|
|
result = await adapter.pre_connect()
|
|
assert result["status"] == "ok"
|
|
|
|
|
|
class TestBlueBubblesAdapterTapback:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter(
|
|
{
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
}
|
|
)
|
|
|
|
def test_tapback_normalize(self, adapter):
|
|
event = make_dm_event()
|
|
event["data"]["isTapback"] = True
|
|
event["data"]["message"]["tapback"] = 0
|
|
|
|
result = adapter.normalize_inbound(event)
|
|
assert "Loved" in result.content
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_reaction_valid(self, adapter):
|
|
mock_post = AsyncMock(return_value={"status": "ok"})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.post = mock_post
|
|
|
|
result = await adapter.send_reaction("chat_001", "msg_001", "love")
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_reaction_invalid(self, adapter):
|
|
adapter._client = AsyncMock()
|
|
|
|
result = await adapter.send_reaction("chat_001", "msg_001", "rocket")
|
|
assert result.success is False
|
|
assert "Unsupported" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_reaction_no_client(self, adapter):
|
|
result = await adapter.send_reaction("chat_001", "msg_001", "love")
|
|
assert result.success is False
|
|
assert "Client not initialized" in result.error
|
|
|
|
|
|
class TestBlueBubblesExceptions:
|
|
def test_import_exceptions(self):
|
|
from yuxi.channels.adapters.bluebubbles.exceptions import (
|
|
BlueBubblesError,
|
|
BlueBubblesAuthenticationError,
|
|
BlueBubblesConnectionError,
|
|
BlueBubblesHTTPError,
|
|
TapbackError,
|
|
)
|
|
|
|
assert issubclass(BlueBubblesAuthenticationError, BlueBubblesError)
|
|
assert issubclass(BlueBubblesConnectionError, BlueBubblesError)
|
|
assert issubclass(BlueBubblesHTTPError, BlueBubblesError)
|
|
assert issubclass(TapbackError, BlueBubblesError)
|
|
|
|
def test_http_error_attributes(self):
|
|
from yuxi.channels.adapters.bluebubbles.exceptions import BlueBubblesHTTPError
|
|
|
|
err = BlueBubblesHTTPError(500, "Internal Server Error")
|
|
assert err.status_code == 500
|
|
assert err.retryable is True
|
|
|
|
err2 = BlueBubblesHTTPError(400, "Bad Request")
|
|
assert err2.status_code == 400
|
|
assert err2.retryable is False
|
|
|
|
def test_auth_error_not_retryable(self):
|
|
from yuxi.channels.adapters.bluebubbles.exceptions import BlueBubblesAuthenticationError
|
|
|
|
err = BlueBubblesAuthenticationError("Bad password")
|
|
assert err.retryable is False
|
|
|
|
|
|
class TestBlueBubblesClient:
|
|
@pytest.fixture
|
|
def client(self):
|
|
from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient
|
|
|
|
return BlueBubblesClient(
|
|
server_url="http://localhost:1234",
|
|
password="test-password",
|
|
)
|
|
|
|
def test_base_url(self, client):
|
|
assert client.base_url == "http://localhost:1234"
|
|
|
|
def test_base_url_strips_trailing_slash(self):
|
|
from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient
|
|
|
|
client = BlueBubblesClient("http://localhost:1234/", "pw")
|
|
assert client.base_url == "http://localhost:1234"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_context_manager(self, client):
|
|
async with client as c:
|
|
assert c._client is not None
|
|
assert client._client is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_request_not_initialized(self, client):
|
|
with pytest.raises(Exception):
|
|
await client.get("/api/v1/server/info")
|
|
|
|
def test_append_auth_adds_password_query_param(self, client):
|
|
assert client._password == "test-password"
|
|
|
|
def test_append_auth_with_existing_query_params(self, client):
|
|
assert client._password == "test-password"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_put_method_exists(self, client):
|
|
assert hasattr(client, "put")
|
|
assert callable(client.put)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_method_exists(self, client):
|
|
assert hasattr(client, "delete")
|
|
assert callable(client.delete)
|
|
|
|
|
|
class TestBlueBubblesSendRetry:
|
|
@pytest.mark.asyncio
|
|
async def test_send_text_safe_retry_preserves_reply_to_guid(self):
|
|
from unittest.mock import AsyncMock
|
|
from yuxi.channels.adapters.bluebubbles.send import send_text_safe
|
|
from yuxi.channels.adapters.bluebubbles.exceptions import BlueBubblesHTTPError
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post.side_effect = [
|
|
BlueBubblesHTTPError(500, "Server Error"),
|
|
{"guid": "msg_retry_001"},
|
|
]
|
|
|
|
result = await send_text_safe(
|
|
mock_client,
|
|
"chat_001",
|
|
"Hello",
|
|
reply_to_guid="original_msg_001",
|
|
max_retries=2,
|
|
)
|
|
assert result.success is True
|
|
call_args = mock_client.post.call_args_list[1]
|
|
payload = call_args[1]["json"]
|
|
assert payload["replyToGuid"] == "original_msg_001"
|
|
|
|
|
|
class TestBlueBubblesStreaming:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter(
|
|
{
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
}
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_stream_chunk_no_client_sends_via_send(self, adapter):
|
|
result = await adapter.send_stream_chunk("chat_001", "msg_001", "streaming...", finished=False)
|
|
assert result.success is False
|
|
assert "Client not initialized" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_stream_chunk_with_client(self, adapter):
|
|
mock_put = AsyncMock(return_value={"status": "ok"})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.put = mock_put
|
|
|
|
result = await adapter.send_stream_chunk("chat_001", "msg_001", "streaming...", finished=False)
|
|
assert result.success is True
|
|
mock_put.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_stream_chunk_finished(self, adapter):
|
|
mock_put = AsyncMock(return_value={"status": "ok"})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.put = mock_put
|
|
|
|
result = await adapter.send_stream_chunk("chat_001", "msg_001", "final", finished=True)
|
|
assert result.success is True
|
|
|
|
|
|
class TestBlueBubblesClientAuth:
|
|
def test_client_no_bearer_auth_header(self):
|
|
from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient
|
|
|
|
client = BlueBubblesClient("http://localhost:1234", "pw")
|
|
assert not hasattr(client, "_headers")
|
|
assert client._password == "pw"
|
|
|
|
def test_append_auth_url_encoding(self):
|
|
from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient
|
|
|
|
client = BlueBubblesClient("http://localhost:1234", "p@ss!")
|
|
assert client._password == "p@ss!"
|
|
|
|
|
|
class TestBlueBubblesProbe:
|
|
@pytest.mark.asyncio
|
|
async def test_check_server_reachable_unreachable(self):
|
|
from yuxi.channels.adapters.bluebubbles.probe import check_server_reachable
|
|
|
|
result = await check_server_reachable(
|
|
server_url="http://nonexistent-host:1234",
|
|
password="test",
|
|
timeout=2.0,
|
|
)
|
|
assert result.status == "unhealthy"
|
|
|
|
|
|
class TestBlueBubblesAdapterSend:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter(
|
|
{
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
}
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_text_success(self, adapter):
|
|
mock_post = AsyncMock(return_value={"guid": "msg_send_001"})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.post = mock_post
|
|
|
|
response = make_response(content="Hello world")
|
|
result = await adapter.send(response)
|
|
|
|
assert result.success is True
|
|
assert result.message_id == "msg_send_001"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_text_no_client(self, adapter):
|
|
response = make_response(content="Hello")
|
|
result = await adapter.send(response)
|
|
assert result.success is False
|
|
assert "Client not initialized" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_text_with_retry_on_500(self, adapter):
|
|
from yuxi.channels.adapters.bluebubbles.exceptions import BlueBubblesHTTPError
|
|
|
|
mock_post = AsyncMock(
|
|
side_effect=[
|
|
BlueBubblesHTTPError(500, "Server Error"),
|
|
{"guid": "msg_retry_001"},
|
|
]
|
|
)
|
|
adapter._client = AsyncMock()
|
|
adapter._client.post = mock_post
|
|
|
|
response = make_response(content="Retry me")
|
|
result = await adapter.send(response)
|
|
|
|
assert result.success is True
|
|
assert result.message_id == "msg_retry_001"
|
|
|
|
|
|
class TestBlueBubblesSendHtmlMessage:
|
|
@pytest.mark.asyncio
|
|
async def test_send_html_returns_delivery_result(self):
|
|
from yuxi.channels.adapters.bluebubbles.send import send_html_message
|
|
from yuxi.channels.models import DeliveryResult
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(return_value={"guid": "html_msg_001"})
|
|
|
|
result = await send_html_message(mock_client, "chat_001", "<b>Hello</b>")
|
|
assert isinstance(result, DeliveryResult)
|
|
assert result.success is True
|
|
assert result.message_id == "html_msg_001"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_html_with_message_guid_fallback(self):
|
|
from yuxi.channels.adapters.bluebubbles.send import send_html_message
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(return_value={"messageGuid": "fallback_001"})
|
|
|
|
result = await send_html_message(mock_client, "chat_001", "<b>Test</b>")
|
|
assert result.message_id == "fallback_001"
|
|
|
|
|
|
class TestBlueBubblesTapbackEmoji:
|
|
def test_resolve_tapback_emoji_heart(self):
|
|
assert resolve_tapback_value("love") == 0
|
|
assert resolve_tapback_value("heart") == 0
|
|
|
|
def test_resolve_tapback_emoji_thumbs(self):
|
|
assert resolve_tapback_value("like") == 1
|
|
assert resolve_tapback_value("dislike") == 2
|
|
|
|
def test_resolve_tapback_emoji_laugh(self):
|
|
assert resolve_tapback_value("laugh") == 3
|
|
|
|
def test_resolve_tapback_emoji_exclaim_question(self):
|
|
assert resolve_tapback_value("exclaim") == 4
|
|
assert resolve_tapback_value("question") == 5
|
|
|
|
|
|
class TestBlueBubblesDownloadMedia:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter(
|
|
{
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
}
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_media_no_client(self, adapter):
|
|
with pytest.raises(Exception):
|
|
await adapter.download_media("/media/test.jpg")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_media_with_client(self, adapter):
|
|
mock_get = AsyncMock()
|
|
mock_get.content = b"fake_image_data"
|
|
adapter._client = AsyncMock()
|
|
adapter._client._client = AsyncMock()
|
|
adapter._client._client.get = AsyncMock(return_value=mock_get)
|
|
adapter._client._base_url = "http://localhost:1234"
|
|
adapter._client._password = "test-password"
|
|
adapter._client.__class__ = type(
|
|
adapter._client.__class__.__name__,
|
|
adapter._client.__class__.__bases__,
|
|
{"download": AsyncMock(return_value=b"fake_image_data")},
|
|
)
|
|
adapter._client.download = AsyncMock(return_value=b"fake_image_data")
|
|
|
|
result = await adapter.download_media("/media/test.jpg")
|
|
assert result == b"fake_image_data"
|
|
|
|
|
|
class TestBlueBubblesStreamChunkEdgeCases:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter(
|
|
{
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
}
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_stream_chunk_no_client(self, adapter):
|
|
result = await adapter.send_stream_chunk("chat_001", "msg_001", "streaming...", finished=False)
|
|
assert result.success is False
|
|
assert "Client not initialized" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_stream_chunk_no_msg_id_creates_new(self, adapter):
|
|
mock_post = AsyncMock(return_value={"guid": "new_msg_001"})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.post = mock_post
|
|
|
|
result = await adapter.send_stream_chunk("chat_001", "", "first chunk", finished=False)
|
|
assert result.success is True
|
|
assert result.message_id == "new_msg_001"
|
|
|
|
|
|
class TestBlueBubblesClientUrlEncoding:
|
|
def test_append_auth_url_encodes_special_chars(self):
|
|
from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient
|
|
|
|
client = BlueBubblesClient("http://localhost:1234", "p@ss w!rd")
|
|
assert client._password == "p@ss w!rd"
|
|
|
|
|
|
class TestBlueBubblesChunkedSend:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter(
|
|
{
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
}
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_within_limit_not_chunked(self, adapter):
|
|
mock_post = AsyncMock(return_value={"guid": "msg_001"})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.post = mock_post
|
|
|
|
response = make_response(content="Short message")
|
|
result = await adapter.send(response)
|
|
|
|
assert result.success is True
|
|
mock_post.assert_called_once()
|
|
call_payload = mock_post.call_args[1]["json"]
|
|
assert call_payload["text"] == "Short message"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_exceeds_limit_chunked(self, adapter):
|
|
mock_post = AsyncMock(return_value={"guid": "msg_001"})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.post = mock_post
|
|
|
|
long_text = "x" * (adapter.text_chunk_limit + 100)
|
|
response = make_response(content=long_text)
|
|
result = await adapter.send(response)
|
|
|
|
assert result.success is True
|
|
assert mock_post.call_count == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_chunked_first_chunk_has_reply_to(self, adapter):
|
|
mock_post = AsyncMock(return_value={"guid": "msg_001"})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.post = mock_post
|
|
|
|
long_text = "x" * (adapter.text_chunk_limit + 100)
|
|
identity = ChannelIdentity(
|
|
channel_id="bluebubbles",
|
|
channel_type=ChannelType.BLUEBUBBLES,
|
|
channel_user_id="+8613800138000",
|
|
channel_chat_id="chat_001",
|
|
)
|
|
response = ChannelResponse(
|
|
identity=identity,
|
|
content=long_text,
|
|
reply_to_message_id="original_001",
|
|
)
|
|
await adapter.send(response)
|
|
|
|
first_call_payload = mock_post.call_args_list[0][1]["json"]
|
|
second_call_payload = mock_post.call_args_list[1][1]["json"]
|
|
assert first_call_payload["replyToGuid"] == "original_001"
|
|
assert "replyToGuid" not in second_call_payload
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_chunked_second_chunk_failure(self, adapter):
|
|
mock_post = AsyncMock(
|
|
side_effect=[
|
|
{"guid": "chunk_1"},
|
|
Exception("Chunk 2 failed"),
|
|
]
|
|
)
|
|
adapter._client = AsyncMock()
|
|
adapter._client.post = mock_post
|
|
|
|
long_text = "x" * (adapter.text_chunk_limit + 100)
|
|
response = make_response(content=long_text)
|
|
result = await adapter.send(response)
|
|
|
|
assert result.success is False
|
|
assert "Chunk 2 failed" in result.error
|
|
|
|
|
|
class TestBlueBubblesTypingReadReceipt:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter(
|
|
{
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
}
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_typing_with_client(self, adapter):
|
|
mock_post = AsyncMock(return_value={"status": "ok"})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.post = mock_post
|
|
|
|
await adapter.send_typing("chat_001", display=True)
|
|
mock_post.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_typing_no_client(self, adapter):
|
|
await adapter.send_typing("chat_001", display=True)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_read_with_client(self, adapter):
|
|
mock_post = AsyncMock(return_value={"status": "ok"})
|
|
adapter._client = AsyncMock()
|
|
adapter._client.post = mock_post
|
|
|
|
await adapter.send_read("chat_001")
|
|
mock_post.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_read_no_client(self, adapter):
|
|
await adapter.send_read("chat_001")
|
|
|
|
|
|
class TestBlueBubblesReconnectStatus:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter(
|
|
{
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
}
|
|
)
|
|
|
|
def test_initial_status_disconnected(self, adapter):
|
|
assert adapter._status == ChannelStatus.DISCONNECTED
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_disconnect_sets_reconnecting(self, adapter):
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
await adapter._on_ws_disconnect()
|
|
assert adapter._status == ChannelStatus.RECONNECTING
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_disconnect_when_not_connected_noop(self, adapter):
|
|
adapter._status = ChannelStatus.DISCONNECTED
|
|
await adapter._on_ws_disconnect()
|
|
assert adapter._status == ChannelStatus.DISCONNECTED
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_reconnect_sets_connected(self, adapter):
|
|
adapter._status = ChannelStatus.RECONNECTING
|
|
await adapter._on_ws_reconnect()
|
|
assert adapter._status == ChannelStatus.CONNECTED
|
|
|
|
|
|
class TestBlueBubblesMonitorJitter:
|
|
def test_monitor_accepts_disconnect_reconnect_callbacks(self):
|
|
from yuxi.channels.adapters.bluebubbles.monitor import BlueBubblesMonitor
|
|
|
|
async def on_disconnect():
|
|
pass
|
|
|
|
async def on_reconnect():
|
|
pass
|
|
|
|
monitor = BlueBubblesMonitor(
|
|
server_url="http://localhost:1234",
|
|
password="test",
|
|
on_event=AsyncMock(),
|
|
on_disconnect=on_disconnect,
|
|
on_reconnect=on_reconnect,
|
|
)
|
|
assert monitor._on_disconnect is on_disconnect
|
|
assert monitor._on_reconnect is on_reconnect
|
|
|
|
|
|
class TestBlueBubblesResolveMessageType:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter(
|
|
{
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
}
|
|
)
|
|
|
|
def test_location_message_type(self, adapter):
|
|
msg = {"location": {"latitude": 31.23, "longitude": 121.47}}
|
|
result = adapter._resolve_message_type(msg, [])
|
|
assert result == MessageType.LOCATION
|
|
|
|
def test_location_missing_coords_falls_through(self, adapter):
|
|
msg = {"location": {"address": "Shanghai"}}
|
|
result = adapter._resolve_message_type(msg, [])
|
|
assert result == MessageType.TEXT
|
|
|
|
def test_sticker_message_type(self, adapter):
|
|
msg = {"sticker": {"stickerDescription": "smiley"}}
|
|
result = adapter._resolve_message_type(msg, [])
|
|
assert result == MessageType.STICKER
|
|
|
|
def test_contact_message_type(self, adapter):
|
|
msg = {"vcard": {"name": "Alice"}}
|
|
result = adapter._resolve_message_type(msg, [])
|
|
assert result == MessageType.CARD
|
|
|
|
def test_location_priority_over_attachment(self, adapter):
|
|
msg = {"location": {"latitude": 1.0, "longitude": 2.0}}
|
|
att = [Attachment(url="/img.png", mime_type="image/png")]
|
|
result = adapter._resolve_message_type(msg, att)
|
|
assert result == MessageType.LOCATION
|
|
|
|
def test_sticker_priority_over_attachment(self, adapter):
|
|
msg = {"sticker": {"stickerDescription": "wave"}}
|
|
att = [Attachment(url="/img.png", mime_type="image/png")]
|
|
result = adapter._resolve_message_type(msg, att)
|
|
assert result == MessageType.STICKER
|
|
|
|
|
|
class TestBlueBubblesSpecialContent:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter(
|
|
{
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
}
|
|
)
|
|
|
|
def test_location_content_with_address(self, adapter):
|
|
msg = {"location": {"latitude": 22.5, "longitude": 114.1, "address": "HK"}}
|
|
content = adapter._extract_special_content(msg)
|
|
assert "22.5" in content
|
|
assert "114.1" in content
|
|
assert "HK" in content
|
|
|
|
def test_location_content_without_address(self, adapter):
|
|
msg = {"location": {"latitude": 10.0, "longitude": 20.0}}
|
|
content = adapter._extract_special_content(msg)
|
|
assert "10.0" in content
|
|
assert "Location:" in content
|
|
|
|
def test_sticker_content(self, adapter):
|
|
msg = {"sticker": {"stickerDescription": "party popper"}}
|
|
content = adapter._extract_special_content(msg)
|
|
assert content == "party popper"
|
|
|
|
def test_sticker_fallback(self, adapter):
|
|
msg = {"sticker": {}}
|
|
content = adapter._extract_special_content(msg)
|
|
assert content == "[Sticker]"
|
|
|
|
def test_contact_content(self, adapter):
|
|
msg = {"contactDetails": {"name": "Bob"}}
|
|
content = adapter._extract_special_content(msg)
|
|
assert content == "Bob"
|
|
|
|
def test_empty_message(self, adapter):
|
|
content = adapter._extract_special_content({"text": "hello"})
|
|
assert content == ""
|
|
|
|
|
|
class TestBlueBubblesActivityUpdate:
|
|
@pytest.mark.asyncio
|
|
async def test_update_activity_includes_ishtml(self):
|
|
from yuxi.channels.adapters.bluebubbles.send import update_activity
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.put = AsyncMock(return_value={})
|
|
|
|
await update_activity(mock_client, "chat_001", "act_001", "**bold**")
|
|
|
|
call_kwargs = mock_client.put.call_args
|
|
assert call_kwargs[1]["json"]["isHTML"] is True
|
|
|
|
|
|
class TestBlueBubblesDownloadCircuitBreaker:
|
|
def test_download_uses_circuit_breaker(self):
|
|
from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient
|
|
|
|
client = BlueBubblesClient("http://localhost:1234", "test", allow_private_network=True)
|
|
|
|
original_call = client._breaker.call
|
|
|
|
call_count = 0
|
|
|
|
def counting_call(fn):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return original_call(fn)
|
|
|
|
client._breaker.call = counting_call
|
|
|
|
client._client = AsyncMock()
|
|
mock_resp = AsyncMock()
|
|
mock_resp.content = b"data"
|
|
client._client.get = AsyncMock(return_value=mock_resp)
|
|
|
|
import asyncio
|
|
|
|
asyncio.run(client.download("/media/test.jpg"))
|
|
|
|
assert call_count == 1
|
|
|
|
|
|
class TestBlueBubblesLaneStreaming:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter(
|
|
{
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
}
|
|
)
|
|
|
|
def test_lane_buffers_initialized(self, adapter):
|
|
assert isinstance(adapter._lane_buffers, dict)
|
|
assert isinstance(adapter._lane_sent, dict)
|
|
assert len(adapter._lane_buffers) == 0
|
|
assert len(adapter._lane_sent) == 0
|
|
|
|
def test_compose_stream_content_no_lane(self, adapter):
|
|
result = adapter._compose_stream_content("Hello", "", False)
|
|
assert result == "Hello"
|
|
|
|
def test_compose_stream_content_with_lane(self, adapter):
|
|
result = adapter._compose_stream_content("Answer", "Thinking...", False)
|
|
assert "<details" in result
|
|
assert "Thinking..." in result
|
|
assert "Answer" in result
|
|
|
|
def test_compose_stream_content_finished(self, adapter):
|
|
result = adapter._compose_stream_content("Answer", "Thinking...", True)
|
|
assert "<details " in result
|
|
assert "open" not in result
|
|
|
|
def test_cleanup_stream_key(self, adapter):
|
|
key = "chat:msg"
|
|
adapter._stream_buffers[key] = "test"
|
|
adapter._lane_buffers[key] = "lane"
|
|
adapter._stream_sent[key] = "sent"
|
|
adapter._lane_sent[key] = "lane_sent"
|
|
adapter._last_edit_at[key] = 1.0
|
|
|
|
adapter._cleanup_stream_key(key)
|
|
assert key not in adapter._stream_buffers
|
|
assert key not in adapter._lane_buffers
|
|
assert key not in adapter._stream_sent
|
|
assert key not in adapter._lane_sent
|
|
assert key not in adapter._last_edit_at
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_lane_chunk_no_client(self, adapter):
|
|
result = await adapter.send_lane_chunk("chat_001", "msg_001", "thinking...")
|
|
assert result.success is False
|
|
|
|
|
|
class TestBlueBubblesReorderBuffer:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter(
|
|
{
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
}
|
|
)
|
|
|
|
def test_reorder_buffer_created_when_enabled(self, adapter):
|
|
assert adapter._reorder_buffer is not None
|
|
assert adapter._message_order_check is True
|
|
|
|
def test_reorder_buffer_disabled(self):
|
|
adapter = BlueBubblesAdapter(
|
|
{
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
"message_order_check": False,
|
|
}
|
|
)
|
|
assert adapter._reorder_buffer is None
|
|
|
|
def test_inbound_reorder_buffer_extract_timestamp(self):
|
|
from yuxi.channels.adapters.bluebubbles.monitor import InboundReorderBuffer
|
|
|
|
event = {
|
|
"type": "new-message",
|
|
"data": {
|
|
"message": {"dateCreated": 1000000},
|
|
},
|
|
}
|
|
ts = InboundReorderBuffer._extract_timestamp(event)
|
|
assert ts == 1000000.0
|
|
|
|
def test_inbound_reorder_buffer_no_timestamp(self):
|
|
from yuxi.channels.adapters.bluebubbles.monitor import InboundReorderBuffer
|
|
|
|
event = {"type": "new-message", "data": {}}
|
|
ts = InboundReorderBuffer._extract_timestamp(event)
|
|
assert ts > 0
|
|
|
|
|
|
class TestBlueBubblesSendSticker:
|
|
@pytest.mark.asyncio
|
|
async def test_send_sticker_bytes(self):
|
|
from yuxi.channels.adapters.bluebubbles.send import send_sticker
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(return_value={"guid": "sticker_001"})
|
|
|
|
result = await send_sticker(mock_client, "chat_001", b"fake_png_data")
|
|
assert result.success is True
|
|
assert result.message_id == "sticker_001"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_sticker_no_valid_data(self):
|
|
from yuxi.channels.adapters.bluebubbles.send import send_sticker
|
|
|
|
mock_client = AsyncMock()
|
|
result = await send_sticker(mock_client, "chat_001", {})
|
|
assert result.success is False
|
|
assert "No valid sticker data" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_sticker_with_url(self):
|
|
from yuxi.channels.adapters.bluebubbles.send import send_sticker
|
|
from unittest.mock import patch, AsyncMock as AsyncMock2
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(return_value={"guid": "sticker_url_001"})
|
|
|
|
with patch("httpx.AsyncClient") as mock_http:
|
|
mock_resp = AsyncMock2()
|
|
mock_resp.content = b"fake_image"
|
|
mock_resp.raise_for_status = lambda: None
|
|
mock_http.return_value.__aenter__.return_value.get = AsyncMock2(return_value=mock_resp)
|
|
|
|
result = await send_sticker(
|
|
mock_client,
|
|
"chat_001",
|
|
{"url": "https://example.com/sticker.png"},
|
|
)
|
|
assert result.success is True
|
|
|
|
|
|
class TestBlueBubblesAiVision:
|
|
def test_vision_disabled_by_default(self):
|
|
from yuxi.channels.adapters.bluebubbles.ai_vision import BlueBubblesVision
|
|
|
|
vision = BlueBubblesVision()
|
|
assert vision.enabled is False
|
|
|
|
def test_vision_cache(self):
|
|
from yuxi.channels.adapters.bluebubbles.ai_vision import VisionCache
|
|
|
|
cache = VisionCache(max_entries=3)
|
|
cache.put("hash1", {"description": "test"})
|
|
assert cache.get("hash1") == {"description": "test"}
|
|
assert cache.size == 1
|
|
|
|
cache.clear()
|
|
assert cache.size == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_analyze_message_media_disabled(self):
|
|
from yuxi.channels.adapters.bluebubbles.ai_vision import analyze_message_media, BlueBubblesVision
|
|
|
|
vision = BlueBubblesVision()
|
|
result = await analyze_message_media(vision, b"data", "test.jpg", "image/jpeg")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_analyze_non_image_skipped(self):
|
|
from yuxi.channels.adapters.bluebubbles.ai_vision import analyze_message_media, BlueBubblesVision
|
|
|
|
vision = BlueBubblesVision({"ai_vision_enabled": True, "ai_vision_api_url": "http://api"})
|
|
result = await analyze_message_media(vision, b"data", "test.mp4", "video/mp4")
|
|
assert result is None
|
|
|
|
|
|
class TestBlueBubblesTemplates:
|
|
def test_render_card_full(self):
|
|
from yuxi.channels.adapters.bluebubbles.templates import render_card
|
|
|
|
result = render_card(
|
|
title="Test Card",
|
|
body="Body text",
|
|
subtitle="Subtitle",
|
|
image_url="https://example.com/img.png",
|
|
footer="Footer",
|
|
)
|
|
assert "<b>Test Card</b>" in result
|
|
assert "Body text" in result
|
|
assert "<i>Subtitle</i>" in result
|
|
assert "img.png" in result
|
|
assert "Footer" in result
|
|
|
|
def test_render_card_minimal(self):
|
|
from yuxi.channels.adapters.bluebubbles.templates import render_card
|
|
|
|
result = render_card(body="Just body")
|
|
assert result == "Just body"
|
|
|
|
def test_render_notification(self):
|
|
from yuxi.channels.adapters.bluebubbles.templates import render_notification
|
|
|
|
result = render_notification("Alert", "Something happened", "warning")
|
|
assert "⚠️" in result
|
|
assert "<b>Alert</b>" in result
|
|
assert "Something happened" in result
|
|
|
|
def test_render_items(self):
|
|
from yuxi.channels.adapters.bluebubbles.templates import render_items
|
|
|
|
items = [{"label": "Name", "value": "Alice"}, {"label": "Age", "value": "30"}]
|
|
result = render_items(items)
|
|
assert "Name: Alice" in result
|
|
assert "Age: 30" in result
|
|
|
|
def test_render_button_row(self):
|
|
from yuxi.channels.adapters.bluebubbles.templates import render_button_row
|
|
|
|
buttons = [
|
|
{"label": "Click", "url": "https://example.com"},
|
|
{"label": "No URL"},
|
|
]
|
|
result = render_button_row(buttons)
|
|
assert "Click" in result
|
|
assert "https://example.com" in result
|
|
assert "No URL" in result
|
|
|
|
|
|
class TestBlueBubblesRenderer:
|
|
def test_render_html_strips_script(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_html_to_imessage
|
|
|
|
result = render_html_to_imessage("<p>Hello</p><script>alert('xss')</script><p>World</p>")
|
|
assert "Hello" in result
|
|
assert "World" in result
|
|
assert "script" not in result.lower()
|
|
|
|
def test_render_html_converts_strong(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_html_to_imessage
|
|
|
|
result = render_html_to_imessage("<strong>Bold</strong>")
|
|
assert "<b>Bold</b>" in result
|
|
|
|
def test_render_html_converts_em(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_html_to_imessage
|
|
|
|
result = render_html_to_imessage("<em>Italic</em>")
|
|
assert "<i>Italic</i>" in result
|
|
|
|
def test_render_html_converts_li(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_html_to_imessage
|
|
|
|
result = render_html_to_imessage("<ul><li>Item 1</li><li>Item 2</li></ul>")
|
|
assert "• Item 1" in result
|
|
assert "• Item 2" in result
|
|
|
|
def test_render_html_empty(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_html_to_imessage
|
|
|
|
assert render_html_to_imessage("") == ""
|
|
|
|
def test_render_markdown_bold(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage
|
|
|
|
result = render_markdown_to_imessage("**bold text**")
|
|
assert "<b>bold text</b>" in result
|
|
|
|
def test_render_markdown_italic(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage
|
|
|
|
result = render_markdown_to_imessage("*italic text*")
|
|
assert "<i>italic text</i>" in result
|
|
|
|
|
|
class TestBlueBubblesStreamingEnhanced:
|
|
def test_stream_buffer(self):
|
|
from yuxi.channels.adapters.bluebubbles.streaming_enhanced import StreamBuffer
|
|
|
|
buf = StreamBuffer(max_buffer_size=100)
|
|
result = buf.append("s1", "Hello ")
|
|
assert result == "Hello "
|
|
result = buf.append("s1", "World")
|
|
assert result == "Hello World"
|
|
assert buf.get("s1") == "Hello World"
|
|
|
|
buf.clear("s1")
|
|
assert buf.get("s1") == ""
|
|
|
|
def test_streaming_typing_sync_init(self):
|
|
from yuxi.channels.adapters.bluebubbles.streaming_enhanced import StreamingTypingSync
|
|
|
|
sync = StreamingTypingSync()
|
|
assert sync._active_chats == set()
|
|
assert not sync._running
|
|
|
|
|
|
class TestBlueBubblesDoctor:
|
|
@pytest.mark.asyncio
|
|
async def test_run_doctor_enhanced(self):
|
|
from yuxi.channels.adapters.bluebubbles.doctor import run_doctor
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(
|
|
return_value={
|
|
"data": {
|
|
"iMessageConnected": True,
|
|
"private_api": True,
|
|
"os_version": "15.0",
|
|
"websocket": True,
|
|
"attachmentRoot": "/media",
|
|
},
|
|
}
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"yuxi.channels.adapters.bluebubbles.doctor._check_websocket_endpoint",
|
|
AsyncMock(return_value=True),
|
|
),
|
|
patch(
|
|
"yuxi.channels.adapters.bluebubbles.doctor._check_attachment_storage",
|
|
AsyncMock(return_value=True),
|
|
),
|
|
patch(
|
|
"yuxi.channels.adapters.bluebubbles.doctor._check_message_history",
|
|
AsyncMock(return_value={"available": True, "count": 5}),
|
|
),
|
|
):
|
|
result = await run_doctor(mock_client)
|
|
assert result["server_reachable"] is True
|
|
assert result["websocket_reachable"] is True
|
|
assert result["attachment_storage_ok"] is True
|
|
assert result["message_history_available"] is True
|
|
assert result["recent_message_count"] == 5
|
|
|
|
|
|
class TestBlueBubblesCacheManager:
|
|
def test_cache_manager_register(self):
|
|
from yuxi.channels.adapters.bluebubbles.cache_manager import CacheManager
|
|
|
|
mgr = CacheManager(max_cache_entries=100)
|
|
cache = mgr.register("test_cache", ttl_seconds=60)
|
|
cache.set("key1", "value1")
|
|
assert cache.get("key1") == "value1"
|
|
|
|
def test_cache_manager_stats(self):
|
|
from yuxi.channels.adapters.bluebubbles.cache_manager import CacheManager
|
|
|
|
mgr = CacheManager()
|
|
cache = mgr.register("stats_cache")
|
|
cache.set("k1", "v1")
|
|
cache.get("k1")
|
|
cache.get("missing")
|
|
|
|
stats = mgr.get_all_stats()
|
|
assert "stats_cache" in stats
|
|
assert stats["stats_cache"]["hits"] == 1
|
|
assert stats["stats_cache"]["misses"] == 1
|
|
|
|
def test_cache_manager_clear_all(self):
|
|
from yuxi.channels.adapters.bluebubbles.cache_manager import CacheManager
|
|
|
|
mgr = CacheManager()
|
|
c1 = mgr.register("c1")
|
|
c2 = mgr.register("c2")
|
|
c1.set("k", "v")
|
|
c2.set("k", "v")
|
|
|
|
mgr.clear_all()
|
|
assert c1.size == 0
|
|
assert c2.size == 0
|
|
|
|
def test_unified_cache_ttl(self):
|
|
from yuxi.channels.adapters.bluebubbles.cache_manager import UnifiedCache
|
|
import time
|
|
|
|
cache = UnifiedCache("test", ttl_seconds=0.01)
|
|
cache.set("key", "value")
|
|
time.sleep(0.02)
|
|
assert cache.get("key") is None
|
|
|
|
def test_unified_cache_eviction(self):
|
|
from yuxi.channels.adapters.bluebubbles.cache_manager import UnifiedCache
|
|
|
|
cache = UnifiedCache("test", max_entries=3)
|
|
for i in range(5):
|
|
cache.set(f"key{i}", f"value{i}")
|
|
assert cache.size <= 3
|
|
|
|
|
|
class TestBlueBubblesSetupWizard:
|
|
@pytest.mark.asyncio
|
|
async def test_run_setup_wizard_basic(self):
|
|
from yuxi.channels.adapters.bluebubbles.setup_wizard import run_setup_wizard
|
|
|
|
with (
|
|
patch(
|
|
"yuxi.channels.adapters.bluebubbles.setup_wizard.validate_credentials",
|
|
AsyncMock(return_value={"valid": True}),
|
|
),
|
|
patch(
|
|
"yuxi.channels.adapters.bluebubbles.setup_wizard.validate_server_url",
|
|
AsyncMock(return_value={"reachable": True}),
|
|
),
|
|
):
|
|
result = await run_setup_wizard(
|
|
server_url="http://mac.local:1234",
|
|
password="secret",
|
|
)
|
|
assert "config" in result
|
|
assert result["config"]["server_url"] == "http://mac.local:1234"
|
|
assert result["complete"] is True
|
|
|
|
def test_wizard_steps_complete(self):
|
|
from yuxi.channels.adapters.bluebubbles.setup_wizard import BlueBubblesSetupWizard
|
|
|
|
wizard = BlueBubblesSetupWizard()
|
|
assert not wizard.is_complete
|
|
|
|
wizard.mark_step_complete("server_url")
|
|
wizard.mark_step_complete("password")
|
|
assert wizard.is_complete
|
|
|
|
def test_wizard_get_next_step(self):
|
|
from yuxi.channels.adapters.bluebubbles.setup_wizard import BlueBubblesSetupWizard
|
|
|
|
wizard = BlueBubblesSetupWizard()
|
|
step = wizard.get_next_step()
|
|
assert step is not None
|
|
assert step["id"] == "server_url"
|
|
|
|
|
|
class TestBlueBubblesAccountsMonitor:
|
|
def test_accounts_monitor_register(self):
|
|
from yuxi.channels.adapters.bluebubbles.accounts_monitor import AccountsMonitor
|
|
from yuxi.channels.adapters.bluebubbles.accounts import BlueBubblesAccountConfig
|
|
|
|
monitor = AccountsMonitor()
|
|
config = BlueBubblesAccountConfig(
|
|
account_id="test_account",
|
|
server_url="http://localhost:1234",
|
|
password="secret",
|
|
)
|
|
monitor.register_account("test_account", config)
|
|
health = monitor.get_health("test_account")
|
|
assert health is not None
|
|
assert health.account_id == "test_account"
|
|
|
|
def test_accounts_monitor_summary(self):
|
|
from yuxi.channels.adapters.bluebubbles.accounts_monitor import AccountsMonitor
|
|
from yuxi.channels.adapters.bluebubbles.accounts import BlueBubblesAccountConfig
|
|
|
|
monitor = AccountsMonitor()
|
|
for i in range(3):
|
|
config = BlueBubblesAccountConfig(
|
|
account_id=f"account_{i}",
|
|
server_url="http://localhost:1234",
|
|
password="secret",
|
|
)
|
|
monitor.register_account(f"account_{i}", config)
|
|
|
|
summary = monitor.get_summary()
|
|
assert summary["total_accounts"] == 3
|
|
assert summary["unhealthy"] == 3
|
|
|
|
|
|
class TestBlueBubblesClientPool:
|
|
def test_client_pool_acquire(self):
|
|
from yuxi.channels.adapters.bluebubbles.client_pool import ClientPool
|
|
|
|
pool = ClientPool(pool_size=2)
|
|
pool.configure("http://localhost:1234", "secret")
|
|
assert pool.size == 0
|
|
assert pool.available == 0
|
|
|
|
def test_client_pool_initial_config(self):
|
|
from yuxi.channels.adapters.bluebubbles.client_pool import ClientPool
|
|
|
|
pool = ClientPool()
|
|
pool.configure("http://localhost:1234", "pw")
|
|
assert pool._config["server_url"] == "http://localhost:1234"
|
|
assert pool._config["password"] == "pw"
|
|
|
|
|
|
class TestBlueBubblesConfigExtended:
|
|
def test_new_config_fields(self):
|
|
config = BlueBubblesConfig()
|
|
assert config.ai_vision_enabled is False
|
|
assert config.streaming_typing_indicator is True
|
|
assert config.message_order_check is True
|
|
assert config.max_cache_entries == 2048
|
|
|
|
|
|
class TestActionGate:
|
|
@pytest.fixture
|
|
def gate(self):
|
|
from yuxi.channels.adapters.bluebubbles.action_gate import ActionGate
|
|
|
|
return ActionGate()
|
|
|
|
def test_all_actions_allowed_when_both_available(self, gate):
|
|
gate.set_private_api_available(True)
|
|
gate.set_macos_26_or_higher(False)
|
|
assert gate.is_action_allowed("send") is True
|
|
assert gate.is_action_allowed("unsend") is True
|
|
assert gate.is_action_allowed("edit") is True
|
|
assert gate.is_action_allowed("rename_group") is True
|
|
assert gate.is_action_allowed("send_media") is True
|
|
assert gate.is_action_allowed("react") is True
|
|
|
|
def test_private_api_actions_blocked_when_private_api_unavailable(self, gate):
|
|
gate.set_private_api_available(False)
|
|
gate.set_macos_26_or_higher(False)
|
|
assert gate.is_action_allowed("send") is True
|
|
assert gate.is_action_allowed("unsend") is False
|
|
assert gate.is_action_allowed("rename_group") is False
|
|
assert gate.is_action_allowed("set_group_icon") is False
|
|
assert gate.is_action_allowed("add_participant") is False
|
|
assert gate.is_action_allowed("remove_participant") is False
|
|
assert gate.is_action_allowed("leave_group") is False
|
|
|
|
def test_edit_blocked_on_macos_26(self, gate):
|
|
gate.set_private_api_available(True)
|
|
gate.set_macos_26_or_higher(True)
|
|
assert gate.is_action_allowed("edit") is False
|
|
assert gate.is_action_allowed("send") is True
|
|
assert gate.is_action_allowed("unsend") is True
|
|
|
|
def test_private_api_none_treated_as_unavailable(self, gate):
|
|
assert gate.is_action_allowed("unsend") is False
|
|
assert gate.is_action_allowed("rename_group") is False
|
|
|
|
def test_get_available_actions_full(self, gate):
|
|
gate.set_private_api_available(True)
|
|
gate.set_macos_26_or_higher(False)
|
|
actions = gate.get_available_actions()
|
|
assert "send" in actions
|
|
assert "unsend" in actions
|
|
assert "edit" in actions
|
|
assert "rename_group" in actions
|
|
assert "react" in actions
|
|
|
|
def test_get_available_actions_private_api_disabled(self, gate):
|
|
gate.set_private_api_available(False)
|
|
gate.set_macos_26_or_higher(False)
|
|
actions = gate.get_available_actions()
|
|
assert "unsend" not in actions
|
|
assert "rename_group" not in actions
|
|
assert "edit" in actions
|
|
|
|
def test_get_available_actions_macos_26(self, gate):
|
|
gate.set_private_api_available(True)
|
|
gate.set_macos_26_or_higher(True)
|
|
actions = gate.get_available_actions()
|
|
assert "edit" not in actions
|
|
assert "unsend" in actions
|
|
|
|
def test_get_available_actions_sorted(self, gate):
|
|
gate.set_private_api_available(True)
|
|
gate.set_macos_26_or_higher(False)
|
|
actions = gate.get_available_actions()
|
|
assert actions == sorted(actions)
|
|
|
|
|
|
class TestCatchupCursorStore:
|
|
@pytest.fixture
|
|
def temp_dir(self, tmp_path):
|
|
return str(tmp_path)
|
|
|
|
def test_cursor_default_empty(self, temp_dir):
|
|
from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore
|
|
|
|
store = CatchupCursorStore(state_dir=temp_dir, account_id="test")
|
|
assert store.cursor == ""
|
|
|
|
def test_cursor_read_write_consistency(self, temp_dir):
|
|
from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore
|
|
|
|
store = CatchupCursorStore(state_dir=temp_dir, account_id="test")
|
|
store.cursor = "1234567890"
|
|
assert store.cursor == "1234567890"
|
|
|
|
def test_cursor_persistence_across_instances(self, temp_dir):
|
|
from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore
|
|
|
|
store1 = CatchupCursorStore(state_dir=temp_dir, account_id="test")
|
|
store1.cursor = "9999999999"
|
|
store2 = CatchupCursorStore(state_dir=temp_dir, account_id="test")
|
|
assert store2.cursor == "9999999999"
|
|
|
|
def test_cursor_json_corruption_resets(self, temp_dir):
|
|
from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore
|
|
|
|
store = CatchupCursorStore(state_dir=temp_dir, account_id="test")
|
|
store.cursor = "original"
|
|
cursor_path = store._cursor_path
|
|
cursor_path.write_text("not valid json {{{", encoding="utf-8")
|
|
store2 = CatchupCursorStore(state_dir=temp_dir, account_id="test")
|
|
assert store2.cursor == ""
|
|
|
|
def test_record_failure_increment(self, temp_dir):
|
|
from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore
|
|
|
|
store = CatchupCursorStore(state_dir=temp_dir, account_id="test")
|
|
assert store.record_failure("msg_1") == 1
|
|
assert store.record_failure("msg_1") == 2
|
|
assert store.record_failure("msg_1") == 3
|
|
|
|
def test_is_given_up_false_initially(self, temp_dir):
|
|
from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore
|
|
|
|
store = CatchupCursorStore(state_dir=temp_dir, account_id="test")
|
|
assert store.is_given_up("msg_x") is False
|
|
|
|
def test_mark_given_up_and_check(self, temp_dir):
|
|
from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore
|
|
|
|
store = CatchupCursorStore(state_dir=temp_dir, account_id="test")
|
|
store.mark_given_up("msg_y")
|
|
assert store.is_given_up("msg_y") is True
|
|
|
|
def test_given_up_persists(self, temp_dir):
|
|
from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore
|
|
|
|
store = CatchupCursorStore(state_dir=temp_dir, account_id="test")
|
|
store.mark_given_up("msg_z")
|
|
store2 = CatchupCursorStore(state_dir=temp_dir, account_id="test")
|
|
assert store2.is_given_up("msg_z") is True
|
|
|
|
def test_reset_failures(self, temp_dir):
|
|
from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore
|
|
|
|
store = CatchupCursorStore(state_dir=temp_dir, account_id="test")
|
|
store.record_failure("msg_r")
|
|
store.record_failure("msg_r")
|
|
store.reset_failures("msg_r")
|
|
assert store.record_failure("msg_r") == 1
|
|
|
|
def test_different_accounts_separate_cursors(self, temp_dir):
|
|
from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore
|
|
|
|
store_a = CatchupCursorStore(state_dir=temp_dir, account_id="account_a")
|
|
store_b = CatchupCursorStore(state_dir=temp_dir, account_id="account_b")
|
|
store_a.cursor = "aaa"
|
|
store_b.cursor = "bbb"
|
|
assert CatchupCursorStore(state_dir=temp_dir, account_id="account_a").cursor == "aaa"
|
|
assert CatchupCursorStore(state_dir=temp_dir, account_id="account_b").cursor == "bbb"
|
|
|
|
|
|
class TestRunFullCatchup:
|
|
@pytest.fixture
|
|
def temp_dir(self, tmp_path):
|
|
return str(tmp_path)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_catchup_updates_cursor(self, temp_dir):
|
|
from yuxi.channels.adapters.bluebubbles.catchup import (
|
|
CatchupCursorStore,
|
|
run_full_catchup,
|
|
)
|
|
import time
|
|
|
|
recent_ts = int(time.time() * 1000) - 60000
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(
|
|
return_value={
|
|
"data": [
|
|
{"guid": "msg_001", "dateCreated": recent_ts, "isFromMe": False},
|
|
]
|
|
}
|
|
)
|
|
store = CatchupCursorStore(state_dir=temp_dir, account_id="test")
|
|
received: list = []
|
|
|
|
async def on_msg(chat_guid, msg):
|
|
received.append(msg)
|
|
|
|
summary = await run_full_catchup(
|
|
mock_client,
|
|
["chat_001"],
|
|
store,
|
|
max_age_minutes=120,
|
|
on_message=on_msg,
|
|
)
|
|
assert summary.replayed == 1
|
|
assert len(received) == 1
|
|
assert summary.cursor_after != ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_catchup_skips_old_messages(self, temp_dir):
|
|
from yuxi.channels.adapters.bluebubbles.catchup import (
|
|
CatchupCursorStore,
|
|
run_full_catchup,
|
|
)
|
|
|
|
now_ms = int(__import__("time").time() * 1000)
|
|
old_ts = now_ms - 4 * 60 * 60 * 1000
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(
|
|
return_value={
|
|
"data": [
|
|
{"guid": "msg_old", "dateCreated": old_ts, "isFromMe": False},
|
|
]
|
|
}
|
|
)
|
|
store = CatchupCursorStore(state_dir=temp_dir, account_id="test")
|
|
received: list = []
|
|
|
|
async def on_msg(chat_guid, msg):
|
|
received.append(msg)
|
|
|
|
summary = await run_full_catchup(
|
|
mock_client,
|
|
["chat_001"],
|
|
store,
|
|
max_age_minutes=120,
|
|
on_message=on_msg,
|
|
)
|
|
assert summary.skipped_pre_cursor > 0
|
|
assert summary.replayed == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_catchup_skips_from_me(self, temp_dir):
|
|
from yuxi.channels.adapters.bluebubbles.catchup import (
|
|
CatchupCursorStore,
|
|
run_full_catchup,
|
|
)
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(
|
|
return_value={
|
|
"data": [
|
|
{"guid": "msg_me", "dateCreated": 9999999999999, "isFromMe": True},
|
|
]
|
|
}
|
|
)
|
|
store = CatchupCursorStore(state_dir=temp_dir, account_id="test")
|
|
received: list = []
|
|
|
|
async def on_msg(chat_guid, msg):
|
|
received.append(msg)
|
|
|
|
summary = await run_full_catchup(
|
|
mock_client,
|
|
["chat_001"],
|
|
store,
|
|
max_age_minutes=120,
|
|
on_message=on_msg,
|
|
)
|
|
assert summary.skipped_from_me > 0
|
|
assert summary.replayed == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_catchup_respects_per_run_limit(self, temp_dir):
|
|
from yuxi.channels.adapters.bluebubbles.catchup import (
|
|
CatchupCursorStore,
|
|
run_full_catchup,
|
|
)
|
|
|
|
msgs = [{"guid": f"msg_{i}", "dateCreated": 9999999999999 - i * 1000, "isFromMe": False} for i in range(20)]
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(return_value={"data": msgs})
|
|
store = CatchupCursorStore(state_dir=temp_dir, account_id="test")
|
|
received: list = []
|
|
|
|
async def on_msg(chat_guid, msg):
|
|
received.append(msg)
|
|
|
|
summary = await run_full_catchup(
|
|
mock_client,
|
|
["chat_001"],
|
|
store,
|
|
max_age_minutes=0,
|
|
per_run_limit=5,
|
|
on_message=on_msg,
|
|
)
|
|
assert summary.replayed == 5
|
|
assert len(received) == 5
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_catchup_skips_given_up(self, temp_dir):
|
|
from yuxi.channels.adapters.bluebubbles.catchup import (
|
|
CatchupCursorStore,
|
|
run_full_catchup,
|
|
)
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(
|
|
return_value={
|
|
"data": [
|
|
{"guid": "msg_givenup", "dateCreated": 9999999999999, "isFromMe": False},
|
|
]
|
|
}
|
|
)
|
|
store = CatchupCursorStore(state_dir=temp_dir, account_id="test")
|
|
store.mark_given_up("msg_givenup")
|
|
received: list = []
|
|
|
|
async def on_msg(chat_guid, msg):
|
|
received.append(msg)
|
|
|
|
summary = await run_full_catchup(
|
|
mock_client,
|
|
["chat_001"],
|
|
store,
|
|
max_age_minutes=0,
|
|
on_message=on_msg,
|
|
)
|
|
assert summary.skipped_given_up > 0
|
|
assert summary.replayed == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_catchup_record_failure_and_retry(self, temp_dir):
|
|
from yuxi.channels.adapters.bluebubbles.catchup import (
|
|
CatchupCursorStore,
|
|
run_full_catchup,
|
|
)
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(
|
|
return_value={
|
|
"data": [
|
|
{"guid": "msg_fail", "dateCreated": 9999999999999, "isFromMe": False},
|
|
]
|
|
}
|
|
)
|
|
store = CatchupCursorStore(state_dir=temp_dir, account_id="test")
|
|
|
|
async def failing_handler(chat_guid, msg):
|
|
raise RuntimeError("simulated failure")
|
|
|
|
summary = await run_full_catchup(
|
|
mock_client,
|
|
["chat_001"],
|
|
store,
|
|
max_age_minutes=0,
|
|
on_message=failing_handler,
|
|
)
|
|
assert summary.failed > 0
|
|
assert summary.replayed == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_catchup_mark_given_up_after_max_failures(self, temp_dir):
|
|
from yuxi.channels.adapters.bluebubbles.catchup import (
|
|
CatchupCursorStore,
|
|
run_full_catchup,
|
|
)
|
|
|
|
store = CatchupCursorStore(state_dir=temp_dir, account_id="test")
|
|
for i in range(5):
|
|
store.record_failure("msg_permfail")
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(
|
|
return_value={
|
|
"data": [
|
|
{"guid": "msg_permfail", "dateCreated": 9999999999999, "isFromMe": False},
|
|
]
|
|
}
|
|
)
|
|
|
|
async def failing_handler(chat_guid, msg):
|
|
raise RuntimeError("simulated failure")
|
|
|
|
summary = await run_full_catchup(
|
|
mock_client,
|
|
["chat_001"],
|
|
store,
|
|
max_age_minutes=0,
|
|
max_failure_retries=5,
|
|
on_message=failing_handler,
|
|
)
|
|
assert summary.given_up > 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_catchup_client_error_graceful(self, temp_dir):
|
|
from yuxi.channels.adapters.bluebubbles.catchup import (
|
|
CatchupCursorStore,
|
|
run_full_catchup,
|
|
)
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(side_effect=Exception("network error"))
|
|
store = CatchupCursorStore(state_dir=temp_dir, account_id="test")
|
|
|
|
summary = await run_full_catchup(
|
|
mock_client,
|
|
["chat_001"],
|
|
store,
|
|
max_age_minutes=120,
|
|
)
|
|
assert summary.replayed == 0
|
|
|
|
|
|
class TestProbeAdvanced:
|
|
def test_is_macos_26_or_higher_exact_26(self):
|
|
from yuxi.channels.adapters.bluebubbles.probe import is_macos_26_or_higher
|
|
|
|
assert is_macos_26_or_higher("26.0") is True
|
|
assert is_macos_26_or_higher("26.1") is True
|
|
|
|
def test_is_macos_26_or_higher_below_26(self):
|
|
from yuxi.channels.adapters.bluebubbles.probe import is_macos_26_or_higher
|
|
|
|
assert is_macos_26_or_higher("25.3") is False
|
|
assert is_macos_26_or_higher("14.5") is False
|
|
|
|
def test_is_macos_26_or_higher_unknown(self):
|
|
from yuxi.channels.adapters.bluebubbles.probe import is_macos_26_or_higher
|
|
|
|
assert is_macos_26_or_higher("unknown") is False
|
|
|
|
def test_is_macos_26_or_higher_invalid_format(self):
|
|
from yuxi.channels.adapters.bluebubbles.probe import is_macos_26_or_higher
|
|
|
|
assert is_macos_26_or_higher("not.a.version") is False
|
|
assert is_macos_26_or_higher("") is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_private_api_true(self):
|
|
from yuxi.channels.adapters.bluebubbles.probe import probe_private_api
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(
|
|
return_value={
|
|
"status": "ok",
|
|
"data": {"private_api": True},
|
|
}
|
|
)
|
|
result = await probe_private_api(mock_client)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_private_api_false(self):
|
|
from yuxi.channels.adapters.bluebubbles.probe import probe_private_api
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(
|
|
return_value={
|
|
"status": "ok",
|
|
"data": {"private_api": False},
|
|
}
|
|
)
|
|
result = await probe_private_api(mock_client)
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_private_api_error_returns_false(self):
|
|
from yuxi.channels.adapters.bluebubbles.probe import probe_private_api
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(side_effect=Exception("boom"))
|
|
result = await probe_private_api(mock_client)
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_imessage_login_healthy(self):
|
|
from yuxi.channels.adapters.bluebubbles.probe import probe_imessage_login
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(
|
|
return_value={
|
|
"status": "ok",
|
|
"data": {"iMessageConnected": True},
|
|
}
|
|
)
|
|
result = await probe_imessage_login(mock_client)
|
|
assert result.status == "healthy"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_imessage_login_degraded(self):
|
|
from yuxi.channels.adapters.bluebubbles.probe import probe_imessage_login
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(
|
|
return_value={
|
|
"status": "ok",
|
|
"data": {"iMessageConnected": False},
|
|
}
|
|
)
|
|
result = await probe_imessage_login(mock_client)
|
|
assert result.status == "degraded"
|
|
|
|
def test_server_info_cache_ttl(self):
|
|
from yuxi.channels.adapters.bluebubbles.probe import ServerInfoCache
|
|
|
|
cache = ServerInfoCache()
|
|
cache.set("url1", {"data": "test"})
|
|
cached = cache.get("url1")
|
|
assert cached is not None
|
|
assert cached["data"] == "test"
|
|
|
|
def test_server_info_cache_invalidate(self):
|
|
from yuxi.channels.adapters.bluebubbles.probe import ServerInfoCache
|
|
|
|
cache = ServerInfoCache()
|
|
cache.set("url1", {"data": "test"})
|
|
cache.invalidate("url1")
|
|
assert cache.get("url1") is None
|
|
|
|
|
|
class TestConfigModelFull:
|
|
def test_config_all_defaults(self):
|
|
config = BlueBubblesConfig()
|
|
assert config.enabled is False
|
|
assert config.server_url == "http://localhost:1234"
|
|
assert config.password == ""
|
|
assert config.request_timeout == 30.0
|
|
assert config.send_timeout_ms == 30000
|
|
assert config.reconnect_max_delay == 60.0
|
|
assert config.dm_policy == "pairing"
|
|
assert config.group_policy == "allowlist"
|
|
assert config.dm_allow_from == []
|
|
assert config.group_allow_chats == []
|
|
assert config.media_max_mb == 100
|
|
assert config.enable_stickers is True
|
|
assert config.streaming_mode == "partial"
|
|
assert config.edit_interval_ms == 500
|
|
assert config.tapback_enabled is True
|
|
assert config.require_mention is False
|
|
assert config.send_read_receipts is True
|
|
assert config.text_chunk_limit == 4000
|
|
assert config.block_streaming is False
|
|
assert config.ai_vision_enabled is False
|
|
assert config.streaming_typing_indicator is True
|
|
assert config.message_order_check is True
|
|
assert config.max_cache_entries == 2048
|
|
assert config.dm_history_limit == 100
|
|
assert config.coalesce_same_sender_dms is False
|
|
assert config.enrich_group_participants_from_contacts is True
|
|
assert config.config_writes is True
|
|
assert config.allow_private_network is False
|
|
assert config.tts_prefer_caf is True
|
|
|
|
def test_config_sub_models_defaults(self):
|
|
config = BlueBubblesConfig()
|
|
assert config.catchup.enabled is True
|
|
assert config.catchup.max_age_minutes == 120
|
|
assert config.catchup.per_run_limit == 50
|
|
assert config.catchup.first_run_lookback_minutes == 30
|
|
assert config.catchup.max_failure_retries == 10
|
|
assert config.health_monitor.enabled is True
|
|
assert config.network.dangerously_allow_private_network is False
|
|
assert config.markdown.enabled is True
|
|
assert config.markdown.code_blocks is True
|
|
assert config.block_streaming_coalesce.enabled is False
|
|
assert config.block_streaming_coalesce.max_flush_interval_ms == 500
|
|
|
|
def test_config_auth_strategy_literal(self):
|
|
config = BlueBubblesConfig(auth_strategy="both")
|
|
assert config.auth_strategy == "both"
|
|
config2 = BlueBubblesConfig(auth_strategy="query")
|
|
assert config2.auth_strategy == "query"
|
|
|
|
def test_config_dm_policy_literal(self):
|
|
config = BlueBubblesConfig(dm_policy="allowlist")
|
|
assert config.dm_policy == "allowlist"
|
|
assert config.dm_allow_from == []
|
|
|
|
def test_config_group_policy_literal(self):
|
|
config = BlueBubblesConfig(group_policy="blocklist")
|
|
assert config.group_policy == "blocklist"
|
|
|
|
def test_from_env_all_new_fields(self):
|
|
with patch.dict(
|
|
"os.environ",
|
|
{
|
|
"BLUEBUBBLES_STREAMING_TYPING": "false",
|
|
"BLUEBUBBLES_MESSAGE_ORDER_CHECK": "false",
|
|
"BLUEBUBBLES_MAX_CACHE_ENTRIES": "512",
|
|
"BLUEBUBBLES_DM_HISTORY_LIMIT": "50",
|
|
"BLUEBUBBLES_COALESCE_DMS": "true",
|
|
"BLUEBUBBLES_ENRICH_GROUP": "false",
|
|
"BLUEBUBBLES_CONFIG_WRITES": "false",
|
|
"BLUEBUBBLES_COALESCE_ENABLED": "true",
|
|
"BLUEBUBBLES_COALESCE_MAX_FLUSH_MS": "800",
|
|
"BLUEBUBBLES_NETWORK_DANGEROUSLY_ALLOW_PRIVATE": "true",
|
|
"BLUEBUBBLES_MARKDOWN_ENABLED": "false",
|
|
"BLUEBUBBLES_MARKDOWN_CODE_BLOCKS": "false",
|
|
"BLUEBUBBLES_CATCHUP_ENABLED": "false",
|
|
"BLUEBUBBLES_CATCHUP_MAX_AGE_MINUTES": "60",
|
|
"BLUEBUBBLES_CATCHUP_PER_RUN_LIMIT": "25",
|
|
"BLUEBUBBLES_CATCHUP_FIRST_LOOKBACK": "15",
|
|
"BLUEBUBBLES_CATCHUP_MAX_FAILURES": "5",
|
|
"BLUEBUBBLES_HEALTH_MONITOR_ENABLED": "false",
|
|
},
|
|
):
|
|
config = BlueBubblesConfig.from_env()
|
|
assert config.streaming_typing_indicator is False
|
|
assert config.message_order_check is False
|
|
assert config.max_cache_entries == 512
|
|
assert config.dm_history_limit == 50
|
|
assert config.coalesce_same_sender_dms is True
|
|
assert config.enrich_group_participants_from_contacts is False
|
|
assert config.config_writes is False
|
|
assert config.block_streaming_coalesce.enabled is True
|
|
assert config.block_streaming_coalesce.max_flush_interval_ms == 800
|
|
assert config.network.dangerously_allow_private_network is True
|
|
assert config.markdown.enabled is False
|
|
assert config.markdown.code_blocks is False
|
|
assert config.catchup.enabled is False
|
|
assert config.catchup.max_age_minutes == 60
|
|
assert config.catchup.per_run_limit == 25
|
|
assert config.catchup.first_run_lookback_minutes == 15
|
|
assert config.catchup.max_failure_retries == 5
|
|
assert config.health_monitor.enabled is False
|
|
|
|
def test_from_env_validate_literal_valid(self):
|
|
with patch.dict(
|
|
"os.environ",
|
|
{
|
|
"BLUEBUBBLES_STREAMING_MODE": "block",
|
|
"BLUEBUBBLES_AUTH_STRATEGY": "both",
|
|
},
|
|
):
|
|
config = BlueBubblesConfig.from_env()
|
|
assert config.streaming_mode == "block"
|
|
assert config.auth_strategy == "both"
|
|
|
|
def test_from_env_validate_literal_invalid(self):
|
|
with patch.dict(
|
|
"os.environ",
|
|
{
|
|
"BLUEBUBBLES_STREAMING_MODE": "invalid_mode",
|
|
"BLUEBUBBLES_AUTH_STRATEGY": "nonexistent",
|
|
},
|
|
):
|
|
config = BlueBubblesConfig.from_env()
|
|
assert config.streaming_mode == "partial"
|
|
assert config.auth_strategy == "header"
|
|
|
|
def test_config_webhook_path_default(self):
|
|
config = BlueBubblesConfig()
|
|
assert config.webhook_path == "/bluebubbles/webhook"
|
|
assert config.webhook_secret == ""
|
|
|
|
def test_config_group_override(self):
|
|
config = BlueBubblesConfig(groups={"chat1": {"system_prompt": "Custom prompt"}})
|
|
override = config.groups.get("chat1")
|
|
assert override is not None
|
|
assert override.system_prompt == "Custom prompt"
|
|
|
|
def test_allow_private_network_warning(self, caplog):
|
|
config = BlueBubblesConfig(allow_private_network=True)
|
|
assert config.allow_private_network is True
|
|
|
|
def test_media_local_roots_default(self):
|
|
config = BlueBubblesConfig()
|
|
assert config.media_local_roots == []
|
|
|
|
|
|
class TestStreamCoalesce:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter(
|
|
{
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test",
|
|
"block_streaming_coalesce": {"enabled": True, "max_flush_interval_ms": 500},
|
|
}
|
|
)
|
|
|
|
def test_coalesce_buffer_merge(self, adapter):
|
|
key = "chat_001:msg_001"
|
|
adapter._stream_buffers[key] = "Hello "
|
|
adapter._stream_buffers[key] = adapter._stream_buffers.get(key, "") + "World"
|
|
assert adapter._stream_buffers[key] == "Hello World"
|
|
|
|
def test_coalesce_multiple_chunks_merged(self, adapter):
|
|
key = "chat_001:msg_002"
|
|
adapter._stream_buffers[key] = ""
|
|
for word in ["The", " ", "quick", " ", "brown"]:
|
|
adapter._stream_buffers[key] = adapter._stream_buffers.get(key, "") + word
|
|
assert adapter._stream_buffers[key] == "The quick brown"
|
|
|
|
def test_coalesce_finished_clears_buffer(self, adapter):
|
|
key = "chat_001:msg_003"
|
|
adapter._stream_buffers[key] = "content"
|
|
adapter._stream_sent[key] = "sent"
|
|
adapter._last_edit_at[key] = 1.0
|
|
adapter._cleanup_stream_key(key)
|
|
assert key not in adapter._stream_buffers
|
|
assert key not in adapter._stream_sent
|
|
assert key not in adapter._last_edit_at
|
|
|
|
def test_lane_buffer_independent(self, adapter):
|
|
key = "chat_001:msg_004"
|
|
adapter._stream_buffers[key] = "reasoning"
|
|
adapter._lane_buffers[key] = "thinking"
|
|
assert adapter._stream_buffers[key] == "reasoning"
|
|
assert adapter._lane_buffers[key] == "thinking"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_stream_chunk_builds_identity(self, adapter):
|
|
adapter._client = AsyncMock()
|
|
adapter._client.post = AsyncMock(return_value={"guid": "new_msg", "messageGuid": "new_msg"})
|
|
result = await adapter.send_stream_chunk("chat_001", "", "Hello", False)
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_stream_chunk_no_client(self, adapter):
|
|
result = await adapter.send_stream_chunk("chat_001", "msg_001", "chunk", False)
|
|
assert result.success is False
|
|
|
|
|
|
class TestInboundEventsFull:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter(
|
|
{
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test-password",
|
|
}
|
|
)
|
|
|
|
def test_event_type_new_message(self, adapter):
|
|
event = make_dm_event(text="Hello")
|
|
result = adapter.normalize_inbound(event)
|
|
assert result.event_type == EventType.MESSAGE_RECEIVED
|
|
assert result.content == "Hello"
|
|
|
|
def test_event_type_typing_indicator(self, adapter):
|
|
event = {
|
|
"type": "typing-indicator",
|
|
"data": {
|
|
"chatGuid": "iMessage;+;chat001",
|
|
"senderGuid": "+8613800138000",
|
|
"display": True,
|
|
},
|
|
}
|
|
result = adapter.normalize_inbound(event)
|
|
assert result.metadata["event"] == "typing"
|
|
assert result.metadata["display"] is True
|
|
|
|
def test_event_type_chat_name_change(self, adapter):
|
|
event = {
|
|
"type": "chat-name-change",
|
|
"data": {
|
|
"chatGuid": "iMessage;-;group001",
|
|
"newName": "New Name",
|
|
},
|
|
}
|
|
result = adapter.normalize_inbound(event)
|
|
assert result.metadata["event"] == "chat_name_change"
|
|
assert result.metadata["new_name"] == "New Name"
|
|
|
|
def test_event_type_chat_read_receipt(self, adapter):
|
|
event = {
|
|
"type": "chat-read-receipt",
|
|
"data": {
|
|
"chatGuid": "iMessage;+;chat001",
|
|
"senderGuid": "+8613800138000",
|
|
},
|
|
}
|
|
result = adapter.normalize_inbound(event)
|
|
assert result.event_type == EventType.MESSAGE_UPDATED
|
|
|
|
def test_event_type_participant_added(self, adapter):
|
|
event = {
|
|
"type": "participant-added",
|
|
"data": {
|
|
"chatGuid": "iMessage;-;group001",
|
|
"user": {"address": "+8613900139000"},
|
|
},
|
|
}
|
|
result = adapter.normalize_inbound(event)
|
|
assert result.metadata.get("event") == "member.joined"
|
|
|
|
def test_event_type_participant_removed(self, adapter):
|
|
event = {
|
|
"type": "participant-removed",
|
|
"data": {
|
|
"chatGuid": "iMessage;-;group001",
|
|
"user": {"address": "+8613900139000"},
|
|
},
|
|
}
|
|
result = adapter.normalize_inbound(event)
|
|
assert result.metadata.get("event") == "member.left"
|
|
|
|
def test_event_type_participant_left(self, adapter):
|
|
event = {
|
|
"type": "participant-left",
|
|
"data": {
|
|
"chatGuid": "iMessage;-;group001",
|
|
"user": {"address": "+8613900139000"},
|
|
},
|
|
}
|
|
result = adapter.normalize_inbound(event)
|
|
assert result.metadata.get("event") == "participant-left"
|
|
|
|
def test_event_type_server_event(self, adapter):
|
|
event = {
|
|
"type": "server-event",
|
|
"data": {"event": "startup"},
|
|
}
|
|
result = adapter.normalize_inbound(event)
|
|
assert result.metadata["event"] == "server-event"
|
|
|
|
def test_normalize_empty_text_message(self, adapter):
|
|
event = make_dm_event(text="")
|
|
result = adapter.normalize_inbound(event)
|
|
assert isinstance(result, ChannelMessage)
|
|
|
|
def test_normalize_message_with_sender(self, adapter):
|
|
event = make_dm_event(text="Test", sender="+8613900139000")
|
|
result = adapter.normalize_inbound(event)
|
|
assert result.identity.channel_user_id == "+8613900139000"
|
|
|
|
|
|
class TestBlueBubblesAdapterTTS:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter(
|
|
{
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test",
|
|
}
|
|
)
|
|
|
|
def test_effective_capabilities_tts_included(self, adapter):
|
|
caps = adapter.effective_capabilities
|
|
assert caps.tts is not None
|
|
|
|
def test_effective_capabilities_action_gate(self, adapter):
|
|
adapter._action_gate.set_private_api_available(True)
|
|
adapter._action_gate.set_macos_26_or_higher(False)
|
|
caps = adapter.effective_capabilities
|
|
assert caps.edit is True
|
|
assert caps.unsend is True
|
|
|
|
|
|
class TestBlueBubblesStatusIssues:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return BlueBubblesAdapter(
|
|
{
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test",
|
|
}
|
|
)
|
|
|
|
def test_status_issues_created(self, adapter):
|
|
assert adapter._status_issues is not None
|
|
initial = adapter._status_issues.get_issues()
|
|
assert initial == []
|
|
|
|
def test_status_issues_check_connection(self, adapter):
|
|
adapter._status_issues.check_connection("disconnected", False)
|
|
issues = adapter._status_issues.get_issues()
|
|
assert len(issues) > 0
|
|
assert any("disconnected" in str(i.get("message", "")).lower() for i in issues)
|
|
|
|
def test_status_issues_clear(self, adapter):
|
|
adapter._status_issues.check_connection("disconnected", False)
|
|
adapter._status_issues.clear()
|
|
assert adapter._status_issues.get_issues() == []
|
|
|
|
|
|
class TestBlueBubblesExecApproval:
|
|
def test_create_and_get_request(self):
|
|
from yuxi.channels.adapters.bluebubbles.exec_approval import ExecApproval
|
|
|
|
approval = ExecApproval()
|
|
req_id = approval.create_approval_request("chat_001", "sudo rm -rf /")
|
|
req = approval.get_request(req_id)
|
|
assert req is not None
|
|
assert req["status"] == "pending"
|
|
assert req["command"] == "sudo rm -rf /"
|
|
|
|
def test_approve_request(self):
|
|
from yuxi.channels.adapters.bluebubbles.exec_approval import ExecApproval
|
|
|
|
approval = ExecApproval()
|
|
req_id = approval.create_approval_request("chat_001", "delete-all")
|
|
assert approval.approve(req_id) is True
|
|
req = approval.get_request(req_id)
|
|
assert req["status"] == "approved"
|
|
|
|
def test_reject_request(self):
|
|
from yuxi.channels.adapters.bluebubbles.exec_approval import ExecApproval
|
|
|
|
approval = ExecApproval()
|
|
req_id = approval.create_approval_request("chat_001", "delete-all")
|
|
assert approval.reject(req_id) is True
|
|
req = approval.get_request(req_id)
|
|
assert req["status"] == "rejected"
|
|
|
|
def test_approve_nonexistent_request(self):
|
|
from yuxi.channels.adapters.bluebubbles.exec_approval import ExecApproval
|
|
|
|
approval = ExecApproval()
|
|
assert approval.approve("nonexistent") is False
|
|
|
|
def test_cleanup_removes_resolved(self):
|
|
from yuxi.channels.adapters.bluebubbles.exec_approval import ExecApproval
|
|
|
|
approval = ExecApproval()
|
|
req_id = approval.create_approval_request("chat_001", "clean")
|
|
approval.approve(req_id)
|
|
approval.cleanup()
|
|
assert approval.get_request(req_id) is None
|
|
|
|
|
|
class TestBlueBubblesMarkdownRenderer:
|
|
def test_basic_bold(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage
|
|
|
|
result = render_markdown_to_imessage("**bold**")
|
|
assert "<b>bold</b>" in result
|
|
|
|
def test_basic_italic(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage
|
|
|
|
result = render_markdown_to_imessage("*italic*")
|
|
assert "<i>italic</i>" in result
|
|
|
|
def test_nested_bold_italic(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage
|
|
|
|
result = render_markdown_to_imessage("**bold *italic* text**")
|
|
assert "<b>bold <i>italic</i> text</b>" in result
|
|
|
|
def test_multiple_nested(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage
|
|
|
|
result = render_markdown_to_imessage("*italic **bold** more*")
|
|
assert "<i>italic <b>bold</b> more</i>" in result
|
|
|
|
def test_empty_input(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage
|
|
|
|
assert render_markdown_to_imessage("") == ""
|
|
|
|
def test_code_preserved(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage
|
|
|
|
result = render_markdown_to_imessage("`code here` not code")
|
|
assert "`code here`" in result
|
|
assert "not code" in result
|
|
|
|
def test_underscore_bold(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage
|
|
|
|
result = render_markdown_to_imessage("__bold__")
|
|
assert "<b>bold</b>" in result
|
|
|
|
def test_strikethrough(self):
|
|
from yuxi.channels.adapters.bluebubbles.renderer import render_markdown_to_imessage
|
|
|
|
result = render_markdown_to_imessage("~~strikethrough~~")
|
|
assert "<s>strikethrough</s>" in result
|
|
|
|
|
|
class TestBlueBubblesStreamingTypingSync:
|
|
def test_typing_sync_created(self):
|
|
adapter = BlueBubblesAdapter(
|
|
{
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test",
|
|
}
|
|
)
|
|
assert adapter._typing_sync is not None
|
|
|
|
def test_typing_sync_no_client(self):
|
|
adapter = BlueBubblesAdapter(
|
|
{
|
|
"server_url": "http://localhost:1234",
|
|
"password": "test",
|
|
}
|
|
)
|
|
assert adapter._typing_sync._client is None
|
|
|
|
def test_typing_sync_stop_all_no_chats(self):
|
|
from yuxi.channels.adapters.bluebubbles.streaming_enhanced import StreamingTypingSync
|
|
|
|
sync = StreamingTypingSync()
|
|
assert len(sync._active_chats) == 0
|
|
|
|
|
|
class TestBlueBubblesConversationBindings:
|
|
def test_bind_and_resolve(self):
|
|
from yuxi.channels.adapters.bluebubbles.conversation_bindings import ConversationBindings
|
|
|
|
bindings = ConversationBindings()
|
|
bindings.bind("conv_1", "chat_guid_1", {"key": "value"})
|
|
resolved = bindings.resolve("conv_1")
|
|
assert resolved is not None
|
|
assert resolved["chat_guid"] == "chat_guid_1"
|
|
assert resolved["context"]["key"] == "value"
|
|
|
|
def test_resolve_nonexistent(self):
|
|
from yuxi.channels.adapters.bluebubbles.conversation_bindings import ConversationBindings
|
|
|
|
bindings = ConversationBindings()
|
|
assert bindings.resolve("nonexistent") is None
|
|
|
|
def test_resolve_by_chat_guid(self):
|
|
from yuxi.channels.adapters.bluebubbles.conversation_bindings import ConversationBindings
|
|
|
|
bindings = ConversationBindings()
|
|
bindings.bind("conv_2", "chat_guid_2", {})
|
|
assert bindings.resolve_by_chat_guid("chat_guid_2") == "conv_2"
|
|
|
|
def test_unbind(self):
|
|
from yuxi.channels.adapters.bluebubbles.conversation_bindings import ConversationBindings
|
|
|
|
bindings = ConversationBindings()
|
|
bindings.bind("conv_3", "chat_guid_3", {})
|
|
bindings.unbind("conv_3")
|
|
assert bindings.resolve("conv_3") is None
|
|
|
|
|
|
class TestBlueBubblesConversationID:
|
|
def test_generate_deterministic(self):
|
|
from yuxi.channels.adapters.bluebubbles.conversation_id import ConversationID
|
|
|
|
gen = ConversationID()
|
|
id1 = gen.generate("bluebubbles", "chat_001", "")
|
|
id2 = gen.generate("bluebubbles", "chat_001", "")
|
|
assert id1 == id2
|
|
assert len(id1) == 32
|
|
|
|
def test_generate_from_context(self):
|
|
from yuxi.channels.adapters.bluebubbles.conversation_id import ConversationID
|
|
|
|
gen = ConversationID()
|
|
ctx = {"channel_id": "bb", "chat_id": "c1", "thread_id": "t1"}
|
|
id1 = gen.generate_from_context(ctx)
|
|
id2 = gen.generate("bb", "c1", "t1")
|
|
assert id1 == id2
|