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