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