1230 lines
45 KiB
Python
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
|