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

1230 lines
45 KiB
Python

from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# =============================================================================
# InboundDedupe
# =============================================================================
class TestInboundDedupe:
@pytest.fixture
def dedupe(self):
from yuxi.channels.adapters.bluebubbles.inbound_dedupe import InboundDedupe
return InboundDedupe()
def test_not_duplicate_initially(self, dedupe):
assert dedupe.is_duplicate("msg_001") is False
def test_mark_seen_then_duplicate(self, dedupe):
dedupe.mark_seen("msg_001")
assert dedupe.is_duplicate("msg_001") is True
def test_different_guids_independent(self, dedupe):
dedupe.mark_seen("msg_001")
assert dedupe.is_duplicate("msg_002") is False
def test_evict_expired(self, dedupe):
dedupe.mark_seen("msg_old")
dedupe._seen["msg_old"] = dedupe._seen["msg_old"] - dedupe.TTL_SECONDS - 10
assert dedupe.is_duplicate("msg_old") is False
# =============================================================================
# ReplyCache
# =============================================================================
class TestReplyCache:
@pytest.fixture
def cache(self):
from yuxi.channels.adapters.bluebubbles.reply_cache import ReplyCache
return ReplyCache()
def test_store_and_resolve(self, cache):
cache.store("chat_001", "short_1", "full-uuid-001")
assert cache.resolve("chat_001", "short_1") == "full-uuid-001"
def test_resolve_missing(self, cache):
assert cache.resolve("chat_001", "nonexistent") is None
def test_resolve_expired(self, cache):
cache.store("chat_001", "short_1", "full-uuid-001")
cache._cache[("chat_001", "short_1")] = ("full-uuid-001", cache._cache[("chat_001", "short_1")][1] - cache.TTL_SECONDS - 10)
assert cache.resolve("chat_001", "short_1") is None
def test_trim_excess_entries(self, cache):
from yuxi.channels.adapters.bluebubbles.reply_cache import ReplyCache
for i in range(ReplyCache.MAX_ENTRIES + 10):
cache.store(f"chat_{i}", f"short_{i}", f"uuid_{i}")
assert len(cache._cache) <= ReplyCache.MAX_ENTRIES
# =============================================================================
# SentMessageCache
# =============================================================================
class TestSentMessageCache:
@pytest.fixture
def cache(self):
from yuxi.channels.adapters.bluebubbles.sent_cache import SentMessageCache
return SentMessageCache()
def test_track_and_lookup(self, cache):
cache.track("msg_001", "chat_001", {"key": "val"})
entry = cache.lookup("msg_001")
assert entry is not None
assert entry["chat_guid"] == "chat_001"
assert entry["metadata"]["key"] == "val"
def test_track_no_metadata(self, cache):
cache.track("msg_002", "chat_002")
entry = cache.lookup("msg_002")
assert entry is not None
assert entry["metadata"] == {}
def test_lookup_missing(self, cache):
assert cache.lookup("nonexistent") is None
def test_lookup_expired(self, cache):
cache.track("msg_003", "chat_003")
cache._cache["msg_003"]["sent_at"] -= cache.TTL_SECONDS + 10
assert cache.lookup("msg_003") is None
def test_remove(self, cache):
cache.track("msg_004", "chat_004")
cache.remove("msg_004")
assert cache.lookup("msg_004") is None
def test_trim_excess(self, cache):
from yuxi.channels.adapters.bluebubbles.sent_cache import SentMessageCache
for i in range(SentMessageCache.MAX_ENTRIES + 10):
cache.track(f"msg_{i}", f"chat_{i}")
assert len(cache._cache) <= SentMessageCache.MAX_ENTRIES
# =============================================================================
# SelfChatCache
# =============================================================================
class TestSelfChatCache:
@pytest.fixture
def cache(self):
from yuxi.channels.adapters.bluebubbles.self_chat_cache import SelfChatCache
return SelfChatCache()
def test_not_self_initially(self, cache):
assert cache.is_self_message("msg_001") is False
def test_add_then_self(self, cache):
cache.add("msg_001")
assert cache.is_self_message("msg_001") is True
def test_expired_returns_false(self, cache):
cache.add("msg_old")
cache._cache["msg_old"] -= cache.TTL_SECONDS + 10
assert cache.is_self_message("msg_old") is False
def test_trim_excess(self, cache):
from yuxi.channels.adapters.bluebubbles.self_chat_cache import SelfChatCache
for i in range(SelfChatCache.MAX_ENTRIES + 10):
cache.add(f"msg_{i}")
assert len(cache._cache) <= SelfChatCache.MAX_ENTRIES
# =============================================================================
# MonitorDebounce
# =============================================================================
class TestMonitorDebounce:
@pytest.fixture
def debounce(self):
from yuxi.channels.adapters.bluebubbles.monitor_debounce import MonitorDebounce
return MonitorDebounce()
def test_first_always_processed(self, debounce):
assert debounce.should_process("msg_001") is True
def test_second_within_window_skipped(self, debounce):
debounce.should_process("msg_001")
assert debounce.should_process("msg_001") is False
def test_different_guids_independent(self, debounce):
debounce.should_process("msg_001")
assert debounce.should_process("msg_002") is True
def test_trim_excess(self, debounce):
from yuxi.channels.adapters.bluebubbles.monitor_debounce import MonitorDebounce
for i in range(MonitorDebounce.MAX_ENTRIES + 10):
debounce.should_process(f"msg_{i}")
assert len(debounce._cache) <= MonitorDebounce.MAX_ENTRIES
# =============================================================================
# MessageEffects
# =============================================================================
class TestMessageEffects:
def test_effect_map_entries(self):
from yuxi.channels.adapters.bluebubbles.message_effects import EFFECT_MAP
assert len(EFFECT_MAP) > 0
assert "slam" in EFFECT_MAP
assert "confetti" in EFFECT_MAP
assert "lasers" in EFFECT_MAP
assert "fireworks" in EFFECT_MAP
def test_normalize_key(self):
from yuxi.channels.adapters.bluebubbles.message_effects import _normalize_key
assert _normalize_key("invisible-ink") == "invisibleink"
assert _normalize_key("invisible_ink") == "invisibleink"
assert _normalize_key("Invisible Ink") == "invisibleink"
assert _normalize_key("Loud") == "loud"
def test_resolve_effect_exact(self):
from yuxi.channels.adapters.bluebubbles.message_effects import resolve_effect
assert resolve_effect("slam") is not None
assert "loud" in resolve_effect("slam")
assert resolve_effect("confetti") is not None
def test_resolve_effect_case_insensitive(self):
from yuxi.channels.adapters.bluebubbles.message_effects import resolve_effect
assert resolve_effect("CONFETTI") is not None
assert resolve_effect("Lasers") is not None
def test_resolve_effect_aliases(self):
from yuxi.channels.adapters.bluebubbles.message_effects import resolve_effect
assert resolve_effect("invisible_ink") is not None
assert resolve_effect("invisible-ink") is not None
assert resolve_effect("invisible") is not None
def test_resolve_effect_unknown(self):
from yuxi.channels.adapters.bluebubbles.message_effects import resolve_effect
assert resolve_effect("nonexistent_effect") is None
assert resolve_effect("") is None
@pytest.mark.asyncio
async def test_send_with_effect_valid(self):
from yuxi.channels.adapters.bluebubbles.message_effects import send_with_effect
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value={"guid": "effect_msg_001"})
result = await send_with_effect(mock_client, "chat_001", "Hello", "slam")
assert result["guid"] == "effect_msg_001"
call_kwargs = mock_client.post.call_args
assert call_kwargs[1]["json"]["effectId"] is not None
@pytest.mark.asyncio
async def test_send_with_effect_invalid(self):
from yuxi.channels.adapters.bluebubbles.message_effects import send_with_effect
mock_client = AsyncMock()
with pytest.raises(ValueError, match="Unknown effect"):
await send_with_effect(mock_client, "chat_001", "Hello", "invalid_effect")
@pytest.mark.asyncio
async def test_send_with_effect_with_reply(self):
from yuxi.channels.adapters.bluebubbles.message_effects import send_with_effect
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value={"guid": "reply_effect_001"})
result = await send_with_effect(mock_client, "chat_001", "Reply", "gentle", reply_to_guid="orig_001")
assert "replyToGuid" in mock_client.post.call_args[1]["json"]
# =============================================================================
# GroupActions
# =============================================================================
class TestGroupActions:
@pytest.mark.asyncio
async def test_rename_chat(self):
from yuxi.channels.adapters.bluebubbles.group_actions import rename_chat
mock_client = AsyncMock()
mock_client.put = AsyncMock(return_value={"status": "ok"})
result = await rename_chat(mock_client, "group_001", "New Name")
assert result["status"] == "ok"
call_args = mock_client.put.call_args
assert call_args[1]["json"]["displayName"] == "New Name"
@pytest.mark.asyncio
async def test_add_participant(self):
from yuxi.channels.adapters.bluebubbles.group_actions import add_participant
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value={"status": "ok"})
result = await add_participant(mock_client, "group_001", "+8613900139000")
assert result["status"] == "ok"
@pytest.mark.asyncio
async def test_remove_participant(self):
from yuxi.channels.adapters.bluebubbles.group_actions import remove_participant
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value={"status": "ok"})
result = await remove_participant(mock_client, "group_001", "+8613900139000")
assert result["status"] == "ok"
@pytest.mark.asyncio
async def test_leave_chat(self):
from yuxi.channels.adapters.bluebubbles.group_actions import leave_chat
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value={"status": "ok"})
result = await leave_chat(mock_client, "group_001")
assert result["status"] == "ok"
# =============================================================================
# Directory (list/search chats/contacts)
# =============================================================================
class TestDirectory:
@pytest.mark.asyncio
async def test_list_chats_success(self):
from yuxi.channels.adapters.bluebubbles.directory import list_chats
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value={"data": [{"guid": "chat_001"}, {"guid": "chat_002"}]})
result = await list_chats(mock_client)
assert len(result) == 2
assert result[0]["guid"] == "chat_001"
@pytest.mark.asyncio
async def test_list_chats_error(self):
from yuxi.channels.adapters.bluebubbles.directory import list_chats
mock_client = AsyncMock()
mock_client.get = AsyncMock(side_effect=Exception("network error"))
result = await list_chats(mock_client)
assert result == []
@pytest.mark.asyncio
async def test_list_contacts_success(self):
from yuxi.channels.adapters.bluebubbles.directory import list_contacts
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value={"data": [{"name": "Alice"}, {"name": "Bob"}]})
result = await list_contacts(mock_client)
assert len(result) == 2
@pytest.mark.asyncio
async def test_list_contacts_empty_data(self):
from yuxi.channels.adapters.bluebubbles.directory import list_contacts
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value={})
result = await list_contacts(mock_client)
assert result == []
@pytest.mark.asyncio
async def test_search_chats(self):
from yuxi.channels.adapters.bluebubbles.directory import search_chats
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value={"data": [{"guid": "chat_001"}]})
result = await search_chats(mock_client, "test query")
assert len(result) == 1
@pytest.mark.asyncio
async def test_search_chats_error(self):
from yuxi.channels.adapters.bluebubbles.directory import search_chats
mock_client = AsyncMock()
mock_client.get = AsyncMock(side_effect=Exception("error"))
result = await search_chats(mock_client, "xyz")
assert result == []
@pytest.mark.asyncio
async def test_search_contacts(self):
from yuxi.channels.adapters.bluebubbles.directory import search_contacts
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value={"data": [{"name": "Alice"}]})
result = await search_contacts(mock_client, "+8613800138000")
assert len(result) == 1
# =============================================================================
# Targets (parse_target, resolve_chat_guid, normalize_bluebubbles_handle)
# =============================================================================
class TestTargets:
def test_normalize_handle(self):
from yuxi.channels.adapters.bluebubbles.targets import normalize_bluebubbles_handle
assert normalize_bluebubbles_handle("+8613800138000") == "8613800138000"
assert normalize_bluebubbles_handle(" +8613800138000 ") == "8613800138000"
assert normalize_bluebubbles_handle("8613800138000") == "8613800138000"
def test_parse_target_chat_guid(self):
from yuxi.channels.adapters.bluebubbles.targets import parse_target
result = parse_target("chat_guid:abc123")
assert result["type"] == "chat_guid"
assert result["value"] == "abc123"
def test_parse_target_group(self):
from yuxi.channels.adapters.bluebubbles.targets import parse_target
result = parse_target("group:MyGroup")
assert result["type"] == "group_identifier"
assert result["value"] == "MyGroup"
def test_parse_target_chat_id(self):
from yuxi.channels.adapters.bluebubbles.targets import parse_target
result = parse_target("chat_id:123")
assert result["type"] == "chat_id"
assert result["value"] == "123"
def test_parse_target_handle_prefix(self):
from yuxi.channels.adapters.bluebubbles.targets import parse_target
result = parse_target("handle:+8613800138000")
assert result["type"] == "handle"
assert result["value"] == "8613800138000"
def test_parse_target_phone_number(self):
from yuxi.channels.adapters.bluebubbles.targets import parse_target
result = parse_target("+8613800138000")
assert result["type"] == "handle"
assert result["value"] == "8613800138000"
def test_parse_target_with_semicolon(self):
from yuxi.channels.adapters.bluebubbles.targets import parse_target
result = parse_target("iMessage;+;chat001")
assert result["type"] == "group_identifier"
assert result["value"] == "iMessage;+;chat001"
def test_parse_target_default(self):
from yuxi.channels.adapters.bluebubbles.targets import parse_target
result = parse_target("some_guid")
assert result["type"] == "chat_guid"
@pytest.mark.asyncio
async def test_resolve_chat_guid_direct(self):
from yuxi.channels.adapters.bluebubbles.targets import resolve_chat_guid
mock_client = AsyncMock()
result = await resolve_chat_guid(mock_client, "chat_guid:direct_guid")
assert result == "direct_guid"
@pytest.mark.asyncio
async def test_resolve_chat_guid_by_handle(self):
from yuxi.channels.adapters.bluebubbles.targets import resolve_chat_guid
mock_client = AsyncMock()
mock_client.get = AsyncMock(
return_value={
"data": [
{
"guid": "chat_handle_001",
"participants": [{"address": "+8613800138000"}],
}
]
}
)
result = await resolve_chat_guid(mock_client, "handle:+8613800138000")
assert result == "chat_handle_001"
@pytest.mark.asyncio
async def test_resolve_chat_guid_by_handle_not_found(self):
from yuxi.channels.adapters.bluebubbles.targets import resolve_chat_guid
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value={"data": []})
result = await resolve_chat_guid(mock_client, "handle:+8613800138000")
assert result is None
@pytest.mark.asyncio
async def test_resolve_chat_guid_by_group_identifier(self):
from yuxi.channels.adapters.bluebubbles.targets import resolve_chat_guid
mock_client = AsyncMock()
mock_client.get = AsyncMock(
return_value={
"data": [
{"guid": "group_chat_001", "displayName": "MyGroup"},
]
}
)
result = await resolve_chat_guid(mock_client, "group:MyGroup")
assert result == "group_chat_001"
@pytest.mark.asyncio
async def test_resolve_chat_guid_query_error(self):
from yuxi.channels.adapters.bluebubbles.targets import resolve_chat_guid
mock_client = AsyncMock()
mock_client.get = AsyncMock(side_effect=Exception("error"))
result = await resolve_chat_guid(mock_client, "handle:+8613800138000")
assert result is None
# =============================================================================
# ConversationRoute
# =============================================================================
class TestConversationRoute:
@pytest.fixture
def route(self):
from yuxi.channels.adapters.bluebubbles.conversation_route import ConversationRoute
return ConversationRoute()
def test_register_and_resolve(self, route):
route.register("conv_1", "adapter_key_1")
assert route.resolve("conv_1") == "adapter_key_1"
def test_resolve_nonexistent(self, route):
assert route.resolve("nonexistent") is None
def test_unregister(self, route):
route.register("conv_2", "key_2")
route.unregister("conv_2")
assert route.resolve("conv_2") is None
def test_register_overwrites(self, route):
route.register("conv_3", "old_key")
route.register("conv_3", "new_key")
assert route.resolve("conv_3") == "new_key"
# =============================================================================
# SessionRoute
# =============================================================================
class TestSessionRoute:
@pytest.fixture
def route(self):
from yuxi.channels.adapters.bluebubbles.session_route import SessionRoute
return SessionRoute()
def test_register_and_resolve(self, route):
route.register("channel_1", {"key": "value"})
assert route.resolve("channel_1") == {"key": "value"}
def test_resolve_nonexistent(self, route):
assert route.resolve("nonexistent") is None
def test_resolve_all(self, route):
route.register("ch1", {"a": 1})
route.register("ch2", {"b": 2})
all_sessions = route.resolve_all()
assert len(all_sessions) == 2
def test_remove(self, route):
route.register("ch3", {"c": 3})
route.remove("ch3")
assert route.resolve("ch3") is None
# =============================================================================
# WebhookAuth
# =============================================================================
class TestWebhookAuth:
def test_extract_signature_bluebubbles(self):
from yuxi.channels.adapters.bluebubbles.webhook_auth import extract_signature
sig = extract_signature({"X-BlueBubbles-Signature": "sha256=abc123"})
assert sig == "sha256=abc123"
def test_extract_signature_github(self):
from yuxi.channels.adapters.bluebubbles.webhook_auth import extract_signature
sig = extract_signature({"X-Hub-Signature-256": "sha256=def456"})
assert sig == "sha256=def456"
def test_extract_signature_case_insensitive(self):
from yuxi.channels.adapters.bluebubbles.webhook_auth import extract_signature
sig = extract_signature({"x-bluebubbles-signature": "sha256=ghi789"})
assert sig == "sha256=ghi789"
def test_extract_signature_none(self):
from yuxi.channels.adapters.bluebubbles.webhook_auth import extract_signature
sig = extract_signature({"Content-Type": "application/json"})
assert sig is None
def test_verify_signature_valid(self):
from yuxi.channels.adapters.bluebubbles.webhook_auth import verify_webhook_signature
import hashlib
import hmac
secret = "test-secret"
body = b'{"key":"value"}'
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
assert verify_webhook_signature(secret, body, f"sha256={expected}") is True
def test_verify_signature_invalid(self):
from yuxi.channels.adapters.bluebubbles.webhook_auth import verify_webhook_signature
assert verify_webhook_signature("secret", b"body", "sha256=wronghash") is False
def test_verify_signature_empty_secret(self):
from yuxi.channels.adapters.bluebubbles.webhook_auth import verify_webhook_signature
assert verify_webhook_signature("", b"body", "sha256=something") is False
def test_verify_signature_empty_header(self):
from yuxi.channels.adapters.bluebubbles.webhook_auth import verify_webhook_signature
assert verify_webhook_signature("secret", b"body", "") is False
# =============================================================================
# WebhookIngress
# =============================================================================
class TestWebhookIngress:
@pytest.fixture
def ingress_no_secret(self):
from yuxi.channels.adapters.bluebubbles.webhook_ingress import WebhookIngress
return WebhookIngress()
@pytest.fixture
def ingress_with_secret(self):
from yuxi.channels.adapters.bluebubbles.webhook_ingress import WebhookIngress
import hashlib
import hmac
secret = "test-secret"
body = b'{"type":"new-message"}'
expected_sig = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
return WebhookIngress(webhook_secret=secret), body, expected_sig
def test_validate_no_secret_configured(self, ingress_no_secret):
assert ingress_no_secret.validate({}, b"anything") is True
def test_validate_missing_signature(self, ingress_with_secret):
ingress, body, _ = ingress_with_secret
assert ingress.validate({}, body) is False
def test_validate_valid_signature(self, ingress_with_secret):
ingress, body, expected_sig = ingress_with_secret
assert ingress.validate({"X-BlueBubbles-Signature": f"sha256={expected_sig}"}, body) is True
def test_validate_invalid_signature(self, ingress_with_secret):
ingress, body, _ = ingress_with_secret
assert ingress.validate({"X-BlueBubbles-Signature": "sha256=wrong"}, body) is False
def test_parse_event_valid_json(self, ingress_no_secret):
event = ingress_no_secret.parse_event(b'{"type":"new-message"}')
assert event is not None
assert event["type"] == "new-message"
def test_parse_event_invalid_json(self, ingress_no_secret):
event = ingress_no_secret.parse_event(b"not json")
assert event is None
# =============================================================================
# SecretContract
# =============================================================================
class TestSecretContract:
@pytest.fixture
def contract(self):
from yuxi.channels.adapters.bluebubbles.secret_contract import SecretContract
return SecretContract()
def test_register_and_list(self, contract):
contract.register("api_key", "OpenAI API Key for vision")
secrets = contract.list_secrets()
assert len(secrets) == 1
assert secrets[0]["label"] == "api_key"
def test_audit_registered_key(self, contract):
contract.register("api_key", "Test key")
contract.audit("api_key", "read", {"caller": "vision"})
log = contract.get_audit_log("api_key")
assert len(log) == 1
assert log[0]["action"] == "read"
def test_audit_unregistered_key(self, contract):
contract.audit("unknown_key", "read")
log = contract.get_audit_log("unknown_key")
assert log == []
def test_audit_log_trimming(self, contract):
from yuxi.channels.adapters.bluebubbles.secret_contract import SecretContract
contract.register("key", "desc")
max_entries = contract._MAX_AUDIT_PER_KEY
for i in range(max_entries + 10):
contract.audit("key", f"action_{i}")
log = contract.get_audit_log("key")
assert len(log) <= max_entries
def test_clear(self, contract):
contract.register("k1", "d1")
contract.audit("k1", "use")
contract.clear()
assert contract.list_secrets() == []
assert contract.get_audit_log("k1") == []
# =============================================================================
# ContactResolver
# =============================================================================
class TestContactResolver:
def test_normalize_phone_lookup_key_standard(self):
from yuxi.channels.adapters.bluebubbles.contact_resolver import normalize_phone_lookup_key
assert normalize_phone_lookup_key("+8613800138000") == "+8613800138000"
def test_normalize_phone_10_digit_us(self):
from yuxi.channels.adapters.bluebubbles.contact_resolver import normalize_phone_lookup_key
assert normalize_phone_lookup_key("2125551234") == "+12125551234"
def test_normalize_phone_11_digit_us(self):
from yuxi.channels.adapters.bluebubbles.contact_resolver import normalize_phone_lookup_key
assert normalize_phone_lookup_key("12125551234") == "+12125551234"
def test_normalize_phone_strips_non_digit(self):
from yuxi.channels.adapters.bluebubbles.contact_resolver import normalize_phone_lookup_key
assert normalize_phone_lookup_key("(212) 555-1234") == "+12125551234"
class TestContactCache:
@pytest.fixture
def cache(self):
from yuxi.channels.adapters.bluebubbles.contact_resolver import ContactCache
return ContactCache()
def test_set_and_get(self, cache):
cache.set("+8613800138000", {"display_name": "Alice"})
result = cache.get("+8613800138000")
assert result is not None
assert result["display_name"] == "Alice"
def test_get_missing(self, cache):
assert cache.get("nonexistent") is None
def test_set_negative_and_get(self, cache):
cache.set_negative("+8613900139000")
result = cache.get("+8613900139000")
assert result is None
def test_positive_expiry(self, cache):
cache.set("key", {"val": 1})
cache._cache["key"] = (cache._cache["key"][0], cache._cache["key"][1] - cache.POSITIVE_TTL - 10)
assert cache.get("key") is None
def test_negative_expiry(self, cache):
cache.set_negative("neg_key")
cache._negative["neg_key"] -= cache.NEGATIVE_TTL + 10
assert cache.get("neg_key") is None
# =============================================================================
# ConfigApply
# =============================================================================
class TestConfigApply:
def test_merge_account_config(self):
from yuxi.channels.adapters.bluebubbles.config_apply import merge_account_config
top = {"server_url": "http://top", "password": "top_pw", "dm_policy": "open"}
account = {"server_url": "http://account", "dm_policy": "allowlist"}
merged = merge_account_config(top, account)
assert merged["server_url"] == "http://account"
assert merged["dm_policy"] == "allowlist"
assert merged["password"] == "top_pw"
def test_merge_account_config_empty_account_fields_ignored(self):
from yuxi.channels.adapters.bluebubbles.config_apply import merge_account_config
top = {"server_url": "http://top", "password": "top_pw"}
account = {"server_url": "", "password": ""}
merged = merge_account_config(top, account)
assert merged["server_url"] == "http://top"
assert merged["password"] == "top_pw"
def test_apply_account_configs(self):
from yuxi.channels.adapters.bluebubbles.config_apply import apply_account_configs
top = {"server_url": "http://top", "password": "top_pw"}
accounts = {"acc1": {"server_url": "http://acc1"}}
result = apply_account_configs(top, accounts)
assert "default" in result
assert "acc1" in result
assert result["acc1"]["server_url"] == "http://acc1"
def test_apply_account_configs_disabled(self):
from yuxi.channels.adapters.bluebubbles.config_apply import apply_account_configs
top = {"server_url": "http://top", "password": "top_pw"}
accounts = {"acc1": {"server_url": "http://acc1"}}
result = apply_account_configs(top, accounts, allow_config_writes=False)
assert "acc1" not in result
assert "default" in result
def test_apply_account_configs_skips_non_dict(self):
from yuxi.channels.adapters.bluebubbles.config_apply import apply_account_configs
top = {"server_url": "http://top"}
accounts = {"acc1": "not_a_dict", "acc2": {"server_url": "http://acc2"}}
result = apply_account_configs(top, accounts)
assert "acc1" not in result
assert "acc2" in result
# =============================================================================
# AccountResolve
# =============================================================================
class TestAccountResolve:
def test_resolve_by_account_id(self):
from yuxi.channels.adapters.bluebubbles.account_resolve import resolve_account
accounts = {"acc1": {"server_url": "http://a"}, "acc2": {"server_url": "http://b"}}
result = resolve_account(accounts, account_id="acc1")
assert result["server_url"] == "http://a"
def test_resolve_default(self):
from yuxi.channels.adapters.bluebubbles.account_resolve import resolve_account
accounts = {"default": {"server_url": "http://d"}, "acc1": {"server_url": "http://a"}}
result = resolve_account(accounts)
assert result["server_url"] == "http://d"
def test_resolve_first_when_no_default(self):
from yuxi.channels.adapters.bluebubbles.account_resolve import resolve_account
accounts = {"acc1": {"server_url": "http://a"}, "acc2": {"server_url": "http://b"}}
result = resolve_account(accounts)
assert result is not None
def test_resolve_empty_accounts(self):
from yuxi.channels.adapters.bluebubbles.account_resolve import resolve_account
assert resolve_account({}) is None
def test_resolve_account_for_handle(self):
from yuxi.channels.adapters.bluebubbles.account_resolve import resolve_account_for_handle
accounts = {
"acc1": {"server_url": "http://a", "handle_mapping": {"+8613800138000": "contact1"}},
"acc2": {"server_url": "http://b", "handle_mapping": {}},
}
result = resolve_account_for_handle(accounts, "+8613800138000")
assert result is not None
assert result["server_url"] == "http://a"
def test_resolve_account_for_handle_not_found(self):
from yuxi.channels.adapters.bluebubbles.account_resolve import resolve_account_for_handle
accounts = {"acc1": {"server_url": "http://a", "handle_mapping": {}}}
result = resolve_account_for_handle(accounts, "+8613900139000")
assert result is None
# =============================================================================
# MessageHistory
# =============================================================================
class TestMessageHistory:
@pytest.fixture
def history(self):
from yuxi.channels.adapters.bluebubbles.history import MessageHistory
return MessageHistory()
def test_add_and_get(self, history):
history.add("chat_001", {"text": "Hello"})
msgs = history.get("chat_001")
assert len(msgs) == 1
assert msgs[0]["text"] == "Hello"
def test_get_limit(self, history):
for i in range(100):
history.add("chat_001", {"text": f"msg_{i}"})
msgs = history.get("chat_001", limit=10)
assert len(msgs) == 10
def test_get_since(self, history):
history.add("chat_001", {"text": "old"})
history.add("chat_001", {"text": "new"})
msgs = history._history["chat_001"]
msgs[0]["_recorded_at"] = 1.0
msgs[1]["_recorded_at"] = 100.0
msgs = history.get_since("chat_001", 50.0)
assert len(msgs) == 1
assert msgs[0]["text"] == "new"
def test_get_nonexistent_chat(self, history):
msgs = history.get("nonexistent")
assert msgs == []
def test_trim_excess(self, history):
from yuxi.channels.adapters.bluebubbles.history import MessageHistory
for i in range(MessageHistory.MAX_MESSAGES + 50):
history.add("chat_001", {"text": f"msg_{i}"})
msgs = history.get("chat_001")
assert len(msgs) <= MessageHistory.MAX_MESSAGES
def test_clear(self, history):
history.add("chat_001", {"text": "Hello"})
history.clear("chat_001")
assert history.get("chat_001") == []
# =============================================================================
# SetupCore
# =============================================================================
class TestSetupCore:
@pytest.mark.asyncio
async def test_validate_server_url_reachable(self):
from yuxi.channels.adapters.bluebubbles.setup_core import validate_server_url
mock_resp = AsyncMock()
mock_resp.status_code = 200
mock_resp.json = MagicMock(return_value={"data": {"os_version": "15.0"}})
with patch("httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client_cls.return_value = mock_client
result = await validate_server_url("http://localhost:1234")
assert result["reachable"] is True
assert "data" in result
assert result["data"]["os_version"] == "15.0"
@pytest.mark.asyncio
async def test_validate_server_url_unreachable(self):
from yuxi.channels.adapters.bluebubbles.setup_core import validate_server_url
with patch("httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client.get = AsyncMock(side_effect=Exception("Connection refused"))
mock_client_cls.return_value = mock_client
result = await validate_server_url("http://nonexistent:9999")
assert result["reachable"] is False
@pytest.mark.asyncio
async def test_validate_credentials_valid(self):
from yuxi.channels.adapters.bluebubbles.setup_core import validate_credentials
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client.get = AsyncMock(
return_value={"data": {"iMessageConnected": True}}
)
with patch(
"yuxi.channels.adapters.bluebubbles.setup_core.BlueBubblesClient",
return_value=mock_client,
):
result = await validate_credentials("http://localhost:1234", "secret")
assert result["valid"] is True
@pytest.mark.asyncio
async def test_validate_credentials_invalid(self):
from yuxi.channels.adapters.bluebubbles.setup_core import validate_credentials
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client.get = AsyncMock(side_effect=Exception("Auth failed"))
with patch(
"yuxi.channels.adapters.bluebubbles.setup_core.BlueBubblesClient",
return_value=mock_client,
):
result = await validate_credentials("http://localhost:1234", "wrong")
assert result["valid"] is False
def test_get_setup_guide(self):
from yuxi.channels.adapters.bluebubbles.setup_core import get_setup_guide
guide = get_setup_guide()
assert "steps" in guide
assert len(guide["steps"]) == 4
assert guide["steps"][0]["title"] == "Install BlueBubbles Server"
# =============================================================================
# ConfigUiHints
# =============================================================================
class TestConfigUiHints:
def test_get_config_ui_hints_returns_dict(self):
from yuxi.channels.adapters.bluebubbles.config_ui_hints import get_config_ui_hints
hints = get_config_ui_hints()
assert isinstance(hints, dict)
assert len(hints) > 0
assert "server_url" in hints
assert "password" in hints
assert hints["password"]["sensitive"] is True
# =============================================================================
# MessagePipeline
# =============================================================================
class TestMessagePipeline:
@pytest.fixture
def pipeline(self):
from yuxi.channels.adapters.bluebubbles.monitor_processing import MessagePipeline
return MessagePipeline()
@pytest.mark.asyncio
async def test_process_no_steps(self, pipeline):
result = await pipeline.process({"key": "value"})
assert result == {"key": "value"}
@pytest.mark.asyncio
async def test_process_with_step(self, pipeline):
async def add_field(event):
event["added"] = True
return event
pipeline.add_step(add_field)
result = await pipeline.process({"key": "value"})
assert result["added"] is True
assert result["key"] == "value"
@pytest.mark.asyncio
async def test_process_step_returns_none_stops(self, pipeline):
async def return_none(event):
return None
async def should_not_run(event):
event["ran"] = True
return event
pipeline.add_step(return_none)
pipeline.add_step(should_not_run)
result = await pipeline.process({"key": "value"})
assert result is None
@pytest.mark.asyncio
async def test_process_step_raises_returns_none(self, pipeline):
async def raise_error(event):
raise RuntimeError("step failed")
pipeline.add_step(raise_error)
result = await pipeline.process({"key": "value"})
assert result is None
# =============================================================================
# Voice (send_tts_voice, check_tts_available, constants)
# =============================================================================
class TestVoice:
def test_audio_format_mime_map(self):
from yuxi.channels.adapters.bluebubbles.voice import AUDIO_FORMAT_MIME_MAP
assert AUDIO_FORMAT_MIME_MAP["ogg"] == "audio/ogg"
assert AUDIO_FORMAT_MIME_MAP["caf"] == "audio/x-caf"
assert AUDIO_FORMAT_MIME_MAP["mp3"] == "audio/mpeg"
def test_check_tts_available(self):
from yuxi.channels.adapters.bluebubbles.voice import check_tts_available
result = check_tts_available()
assert "espeak" in result
assert "ffmpeg" in result
assert "pydub" in result
assert isinstance(result["espeak"], bool)
def test_supported_audio_file_formats(self):
from yuxi.channels.adapters.bluebubbles.voice import SUPPORTED_AUDIO_FILE_FORMATS
assert "mp3" in SUPPORTED_AUDIO_FILE_FORMATS
assert "caf" in SUPPORTED_AUDIO_FILE_FORMATS
def test_preferred_audio_format(self):
from yuxi.channels.adapters.bluebubbles.voice import PREFERRED_AUDIO_FORMAT
assert PREFERRED_AUDIO_FORMAT == "caf"
@pytest.mark.asyncio
async def test_send_tts_voice_no_espeak(self):
from yuxi.channels.adapters.bluebubbles.voice import send_tts_voice
mock_client = AsyncMock()
result = await send_tts_voice(mock_client, "chat_001", "Hello world")
assert "success" in result
assert result["success"] is False
# =============================================================================
# Edge case / boundary tests for already-tested modules
# =============================================================================
class TestAdapterEdgeCases:
@pytest.fixture
def adapter(self):
from yuxi.channels.adapters.bluebubbles.adapter import BlueBubblesAdapter
return BlueBubblesAdapter(
{
"server_url": "http://localhost:1234",
"password": "test-password",
}
)
def test_format_outbound_unsupported_type(self, adapter):
from yuxi.channels.models import ChannelIdentity, ChannelResponse, ChannelType, MessageType
response = ChannelResponse(
identity=ChannelIdentity(
channel_id="bluebubbles",
channel_type=ChannelType.BLUEBUBBLES,
channel_user_id="+8613800138000",
channel_chat_id="chat_001",
),
message_type=MessageType.TEXT,
content="fallback",
)
result = adapter.format_outbound(response)
assert "endpoint" in result
assert "payload" in result
def test_normalize_inbound_missing_chat_guid(self, adapter):
event = {
"type": "new-message",
"data": {
"chat": {"isGroup": False},
"message": {
"guid": "msg_001",
"text": "hello",
"sender": {"address": "+8613800138000"},
"attachments": [],
},
},
}
result = adapter.normalize_inbound(event)
assert isinstance(result, object)
def test_normalize_inbound_missing_sender(self, adapter):
event = {
"type": "new-message",
"data": {
"chat": {"guid": "chat_001", "isGroup": False},
"message": {
"guid": "msg_001",
"text": "hello",
"attachments": [],
},
},
}
result = adapter.normalize_inbound(event)
assert result.identity.channel_user_id == "unknown"
def test_normalize_inbound_empty_data(self, adapter):
event = {"type": "unknown-event", "data": {}}
result = adapter.normalize_inbound(event)
assert result.content == ""
def test_normalize_group_without_participants(self, adapter):
event = {
"type": "new-message",
"data": {
"chat": {"guid": "iMessage;-;group001", "isGroup": True, "displayName": "Group"},
"message": {
"guid": "msg_001",
"text": "hello",
"sender": {"address": "+8613800138000"},
"attachments": [],
},
},
}
result = adapter.normalize_inbound(event)
assert result.chat_type.value == "group"
assert result.metadata.get("group_participants") is not None
class TestBlueBubblesConfigAdditional:
def test_config_immutable_defaults(self):
from yuxi.channels.adapters.bluebubbles.config import BlueBubblesConfig
config1 = BlueBubblesConfig()
config2 = BlueBubblesConfig()
config1.dm_allow_from.append("test")
assert config2.dm_allow_from == []
def test_config_from_env_empty(self):
from yuxi.channels.adapters.bluebubbles.config import BlueBubblesConfig
with patch.dict("os.environ", {}, clear=True):
config = BlueBubblesConfig.from_env()
assert config.enabled is False
def test_config_password_not_in_repr(self):
from yuxi.channels.adapters.bluebubbles.config import BlueBubblesConfig
config = BlueBubblesConfig(password="secret123")
repr_str = repr(config)
assert "secret123" not in repr_str