ForcePilot/backend/test/unit/channels/test_channels_bluebubbles_supplement.py

1230 lines
45 KiB
Python
Raw Normal View History

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