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重连相关测试
1863 lines
65 KiB
Python
1863 lines
65 KiB
Python
"""Comprehensive unit tests for Signal channel adapter — covering untested functions,
|
||
edge cases, error handling, and boundary conditions across all modules.
|
||
"""
|
||
|
||
import asyncio
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from yuxi.channels.adapters.signal.channel import SignalChannel
|
||
from yuxi.channels.adapters.signal.client import (
|
||
RpcClient,
|
||
RpcError,
|
||
RateLimitError,
|
||
UnauthorizedError,
|
||
_validate_daemon_url,
|
||
_detect_rate_limit,
|
||
)
|
||
from yuxi.channels.adapters.signal.send import (
|
||
SignalSender,
|
||
_extract_chunk_styles,
|
||
_make_message_id,
|
||
)
|
||
from yuxi.channels.adapters.signal.format import (
|
||
FormattedText,
|
||
StyleRange,
|
||
markdown_to_signal_styles,
|
||
split_text,
|
||
clamp_styles_to_length,
|
||
_convert_tables,
|
||
_process_links,
|
||
_url_text_equivalent,
|
||
_overlaps_any,
|
||
_table_rows_to_bullets,
|
||
_convert_headings,
|
||
_convert_blockquotes,
|
||
_merge_adjacent_styles,
|
||
)
|
||
from yuxi.channels.adapters.signal.security import (
|
||
SignalSecurityPolicy,
|
||
)
|
||
from yuxi.channels.adapters.signal.normalize import (
|
||
normalize_target,
|
||
parse_signal_message,
|
||
parse_signal_reaction,
|
||
parse_signal_receipt,
|
||
is_own_message,
|
||
build_dedup_key,
|
||
check_debounce,
|
||
)
|
||
from yuxi.channels.adapters.signal.setup import (
|
||
SetupStep,
|
||
SetupWizard,
|
||
validate_e164,
|
||
format_e164,
|
||
)
|
||
from yuxi.channels.adapters.signal.session import resolve_thread
|
||
from yuxi.channels.adapters.signal.outbound_session import OutboundSession
|
||
from yuxi.channels.adapters.signal.media_vision import MediaVisionAnalyzer
|
||
from yuxi.channels.adapters.signal.rpc_context import RpcContext
|
||
from yuxi.channels.adapters.signal.token_utils import ensure_oauth_prefix, normalize_token
|
||
from yuxi.channels.adapters.signal.config_ui_hints import SIGNAL_CONFIG_UI_HINTS
|
||
from yuxi.channels.adapters.signal.reaction_level import ReactionLevelController, ReactionLevel
|
||
from yuxi.channels.adapters.signal.entity_cache import EntityCache
|
||
from yuxi.channels.adapters.signal.event_queue import OrderedEventQueue
|
||
from yuxi.channels.adapters.signal.exec_auth import ExecAuthAdapter, ExecAuthResult
|
||
from yuxi.channels.models import (
|
||
ChannelIdentity,
|
||
ChannelResponse,
|
||
ChannelType,
|
||
ChatType,
|
||
DeliveryResult,
|
||
EventType,
|
||
MessageType,
|
||
)
|
||
|
||
|
||
# ─── send.py – SignalSender edge cases ───────────────────────────────────────
|
||
|
||
|
||
class TestMakeMessageId:
|
||
def test_with_timestamp(self):
|
||
msg_id = _make_message_id(1715000000000)
|
||
assert msg_id is not None
|
||
assert msg_id.startswith("1715000000000:")
|
||
assert len(msg_id.split(":")[1]) == 8
|
||
|
||
def test_with_string_timestamp(self):
|
||
msg_id = _make_message_id("1715000000000")
|
||
assert msg_id is not None
|
||
assert msg_id.startswith("1715000000000:")
|
||
|
||
def test_with_none_returns_none(self):
|
||
assert _make_message_id(None) is None
|
||
|
||
def test_with_empty_string(self):
|
||
assert _make_message_id("") is None
|
||
|
||
def test_with_zero(self):
|
||
assert _make_message_id(0) is None
|
||
|
||
|
||
class TestExtractChunkStyles:
|
||
def test_full_overlap(self):
|
||
styles = [StyleRange(start=0, length=10, style="BOLD")]
|
||
result = _extract_chunk_styles(styles, offset=0, chunk_len=10)
|
||
assert len(result) == 1
|
||
assert result[0].start == 0
|
||
assert result[0].length == 10
|
||
|
||
def test_partial_overlap_start(self):
|
||
styles = [StyleRange(start=0, length=20, style="BOLD")]
|
||
result = _extract_chunk_styles(styles, offset=5, chunk_len=10)
|
||
assert len(result) == 1
|
||
assert result[0].start == 0
|
||
assert result[0].length == 10
|
||
|
||
def test_partial_overlap_end(self):
|
||
styles = [StyleRange(start=8, length=20, style="ITALIC")]
|
||
result = _extract_chunk_styles(styles, offset=0, chunk_len=10)
|
||
assert len(result) == 1
|
||
assert result[0].start == 8
|
||
assert result[0].length == 2
|
||
|
||
def test_no_overlap_before(self):
|
||
styles = [StyleRange(start=0, length=3, style="BOLD")]
|
||
result = _extract_chunk_styles(styles, offset=5, chunk_len=10)
|
||
assert len(result) == 0
|
||
|
||
def test_no_overlap_after(self):
|
||
styles = [StyleRange(start=20, length=5, style="BOLD")]
|
||
result = _extract_chunk_styles(styles, offset=0, chunk_len=10)
|
||
assert len(result) == 0
|
||
|
||
def test_empty_styles(self):
|
||
result = _extract_chunk_styles([], offset=0, chunk_len=10)
|
||
assert result == []
|
||
|
||
def test_multiple_styles(self):
|
||
styles = [
|
||
StyleRange(start=0, length=5, style="BOLD"),
|
||
StyleRange(start=5, length=5, style="ITALIC"),
|
||
StyleRange(start=15, length=5, style="MONOSPACE"),
|
||
]
|
||
result = _extract_chunk_styles(styles, offset=0, chunk_len=10)
|
||
assert len(result) == 2
|
||
|
||
|
||
class TestSignalSenderSendText:
|
||
def test_plain_mode_with_formatted(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={"timestamp": 1715000000000})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
|
||
formatted = FormattedText(
|
||
body="**bold**",
|
||
styles=[StyleRange(start=0, length=4, style="BOLD")],
|
||
)
|
||
result = asyncio.run(
|
||
sender.send_text("+8615678123400", "fallback", formatted_body=formatted, text_mode="plain")
|
||
)
|
||
assert result.success is True
|
||
|
||
def test_send_text_with_reply(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={"timestamp": 1715000000000})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
|
||
result = asyncio.run(sender.send_text("+8615678123400", "Hello", reply_to_id="1715000000001"))
|
||
assert result.success is True
|
||
|
||
def test_send_text_with_previews_and_mentions(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={"timestamp": 1715000000000})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
|
||
result = asyncio.run(
|
||
sender.send_text(
|
||
"+8615678123400",
|
||
"Hello @user",
|
||
previews=[{"url": "https://example.com"}],
|
||
mentions=[{"user_id": "uuid-123", "start": 6, "length": 5}],
|
||
)
|
||
)
|
||
assert result.success is True
|
||
|
||
def test_send_text_rpc_error_propagates(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(side_effect=RpcError("RPC failed", code=-32000))
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
|
||
result = asyncio.run(sender.send_text("+8615678123400", "Hello"))
|
||
assert result.success is False
|
||
assert "RPC failed" in result.error
|
||
|
||
def test_send_text_generic_error_returns_failure(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(side_effect=RuntimeError("boom"))
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
|
||
result = asyncio.run(sender.send_text("+8615678123400", "Hello"))
|
||
assert result.success is False
|
||
assert "boom" in result.error
|
||
|
||
def test_send_text_multiple_chunks(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={"timestamp": 1715000000000})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
|
||
long_text = "A" * 5000
|
||
result = asyncio.run(sender.send_text("+8615678123400", long_text))
|
||
assert result.success is True
|
||
|
||
def test_send_text_chunk_failure_returns_error(self):
|
||
rpc = AsyncMock()
|
||
call_count = [0]
|
||
|
||
async def fail_once(*args, **kwargs):
|
||
call_count[0] += 1
|
||
if call_count[0] == 1:
|
||
return {"timestamp": 1715000000000}
|
||
raise RuntimeError("chunk failed")
|
||
|
||
rpc.call = fail_once
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
|
||
long_text = "A" * 5000
|
||
result = asyncio.run(sender.send_text("+8615678123400", long_text))
|
||
assert result.success is False
|
||
|
||
|
||
class TestSignalSenderSendMedia:
|
||
def test_send_image(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={"timestamp": 1715000000000})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
|
||
result = asyncio.run(
|
||
sender.send_media("+8615678123400", b"fake_image", "image", filename="photo.jpg", caption="nice")
|
||
)
|
||
assert result.success is True
|
||
|
||
def test_send_media_unknown_type(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={"timestamp": 1715000000000})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
|
||
result = asyncio.run(sender.send_media("+8615678123400", b"data", "unknown_type"))
|
||
assert result.success is True
|
||
|
||
def test_send_media_no_filename_or_caption(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={"timestamp": 1715000000000})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
|
||
result = asyncio.run(sender.send_media("+8615678123400", b"data", "file"))
|
||
assert result.success is True
|
||
|
||
def test_send_media_failure(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(side_effect=RuntimeError("media fail"))
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
|
||
result = asyncio.run(sender.send_media("+8615678123400", b"data", "image"))
|
||
assert result.success is False
|
||
|
||
|
||
class TestSignalSenderTypingIndicator:
|
||
def test_success(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
|
||
result = asyncio.run(sender.send_typing_indicator("+8615678123400"))
|
||
assert result.success is True
|
||
|
||
def test_failure(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(side_effect=RuntimeError("fail"))
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
|
||
result = asyncio.run(sender.send_typing_indicator("+8615678123400"))
|
||
assert result.success is False
|
||
|
||
|
||
class TestSignalSenderEditMessage:
|
||
def test_success(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={"timestamp": 1715000000000})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
|
||
result = asyncio.run(sender.edit_message("+8615678123400", "+8612345678901", 1715000000000, "edited"))
|
||
assert result.success is True
|
||
|
||
def test_failure(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(side_effect=RuntimeError("fail"))
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
|
||
result = asyncio.run(sender.edit_message("+8615678123400", "+8612345678901", 1715000000000, "edited"))
|
||
assert result.success is False
|
||
|
||
|
||
class TestSignalSenderDeleteMessage:
|
||
def test_success(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
|
||
result = asyncio.run(sender.delete_message("+8615678123400", [1715000000000]))
|
||
assert result.success is True
|
||
|
||
def test_failure(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(side_effect=RuntimeError("fail"))
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
|
||
result = asyncio.run(sender.delete_message("+8615678123400", [1715000000000]))
|
||
assert result.success is False
|
||
|
||
|
||
class TestSignalSenderSticker:
|
||
def test_success(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={"timestamp": 1715000000000})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
|
||
result = asyncio.run(sender.send_sticker("+8615678123400", "pack-1", 42))
|
||
assert result.success is True
|
||
|
||
def test_failure(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(side_effect=RuntimeError("fail"))
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
|
||
result = asyncio.run(sender.send_sticker("+8615678123400", "pack-1", 42))
|
||
assert result.success is False
|
||
|
||
|
||
class TestSignalSenderSilentMessage:
|
||
def test_success(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={"timestamp": 1715000000000})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
|
||
result = asyncio.run(sender.send_silent_message("+8615678123400", "shh"))
|
||
assert result.success is True
|
||
|
||
def test_with_reply(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={"timestamp": 1715000000000})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
|
||
result = asyncio.run(sender.send_silent_message("+8615678123400", "shh", reply_to_id="1715000000001"))
|
||
assert result.success is True
|
||
|
||
|
||
class TestSignalSenderPinUnpin:
|
||
def test_pin_message_success(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
result = asyncio.run(sender.pin_message("group:abc", 1715000000000))
|
||
assert result.success is True
|
||
|
||
def test_unpin_message_success(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
result = asyncio.run(sender.unpin_message("group:abc"))
|
||
assert result.success is True
|
||
|
||
def test_pin_failure(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(side_effect=RuntimeError("fail"))
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
result = asyncio.run(sender.pin_message("group:abc", 1715000000000))
|
||
assert result.success is False
|
||
|
||
def test_unpin_failure(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(side_effect=RuntimeError("fail"))
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
result = asyncio.run(sender.unpin_message("group:abc"))
|
||
assert result.success is False
|
||
|
||
|
||
class TestSignalSenderVoice:
|
||
def test_success(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={"timestamp": 1715000000000})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
result = asyncio.run(sender.send_voice("+8615678123400", b"audio_data", duration_ms=5000))
|
||
assert result.success is True
|
||
|
||
def test_failure(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(side_effect=RuntimeError("fail"))
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
result = asyncio.run(sender.send_voice("+8615678123400", b"audio_data"))
|
||
assert result.success is False
|
||
|
||
|
||
class TestSignalSenderGroupOps:
|
||
def test_create_group_success(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={"groupId": "xyz"})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
result = asyncio.run(sender.create_group("Test Group", ["+8615678123400", "+8619999888877"]))
|
||
assert result.success is True
|
||
assert result.message_id == "xyz"
|
||
|
||
def test_create_group_with_avatar(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={"groupId": "xyz"})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
result = asyncio.run(sender.create_group("Test", ["+8615678123400"], avatar_path="/tmp/av.png"))
|
||
assert result.success is True
|
||
|
||
def test_create_group_failure(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(side_effect=RuntimeError("fail"))
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
result = asyncio.run(sender.create_group("Fail", ["+8615678123400"]))
|
||
assert result.success is False
|
||
|
||
def test_delete_group_adds_prefix(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
result = asyncio.run(sender.delete_group("abc123"))
|
||
assert result.success is True
|
||
call_args = rpc.call.call_args[0]
|
||
assert call_args[1]["groupId"] == "group:abc123"
|
||
|
||
def test_delete_group_already_prefixed(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
result = asyncio.run(sender.delete_group("group:abc123"))
|
||
assert result.success is True
|
||
|
||
def test_delete_group_failure(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(side_effect=RuntimeError("fail"))
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
result = asyncio.run(sender.delete_group("abc"))
|
||
assert result.success is False
|
||
|
||
def test_manage_members_add(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
result = asyncio.run(sender.manage_members("group:abc", add=["+8619999888877"]))
|
||
assert result.success is True
|
||
|
||
def test_manage_members_remove(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
result = asyncio.run(sender.manage_members("group:abc", remove=["+8619999888877"]))
|
||
assert result.success is True
|
||
|
||
def test_manage_members_both(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
result = asyncio.run(sender.manage_members("group:abc", add=["+8618888888888"], remove=["+8617777777777"]))
|
||
assert result.success is True
|
||
|
||
def test_manage_members_adds_prefix(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(return_value={})
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
result = asyncio.run(sender.manage_members("abc123", add=["+8619999888877"]))
|
||
assert result.success is True
|
||
call_args = rpc.call.call_args[0]
|
||
assert call_args[1]["groupId"] == "group:abc123"
|
||
|
||
def test_manage_members_failure(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(side_effect=RuntimeError("fail"))
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
result = asyncio.run(sender.manage_members("group:abc", add=["+8619999888877"]))
|
||
assert result.success is False
|
||
|
||
|
||
class TestSignalSenderCache:
|
||
def test_cache_and_retrieve(self):
|
||
async def _t():
|
||
sender = SignalSender(MagicMock(), "+8612345678901")
|
||
sender._cache_sent("msg-1", "+8615678123400")
|
||
entry = sender._get_cached("msg-1")
|
||
assert entry is not None
|
||
assert entry["recipient"] == "+8615678123400"
|
||
|
||
asyncio.run(_t())
|
||
|
||
def test_cache_nonexistent(self):
|
||
async def _t():
|
||
sender = SignalSender(MagicMock(), "+8612345678901")
|
||
assert sender._get_cached("no-exist") is None
|
||
|
||
asyncio.run(_t())
|
||
|
||
def test_prune_removes_excess(self):
|
||
async def _t():
|
||
sender = SignalSender(MagicMock(), "+8612345678901")
|
||
sender.CACHE_MAX_SIZE = 3
|
||
for i in range(10):
|
||
sender._cache_sent(f"msg-{i}", f"+86100000000{i:02d}")
|
||
assert len(sender._sent_message_cache) <= 3
|
||
|
||
asyncio.run(_t())
|
||
|
||
def test_retry_logic_exponential_backoff(self):
|
||
rpc = AsyncMock()
|
||
call_count = [0]
|
||
|
||
async def fail_twice(*args, **kwargs):
|
||
call_count[0] += 1
|
||
if call_count[0] < 3:
|
||
raise ConnectionError("transient")
|
||
return {"timestamp": 1715000000000}
|
||
|
||
rpc.call = fail_twice
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
result = asyncio.run(sender.send_text("+8615678123400", "Hello"))
|
||
assert result.success is True
|
||
assert call_count[0] == 3
|
||
|
||
def test_retry_exhausted(self):
|
||
rpc = AsyncMock()
|
||
rpc.call = AsyncMock(side_effect=ConnectionError("always fails"))
|
||
sender = SignalSender(rpc, "+8612345678901")
|
||
result = asyncio.run(sender.send_text("+8615678123400", "Hello"))
|
||
assert result.success is False
|
||
assert "always fails" in result.error
|
||
|
||
|
||
# ─── client.py – RpcClient edge cases ─────────────────────────────────────────
|
||
|
||
|
||
class TestRpcClientRetry:
|
||
@patch("aiohttp.ClientSession")
|
||
def test_retry_on_client_error(self, mock_session_cls):
|
||
mock_session = MagicMock()
|
||
mock_session_cls.return_value = mock_session
|
||
mock_resp = AsyncMock()
|
||
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||
mock_resp.__aexit__ = AsyncMock(return_value=None)
|
||
mock_resp.status = 200
|
||
call_count = [0]
|
||
|
||
async def mock_json():
|
||
call_count[0] += 1
|
||
if call_count[0] < 3:
|
||
raise TimeoutError("timeout")
|
||
return {"result": {"timestamp": 1715000000000}}
|
||
|
||
mock_resp.json = mock_json
|
||
mock_session.post = MagicMock(return_value=mock_resp)
|
||
|
||
client = RpcClient("http://127.0.0.1:8080", allow_remote_daemon=True)
|
||
client._session = mock_session
|
||
client._retry_config = {"attempts": 3, "min_delay_ms": 1, "max_delay_ms": 50, "jitter": 0}
|
||
|
||
result = asyncio.run(client.call("send", {"msg": "hello"}))
|
||
assert result["timestamp"] == 1715000000000
|
||
assert call_count[0] == 3
|
||
|
||
def test_call_without_session_raises(self):
|
||
client = RpcClient("http://127.0.0.1:8080", allow_remote_daemon=True)
|
||
with pytest.raises(RuntimeError, match="not connected"):
|
||
asyncio.run(client.call("test", {}))
|
||
|
||
def test_rpc_error_propagates(self):
|
||
client = RpcClient("http://127.0.0.1:8080", allow_remote_daemon=True)
|
||
mock_session = MagicMock()
|
||
mock_resp = AsyncMock()
|
||
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||
mock_resp.__aexit__ = AsyncMock(return_value=None)
|
||
mock_resp.status = 200
|
||
mock_resp.json = AsyncMock(return_value={"error": {"code": -32000, "message": "custom error"}})
|
||
mock_session.post = MagicMock(return_value=mock_resp)
|
||
client._session = mock_session
|
||
|
||
with pytest.raises(RpcError, match="custom error"):
|
||
asyncio.run(client.call("test", {}))
|
||
|
||
def test_unauthorized_http_401(self):
|
||
client = RpcClient("http://127.0.0.1:8080", allow_remote_daemon=True)
|
||
mock_session = MagicMock()
|
||
mock_resp = AsyncMock()
|
||
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||
mock_resp.__aexit__ = AsyncMock(return_value=None)
|
||
mock_resp.status = 401
|
||
mock_session.post = MagicMock(return_value=mock_resp)
|
||
client._session = mock_session
|
||
|
||
with pytest.raises(UnauthorizedError):
|
||
asyncio.run(client.call("test", {}))
|
||
|
||
def test_disconnect(self):
|
||
client = RpcClient("http://127.0.0.1:8080", allow_remote_daemon=True)
|
||
mock_session = AsyncMock()
|
||
client._session = mock_session
|
||
|
||
asyncio.run(client.disconnect())
|
||
mock_session.close.assert_called_once()
|
||
assert client._session is None
|
||
|
||
def test_disconnect_no_session(self):
|
||
client = RpcClient("http://127.0.0.1:8080", allow_remote_daemon=True)
|
||
asyncio.run(client.disconnect())
|
||
|
||
|
||
class TestDetectRateLimit:
|
||
def test_detected(self):
|
||
err = _detect_rate_limit("Rate limit exceeded", {"retry_after_seconds": 60})
|
||
assert err is not None
|
||
assert err.retry_after_seconds == 60
|
||
assert isinstance(err, RateLimitError)
|
||
|
||
def test_detected_lowercase(self):
|
||
err = _detect_rate_limit("ratelimitexception: too many", {})
|
||
assert err is not None
|
||
assert isinstance(err, RateLimitError)
|
||
|
||
def test_not_detected(self):
|
||
assert _detect_rate_limit("unknown error", {}) is None
|
||
|
||
def test_not_detected_empty(self):
|
||
assert _detect_rate_limit("Something else", {}) is None
|
||
|
||
|
||
class TestValidateDaemonUrl:
|
||
def test_ipv6_loopback(self):
|
||
_validate_daemon_url("http://[::1]:8080")
|
||
|
||
def test_ipv6_private_rejected(self):
|
||
with pytest.raises(ValueError, match="SSRF guard"):
|
||
_validate_daemon_url("http://[2001:db8::1]:8080")
|
||
|
||
def test_empty_hostname_raises(self):
|
||
with pytest.raises(ValueError, match="cannot determine host"):
|
||
_validate_daemon_url("http://")
|
||
|
||
def test_non_resolvable_hostname_in_loopback(self):
|
||
with pytest.raises(ValueError, match="SSRF guard"):
|
||
_validate_daemon_url("http://example.com:8080")
|
||
|
||
|
||
class TestRpcErrorClasses:
|
||
def test_rpc_error_code(self):
|
||
err = RpcError("test", code=-32000)
|
||
assert err.code == -32000
|
||
assert str(err) == "test"
|
||
|
||
def test_rate_limit_error(self):
|
||
err = RateLimitError("rate", retry_after_seconds=30, token="abc")
|
||
assert err.code == -32603
|
||
assert err.retry_after_seconds == 30
|
||
assert err.token == "abc"
|
||
|
||
def test_rate_limit_without_optional(self):
|
||
err = RateLimitError("rate")
|
||
assert err.retry_after_seconds is None
|
||
assert err.token is None
|
||
|
||
|
||
# ─── setup.py – SetupWizard and E.164 ────────────────────────────────────────
|
||
|
||
|
||
class TestSetupWizard:
|
||
def test_default_step_is_status(self):
|
||
wizard = SetupWizard()
|
||
assert wizard.current_step == "status"
|
||
|
||
def test_advance_steps(self):
|
||
wizard = SetupWizard()
|
||
wizard.advance()
|
||
assert wizard.current_step == "prepare"
|
||
|
||
wizard.advance()
|
||
assert wizard.current_step == "cli_path"
|
||
|
||
def test_full_advance_loop(self):
|
||
wizard = SetupWizard()
|
||
steps = list(SetupStep)
|
||
for _ in range(len(steps) - 1):
|
||
wizard.advance()
|
||
assert wizard.current_step == steps[-1].value
|
||
|
||
def test_get_step_result_status(self):
|
||
wizard = SetupWizard()
|
||
result = wizard.get_step_result()
|
||
assert result["step"] == "status"
|
||
assert "total_steps" in result
|
||
|
||
def test_get_step_result_completion(self):
|
||
wizard = SetupWizard()
|
||
for _ in range(len(SetupStep) - 1):
|
||
wizard.advance()
|
||
result = wizard.get_step_result()
|
||
assert result["step"] == "completion"
|
||
assert "next_steps" in result
|
||
|
||
def test_prepare_step_with_config(self):
|
||
wizard = SetupWizard({"cli_path": "signal-cli"})
|
||
wizard.advance()
|
||
result = wizard.get_step_result()
|
||
assert result["step"] == "prepare"
|
||
assert "fields" in result
|
||
|
||
def test_signal_number_step(self):
|
||
wizard = SetupWizard({"signal_number": "+8613800138000"})
|
||
wizard.advance()
|
||
wizard.advance()
|
||
wizard.advance()
|
||
result = wizard.get_step_result()
|
||
assert result["step"] == "signal_number"
|
||
|
||
def test_allow_from_step(self):
|
||
wizard = SetupWizard({"security": {"allow_from": ["+8619847654321", "+8615678123400"]}})
|
||
wizard.advance()
|
||
wizard.advance()
|
||
wizard.advance()
|
||
wizard.advance()
|
||
result = wizard.get_step_result()
|
||
assert result["step"] == "allow_from"
|
||
|
||
|
||
class TestValidateE164:
|
||
def test_valid_various(self):
|
||
assert validate_e164("+1234567890") is True
|
||
assert validate_e164("+8613800138000") is True
|
||
assert validate_e164("+15551234567") is True
|
||
assert validate_e164("+12345") is True
|
||
|
||
def test_invalid_short(self):
|
||
assert validate_e164("+1234") is False
|
||
|
||
def test_invalid_long(self):
|
||
assert validate_e164("+1234567890123456") is False
|
||
|
||
def test_invalid_no_plus(self):
|
||
assert validate_e164("1234567890") is False
|
||
|
||
def test_invalid_letters(self):
|
||
assert validate_e164("+1234abc") is False
|
||
|
||
def test_invalid_empty(self):
|
||
assert validate_e164("") is False
|
||
|
||
|
||
class TestFormatE164:
|
||
def test_already_valid(self):
|
||
assert format_e164("+8613800138000") == "+8613800138000"
|
||
|
||
def test_strips_non_digits(self):
|
||
assert format_e164("+86 138-0013-8000") == "+8613800138000"
|
||
|
||
def test_adds_plus(self):
|
||
assert format_e164("8613800138000") == "+8613800138000"
|
||
|
||
def test_invalid_raises(self):
|
||
with pytest.raises(ValueError, match="Invalid E.164"):
|
||
format_e164("123")
|
||
|
||
|
||
# ─── normalize.py – additional parsing coverage ──────────────────────────────
|
||
|
||
|
||
class TestParseSignalReactionDetail:
|
||
def test_reaction_add(self):
|
||
raw = {
|
||
"envelope": {"source": "+1234"},
|
||
"reaction": {
|
||
"targetAuthor": "+5678",
|
||
"targetSentTimestamp": 1715000000000,
|
||
"emoji": "❤️",
|
||
},
|
||
}
|
||
msg = parse_signal_reaction(raw)
|
||
assert msg is not None
|
||
assert msg.event_type == EventType.MESSAGE_UPDATED
|
||
assert "❤️" in msg.content
|
||
assert msg.metadata["reaction_emoji"] == "❤️"
|
||
assert msg.metadata["reaction_removed"] is False
|
||
|
||
def test_reaction_remove(self):
|
||
raw = {
|
||
"envelope": {"source": "+1234"},
|
||
"reaction": {
|
||
"targetAuthor": "+5678",
|
||
"targetSentTimestamp": 1715000000000,
|
||
"emoji": "👍",
|
||
"remove": True,
|
||
},
|
||
}
|
||
msg = parse_signal_reaction(raw)
|
||
assert msg is not None
|
||
assert "reaction_removed" in msg.content
|
||
assert msg.metadata["reaction_removed"] is True
|
||
|
||
def test_reaction_in_group(self):
|
||
raw = {
|
||
"envelope": {"source": "+1234"},
|
||
"reaction": {
|
||
"targetAuthor": "+5678",
|
||
"targetSentTimestamp": 1715000000000,
|
||
"emoji": "🔥",
|
||
"groupInfo": {"groupId": "group-abc"},
|
||
},
|
||
}
|
||
msg = parse_signal_reaction(raw)
|
||
assert msg is not None
|
||
assert msg.chat_type == ChatType.GROUP
|
||
assert msg.identity.channel_chat_id == "group:group-abc"
|
||
|
||
def test_no_reaction_returns_none(self):
|
||
raw = {"envelope": {"source": "+1234"}}
|
||
msg = parse_signal_reaction(raw)
|
||
assert msg is None
|
||
|
||
|
||
class TestParseSignalReceiptDetail:
|
||
def test_read_receipt(self):
|
||
raw = {
|
||
"envelope": {"source": "+1234"},
|
||
"receiptMessage": {
|
||
"type": "READ",
|
||
"timestamps": [1715000000000, 1715000000001],
|
||
},
|
||
}
|
||
msg = parse_signal_receipt(raw)
|
||
assert msg is not None
|
||
assert msg.event_type == EventType.READ_RECEIPT
|
||
assert len(msg.metadata["receipt_timestamps"]) == 2
|
||
|
||
def test_delivery_receipt(self):
|
||
raw = {
|
||
"envelope": {"source": "+1234"},
|
||
"receiptMessage": {"type": "DELIVERED", "timestamps": [1715000000000]},
|
||
}
|
||
msg = parse_signal_receipt(raw)
|
||
assert msg is not None
|
||
|
||
def test_no_receipt_returns_none(self):
|
||
raw = {"envelope": {"source": "+1234"}}
|
||
msg = parse_signal_receipt(raw)
|
||
assert msg is None
|
||
|
||
|
||
class TestCheckDebounce:
|
||
def test_first_call_no_debounce(self):
|
||
assert check_debounce("conv-1", interval_ms=1000) is False
|
||
|
||
def test_second_call_within_interval_debounces(self):
|
||
assert check_debounce("conv-debounce", interval_ms=1000) is False
|
||
assert check_debounce("conv-debounce", interval_ms=1000) is True
|
||
|
||
def test_different_conversations_independent(self):
|
||
assert check_debounce("conv-a", interval_ms=1000) is False
|
||
assert check_debounce("conv-b", interval_ms=1000) is False
|
||
|
||
def test_zero_interval_no_debounce(self):
|
||
assert check_debounce("conv-c", interval_ms=0) is False
|
||
assert check_debounce("conv-c", interval_ms=0) is False
|
||
|
||
|
||
class TestParseSignalMessageForwardedContactLocation:
|
||
def test_forwarded_message(self):
|
||
raw = {
|
||
"envelope": {"source": "+1234"},
|
||
"dataMessage": {
|
||
"timestamp": 1715000000000,
|
||
"message": "FWD",
|
||
"forwarded": True,
|
||
},
|
||
}
|
||
msg = parse_signal_message(raw)
|
||
assert msg is not None
|
||
assert msg.metadata.get("forwarded") is True
|
||
|
||
def test_contact_share(self):
|
||
raw = {
|
||
"envelope": {"source": "+1234"},
|
||
"dataMessage": {
|
||
"timestamp": 1715000000000,
|
||
"message": "",
|
||
"contacts": [{"name": "Alice", "number": "+99998888777"}],
|
||
},
|
||
}
|
||
msg = parse_signal_message(raw)
|
||
assert msg is not None
|
||
assert "contacts" in msg.metadata
|
||
|
||
def test_location_share(self):
|
||
raw = {
|
||
"envelope": {"source": "+1234"},
|
||
"dataMessage": {
|
||
"timestamp": 1715000000000,
|
||
"message": "",
|
||
"location": {"latitude": 39.9, "longitude": 116.4, "label": "Beijing"},
|
||
},
|
||
}
|
||
msg = parse_signal_message(raw)
|
||
assert msg is not None
|
||
assert msg.message_type == MessageType.LOCATION
|
||
assert msg.metadata["location"]["latitude"] == 39.9
|
||
|
||
def test_sticker_message_type(self):
|
||
raw = {
|
||
"envelope": {"source": "+1234"},
|
||
"dataMessage": {
|
||
"timestamp": 1715000000000,
|
||
"sticker": {"packId": "pack-1", "stickerId": 42},
|
||
},
|
||
}
|
||
msg = parse_signal_message(raw)
|
||
assert msg is not None
|
||
assert msg.message_type == MessageType.STICKER
|
||
|
||
def test_message_with_text_and_attachment(self):
|
||
raw = {
|
||
"envelope": {"source": "+1234"},
|
||
"dataMessage": {
|
||
"timestamp": 1715000000000,
|
||
"message": "Check this photo",
|
||
"attachments": [{"contentType": "image/png", "id": "att-1", "size": 1024}],
|
||
},
|
||
}
|
||
msg = parse_signal_message(raw)
|
||
assert msg is not None
|
||
assert msg.content == "Check this photo"
|
||
assert msg.message_type == MessageType.IMAGE
|
||
assert len(msg.attachments) == 1
|
||
assert msg.attachments[0].file_id == "att-1"
|
||
|
||
def test_mentions_by_startswith(self):
|
||
raw = {
|
||
"envelope": {"source": "+1234"},
|
||
"dataMessage": {
|
||
"timestamp": 1715000000000,
|
||
"message": "@Alice hello",
|
||
"bodyRanges": [{"startsWith": True, "start": 0, "length": 6}],
|
||
},
|
||
}
|
||
msg = parse_signal_message(raw)
|
||
assert msg is not None
|
||
assert msg.mentions is not None
|
||
assert "@Alice" in msg.mentions.mentioned_user_ids
|
||
|
||
|
||
# ─── format.py – complete coverage ───────────────────────────────────────────
|
||
|
||
|
||
class TestUrlTextEquivalent:
|
||
def test_exact_match(self):
|
||
assert _url_text_equivalent("https://example.com", "https://example.com") is True
|
||
|
||
def test_trailing_slash(self):
|
||
assert _url_text_equivalent("https://example.com/", "https://example.com") is True
|
||
|
||
def test_label_matches_url_without_scheme(self):
|
||
assert _url_text_equivalent("example.com", "https://example.com") is True
|
||
assert _url_text_equivalent("example.com", "http://example.com") is True
|
||
|
||
def test_no_match(self):
|
||
assert _url_text_equivalent("click here", "https://example.com") is False
|
||
|
||
def test_case_insensitive(self):
|
||
assert _url_text_equivalent("Example.Com", "https://example.com") is True
|
||
|
||
|
||
class TestOverlapsAny:
|
||
def test_overlaps(self):
|
||
assert _overlaps_any(5, 10, [(0, 8)]) is True
|
||
assert _overlaps_any(5, 10, [(8, 15)]) is True
|
||
assert _overlaps_any(5, 10, [(5, 10)]) is True
|
||
|
||
def test_no_overlap(self):
|
||
assert _overlaps_any(5, 10, [(0, 4)]) is False
|
||
assert _overlaps_any(5, 10, [(10, 15)]) is False
|
||
|
||
def test_empty_ranges(self):
|
||
assert _overlaps_any(5, 10, []) is False
|
||
|
||
|
||
class TestTableRowsToBullets:
|
||
def test_empty_returns_empty(self):
|
||
assert _table_rows_to_bullets([]) == []
|
||
|
||
def test_header_plus_rows(self):
|
||
rows = [["Col1", "Col2"], ["A", "B"]]
|
||
result = _table_rows_to_bullets(rows)
|
||
assert len(result) >= 1
|
||
|
||
def test_single_row(self):
|
||
rows = [["A", "B"]]
|
||
result = _table_rows_to_bullets(rows)
|
||
assert len(result) > 0
|
||
|
||
|
||
class TestConvertTables:
|
||
def test_passthrough_mode(self):
|
||
text = "| A | B |\n| 1 | 2 |"
|
||
result = _convert_tables(text, table_mode="passthrough")
|
||
assert result == text
|
||
|
||
def test_no_table(self):
|
||
text = "Plain text"
|
||
result = _convert_tables(text, table_mode="bullets")
|
||
assert result == text
|
||
|
||
|
||
class TestProcessLinks:
|
||
def test_simple_link(self):
|
||
text = "[click](https://example.com)"
|
||
result, ranges = _process_links(text)
|
||
assert "click" in result
|
||
assert "https://example.com" in result
|
||
|
||
def test_auto_link(self):
|
||
text = "[https://example.com](https://example.com)"
|
||
result, ranges = _process_links(text)
|
||
assert result == "https://example.com"
|
||
|
||
|
||
class TestConvertHeadings:
|
||
def test_h1_bold(self):
|
||
result = _convert_headings("# Title", "bold")
|
||
assert "Title" in result
|
||
assert "**" in result
|
||
|
||
def test_plain_style(self):
|
||
result = _convert_headings("# Title", "plain")
|
||
assert result == "# Title"
|
||
|
||
def test_h3(self):
|
||
result = _convert_headings("### Subtitle", "bold")
|
||
assert "Subtitle" in result
|
||
|
||
def test_no_heading(self):
|
||
result = _convert_headings("plain text", "bold")
|
||
assert result == "plain text"
|
||
|
||
|
||
class TestConvertBlockquotes:
|
||
def test_single_blockquote(self):
|
||
result = _convert_blockquotes("> quoted", "> ")
|
||
assert result == "> quoted"
|
||
|
||
def test_custom_prefix(self):
|
||
result = _convert_blockquotes("> text", "| ")
|
||
assert result == "| text"
|
||
|
||
def test_no_blockquote(self):
|
||
result = _convert_blockquotes("plain", "> ")
|
||
assert result == "plain"
|
||
|
||
|
||
class TestMergeAdjacentStyles:
|
||
def test_adjacent_same_style(self):
|
||
styles = [
|
||
StyleRange(start=0, length=5, style="BOLD"),
|
||
StyleRange(start=5, length=5, style="BOLD"),
|
||
]
|
||
result = _merge_adjacent_styles(styles)
|
||
assert len(result) == 1
|
||
assert result[0].length == 10
|
||
|
||
def test_different_styles(self):
|
||
styles = [
|
||
StyleRange(start=0, length=5, style="BOLD"),
|
||
StyleRange(start=5, length=5, style="ITALIC"),
|
||
]
|
||
result = _merge_adjacent_styles(styles)
|
||
assert len(result) == 2
|
||
|
||
def test_empty_list(self):
|
||
assert _merge_adjacent_styles([]) == []
|
||
|
||
|
||
class TestClampStyles:
|
||
def test_style_within_bounds(self):
|
||
styles = [StyleRange(start=0, length=5, style="BOLD")]
|
||
result = clamp_styles_to_length(styles, "Hello")
|
||
assert len(result) == 1
|
||
|
||
def test_style_exceeds_body(self):
|
||
styles = [StyleRange(start=2, length=10, style="BOLD")]
|
||
result = clamp_styles_to_length(styles, "Hi")
|
||
assert len(result) == 0
|
||
|
||
def test_style_starts_past_body(self):
|
||
styles = [StyleRange(start=10, length=5, style="BOLD")]
|
||
result = clamp_styles_to_length(styles, "short")
|
||
assert len(result) == 0
|
||
|
||
def test_style_at_body_boundary(self):
|
||
styles = [StyleRange(start=3, length=2, style="BOLD")]
|
||
result = clamp_styles_to_length(styles, "Hello")
|
||
assert len(result) == 1
|
||
assert result[0].length == 2
|
||
|
||
|
||
class TestFullMarkdownConversion:
|
||
def test_colon_emoji_preserved(self):
|
||
result = markdown_to_signal_styles("Hello :smile: world")
|
||
assert result is not None
|
||
assert ":smile:" in result.body
|
||
|
||
def test_mixed_formatting_edge(self):
|
||
result = markdown_to_signal_styles("**a*b**c*")
|
||
assert result is not None
|
||
|
||
def test_list_items(self):
|
||
result = markdown_to_signal_styles("- item1\n- item2")
|
||
assert result is not None
|
||
assert "item1" in result.body
|
||
|
||
|
||
# ─── security.py – additional scenarios ──────────────────────────────────────
|
||
|
||
|
||
class TestSecurityExpandAllowlist:
|
||
def test_strips_signal_prefix(self):
|
||
result = SignalSecurityPolicy._expand_allowlist(["signal:+1234"])
|
||
assert "+1234" in result
|
||
|
||
def test_mixed_entries(self):
|
||
result = SignalSecurityPolicy._expand_allowlist(["signal:+1234", "uuid:abc", "+5678"])
|
||
assert "+1234" in result
|
||
assert "uuid:abc" in result
|
||
assert "+5678" in result
|
||
|
||
def test_empty(self):
|
||
assert SignalSecurityPolicy._expand_allowlist([]) == []
|
||
|
||
|
||
class TestSecurityWildcard:
|
||
def test_wildcard_match_true(self):
|
||
policy = SignalSecurityPolicy(allow_from=["*"])
|
||
assert policy._is_wildcard_match("+8610847654321") is True
|
||
|
||
def test_wildcard_match_false(self):
|
||
policy = SignalSecurityPolicy(allow_from=["+8610847654321"])
|
||
assert policy._is_wildcard_match("+8615678123400") is False
|
||
|
||
def test_wildcard_dm_always_allowed(self):
|
||
policy = SignalSecurityPolicy(dm_policy="allowlist", allow_from=["*"])
|
||
msg = MagicMock()
|
||
msg.identity.channel_user_id = "+8619999888877"
|
||
assert policy.check_dm_permission(msg) is True
|
||
|
||
|
||
class TestSecurityPairingChallenge:
|
||
def test_record_and_check(self):
|
||
policy = SignalSecurityPolicy()
|
||
policy.record_pairing_challenge("+8610847654321")
|
||
assert policy.has_pairing_challenge("+8610847654321") is True
|
||
assert policy.has_pairing_challenge("+8615678123400") is False
|
||
|
||
def test_reject_removes_pending(self):
|
||
policy = SignalSecurityPolicy(dm_policy="pairing")
|
||
msg = MagicMock()
|
||
msg.identity.channel_user_id = "+8610847654321"
|
||
policy.check_dm_permission(msg)
|
||
assert "+8610847654321" in policy.pending_pairings
|
||
policy.reject_pairing("+8610847654321")
|
||
assert "+8610847654321" not in policy.pending_pairings
|
||
|
||
|
||
class TestSecurityCommandDoubleAuth:
|
||
def test_wildcard_passes(self):
|
||
policy = SignalSecurityPolicy(command_double_auth=True, allow_from=["*"])
|
||
msg = MagicMock()
|
||
msg.identity.channel_user_id = "+8619999888877"
|
||
msg.identity.channel_chat_id = "group:unknown"
|
||
assert policy.check_command_double_auth(msg) is True
|
||
|
||
def test_not_enabled_passes(self):
|
||
policy = SignalSecurityPolicy(command_double_auth=False)
|
||
msg = MagicMock()
|
||
msg.identity.channel_user_id = "+8619999888877"
|
||
msg.identity.channel_chat_id = "group:unknown"
|
||
assert policy.check_command_double_auth(msg) is True
|
||
|
||
def test_neither_allowed_denies(self):
|
||
policy = SignalSecurityPolicy(command_double_auth=True)
|
||
msg = MagicMock()
|
||
msg.identity.channel_user_id = "+8619999888877"
|
||
msg.identity.channel_chat_id = "group:unknown"
|
||
assert policy.check_command_double_auth(msg) is False
|
||
|
||
|
||
class TestSecuritySetStoreHandlers:
|
||
async def test_set_and_approve_with_store(self):
|
||
policy = SignalSecurityPolicy(dm_policy="pairing")
|
||
write_fn = AsyncMock()
|
||
read_fn = AsyncMock()
|
||
policy.set_store_handlers(write_fn, read_fn)
|
||
await policy.approve_pairing("+8610847654321")
|
||
write_fn.assert_called_once()
|
||
|
||
async def test_load_pairing_store_without_handler(self):
|
||
policy = SignalSecurityPolicy()
|
||
await policy.load_pairing_store()
|
||
|
||
async def test_load_pairing_store_with_data(self):
|
||
policy = SignalSecurityPolicy()
|
||
read_fn = AsyncMock(
|
||
return_value={
|
||
"k1": {"user_id": "+8610847654321"},
|
||
"k2": {"user_id": "+8615678123400"},
|
||
}
|
||
)
|
||
policy.set_store_handlers(AsyncMock(), read_fn)
|
||
await policy.load_pairing_store()
|
||
assert "+8610847654321" in policy._dm_allowlist
|
||
assert "+8615678123400" in policy._dm_allowlist
|
||
|
||
async def test_load_pairing_store_handles_error(self):
|
||
policy = SignalSecurityPolicy()
|
||
read_fn = AsyncMock(side_effect=RuntimeError("fail"))
|
||
policy.set_store_handlers(AsyncMock(), read_fn)
|
||
await policy.load_pairing_store()
|
||
|
||
|
||
# ─── session.py / outbound_session.py / rpc_context.py / token_utils.py ─────
|
||
|
||
|
||
class TestResolveThread:
|
||
def test_direct_message(self):
|
||
identity = ChannelIdentity(
|
||
channel_id="signal",
|
||
channel_type=ChannelType.SIGNAL,
|
||
channel_user_id="+8613800138000",
|
||
channel_chat_id="+8613800138000",
|
||
)
|
||
thread = resolve_thread(identity, "main")
|
||
assert thread.startswith("agent:main:signal:dm:")
|
||
assert "8613800138000" in thread
|
||
|
||
def test_group_message(self):
|
||
identity = ChannelIdentity(
|
||
channel_id="signal",
|
||
channel_type=ChannelType.SIGNAL,
|
||
channel_user_id="+8613800138000",
|
||
channel_chat_id="group:abc123",
|
||
)
|
||
thread = resolve_thread(identity, "main")
|
||
assert thread == "agent:main:signal:group:abc123"
|
||
|
||
def test_wrong_channel_type_raises(self):
|
||
identity = ChannelIdentity(
|
||
channel_id="discord",
|
||
channel_type=ChannelType.DISCORD,
|
||
channel_user_id="+8613800138000",
|
||
channel_chat_id="+8613800138000",
|
||
)
|
||
with pytest.raises(ValueError, match="Unexpected channel type"):
|
||
resolve_thread(identity)
|
||
|
||
|
||
class TestOutboundSession:
|
||
def test_resolve_group(self):
|
||
session = OutboundSession.resolve("group:abc123", "+8610847654321")
|
||
assert session.is_group is True
|
||
assert session.from_ == "+8610847654321"
|
||
assert session.to == "group:abc123"
|
||
|
||
def test_resolve_uuid(self):
|
||
session = OutboundSession.resolve("uuid:abcd-1234", "+8610847654321")
|
||
assert session.is_group is False
|
||
assert session.chat_type == ChatType.DIRECT
|
||
|
||
def test_resolve_direct(self):
|
||
session = OutboundSession.resolve("+8615678123400", "+8610847654321")
|
||
assert session.is_group is False
|
||
assert session.chat_type == ChatType.DIRECT
|
||
assert session.conversation_id == "+8615678123400"
|
||
|
||
|
||
class TestRpcContext:
|
||
@patch("yuxi.channels.adapters.signal.rpc_context.RpcClient")
|
||
def test_resolve(self, mock_rpc_client_cls):
|
||
mock_client = AsyncMock()
|
||
mock_client.connect = AsyncMock()
|
||
mock_rpc_client_cls.return_value = mock_client
|
||
|
||
async def _test():
|
||
ctx = await RpcContext.resolve("http://127.0.0.1:8080", "+8610847654321")
|
||
assert ctx.account == "+8610847654321"
|
||
assert ctx.base_url == "http://127.0.0.1:8080"
|
||
|
||
asyncio.run(_test())
|
||
|
||
@patch("yuxi.channels.adapters.signal.rpc_context.RpcClient")
|
||
def test_context_manager(self, mock_rpc_client_cls):
|
||
mock_client = AsyncMock()
|
||
mock_client.connect = AsyncMock()
|
||
mock_client.disconnect = AsyncMock()
|
||
mock_rpc_client_cls.return_value = mock_client
|
||
|
||
async def _test():
|
||
async with await RpcContext.resolve("http://127.0.0.1:8080", "+8610847654321") as ctx:
|
||
assert ctx.account == "+8610847654321"
|
||
mock_client.disconnect.assert_called_once()
|
||
|
||
asyncio.run(_test())
|
||
|
||
|
||
class TestTokenUtils:
|
||
def test_already_prefixed(self):
|
||
assert ensure_oauth_prefix("oauth:token123") == "oauth:token123"
|
||
|
||
def test_not_prefixed(self):
|
||
assert ensure_oauth_prefix("token123") == "oauth:token123"
|
||
|
||
def test_with_whitespace(self):
|
||
assert ensure_oauth_prefix(" oauth:token ") == "oauth:token"
|
||
|
||
def test_normalize_token_calls_ensure(self):
|
||
assert normalize_token("mytoken") == "oauth:mytoken"
|
||
|
||
|
||
# ─── config_ui_hints.py ──────────────────────────────────────────────────────
|
||
|
||
|
||
class TestConfigUiHints:
|
||
def test_all_core_keys_exist(self):
|
||
assert "enabled" in SIGNAL_CONFIG_UI_HINTS
|
||
assert "signal_number" in SIGNAL_CONFIG_UI_HINTS
|
||
assert "accounts" in SIGNAL_CONFIG_UI_HINTS
|
||
assert "cli_path" in SIGNAL_CONFIG_UI_HINTS
|
||
assert "block_streaming" in SIGNAL_CONFIG_UI_HINTS
|
||
assert "reaction_level" in SIGNAL_CONFIG_UI_HINTS
|
||
assert "config_writes" in SIGNAL_CONFIG_UI_HINTS
|
||
assert "media_max_mb" in SIGNAL_CONFIG_UI_HINTS
|
||
assert "allow_remote_daemon" in SIGNAL_CONFIG_UI_HINTS
|
||
assert "ai_vision" in SIGNAL_CONFIG_UI_HINTS
|
||
assert "ingest" in SIGNAL_CONFIG_UI_HINTS
|
||
assert "main_dm_owner_pin" in SIGNAL_CONFIG_UI_HINTS
|
||
|
||
def test_all_entries_have_label_and_group(self):
|
||
for key, entry in SIGNAL_CONFIG_UI_HINTS.items():
|
||
assert "label" in entry, f"Missing label in {key}"
|
||
assert "group" in entry, f"Missing group in {key}"
|
||
|
||
|
||
# ─── reaction_level.py ───────────────────────────────────────────────────────
|
||
|
||
|
||
class TestReactionLevelEnum:
|
||
def test_values(self):
|
||
assert ReactionLevel.OFF == "off"
|
||
assert ReactionLevel.ACK == "ack"
|
||
assert ReactionLevel.MINIMAL == "minimal"
|
||
assert ReactionLevel.EXTENSIVE == "extensive"
|
||
|
||
|
||
class TestReactionLevelControllerExtended:
|
||
def test_extensive_allows_all(self):
|
||
ctrl = ReactionLevelController("extensive")
|
||
assert ctrl.should_send_reaction("c1", "🎉") is True
|
||
|
||
def test_off_denies_all(self):
|
||
ctrl = ReactionLevelController("off")
|
||
assert ctrl.should_send_reaction("c1") is False
|
||
|
||
def test_minimal_always_allows(self):
|
||
ctrl = ReactionLevelController("minimal")
|
||
assert ctrl.should_send_reaction("c1") is True
|
||
assert ctrl.should_send_reaction("c1") is True
|
||
|
||
def test_ack_only_for_eyes(self):
|
||
ctrl = ReactionLevelController("ack")
|
||
assert ctrl.should_send_reaction("c1") is False
|
||
assert ctrl.should_send_reaction("c1", "\U0001f440") is True
|
||
|
||
def test_should_send_auto_ack_once(self):
|
||
ctrl = ReactionLevelController("ack")
|
||
assert ctrl.should_send_auto_ack("c1") is True
|
||
assert ctrl.should_send_auto_ack("c1") is False
|
||
|
||
def test_should_send_auto_ack_off_level(self):
|
||
ctrl = ReactionLevelController("off")
|
||
assert ctrl.should_send_auto_ack("c1") is False
|
||
|
||
def test_reset_ack(self):
|
||
ctrl = ReactionLevelController("ack")
|
||
ctrl.should_send_auto_ack("c1")
|
||
assert ctrl.should_send_auto_ack("c1") is False
|
||
ctrl.reset_ack("c1")
|
||
assert ctrl.should_send_auto_ack("c1") is True
|
||
|
||
|
||
# ─── channel.py – SignalChannel additional coverage ─────────────────────────
|
||
|
||
|
||
class TestSignalChannelWarnOnce:
|
||
def test_first_call_logs(self):
|
||
with patch("logging.Logger.warning") as mock_warn:
|
||
channel = SignalChannel()
|
||
channel._warn_once("key1", "test message")
|
||
mock_warn.assert_called_once()
|
||
|
||
def test_second_call_suppressed(self):
|
||
with patch("logging.Logger.warning") as mock_warn:
|
||
channel = SignalChannel()
|
||
channel._warn_once("key1", "msg1")
|
||
channel._warn_once("key1", "msg1")
|
||
assert mock_warn.call_count == 1
|
||
|
||
def test_different_keys_both_logged(self):
|
||
with patch("logging.Logger.warning") as mock_warn:
|
||
channel = SignalChannel()
|
||
channel._warn_once("k1", "m1")
|
||
channel._warn_once("k2", "m2")
|
||
assert mock_warn.call_count == 2
|
||
|
||
|
||
class TestSignalChannelValidateConfig:
|
||
def test_empty_config_passes(self):
|
||
channel = SignalChannel({"enabled": False})
|
||
channel._validate_config()
|
||
|
||
def test_missing_signal_number_in_account(self):
|
||
channel = SignalChannel(
|
||
{
|
||
"accounts": {"acc1": {"enabled": True}},
|
||
"enabled": False,
|
||
}
|
||
)
|
||
with pytest.raises(ValueError, match="signal_number is required"):
|
||
channel._validate_config()
|
||
|
||
|
||
class TestSignalChannelSendTyping:
|
||
async def test_success(self):
|
||
channel = SignalChannel()
|
||
channel._sender = AsyncMock()
|
||
channel._sender.send_typing_indicator = AsyncMock(return_value=DeliveryResult(success=True))
|
||
result = await channel.send_chat_action("+8610847654321")
|
||
assert result.success is True
|
||
|
||
async def test_no_sender(self):
|
||
channel = SignalChannel()
|
||
result = await channel.send_chat_action("+8610847654321")
|
||
assert result.success is False
|
||
|
||
|
||
class TestSignalChannelDeleteMessage:
|
||
async def test_success(self):
|
||
channel = SignalChannel()
|
||
channel._sender = AsyncMock()
|
||
channel._sender.delete_message = AsyncMock(return_value=DeliveryResult(success=True))
|
||
result = await channel.delete_message("+8610847654321", "123")
|
||
assert result.success is True
|
||
|
||
async def test_no_sender(self):
|
||
channel = SignalChannel()
|
||
result = await channel.delete_message("+8610847654321", "123")
|
||
assert result.success is False
|
||
|
||
|
||
class TestSignalChannelSend:
|
||
async def test_send_with_rpc_error_handling(self):
|
||
channel = SignalChannel()
|
||
channel._sender = MagicMock()
|
||
|
||
async def send_error(*args, **kwargs):
|
||
raise RpcError("RPC crash", code=-32000)
|
||
|
||
channel._sender.send_text = send_error
|
||
channel._circuit_breaker.state = "closed"
|
||
|
||
|
||
response = ChannelResponse(
|
||
identity=ChannelIdentity(
|
||
channel_id="signal",
|
||
channel_type=ChannelType.SIGNAL,
|
||
channel_user_id="+8610847654321",
|
||
channel_chat_id="+8610847654321",
|
||
),
|
||
content="hello",
|
||
)
|
||
result = await channel.send(response)
|
||
assert result.success is False
|
||
|
||
async def test_send_disabled_channel(self):
|
||
channel = SignalChannel({"enabled": False})
|
||
|
||
response = ChannelResponse(
|
||
identity=ChannelIdentity(
|
||
channel_id="signal",
|
||
channel_type=ChannelType.SIGNAL,
|
||
channel_user_id="+8610847654321",
|
||
channel_chat_id="+8610847654321",
|
||
),
|
||
content="hello",
|
||
)
|
||
result = await channel.send(response)
|
||
assert result.success is False
|
||
|
||
|
||
class TestSignalChannelHealthCheck:
|
||
async def test_on_connected_channel(self):
|
||
channel = SignalChannel()
|
||
channel._rpc_client = AsyncMock()
|
||
channel._rpc_client.call = AsyncMock(return_value={"version": "1.0"})
|
||
channel._sender = MagicMock()
|
||
channel._account_number = "+8610847654321"
|
||
|
||
from yuxi.channels.models import ChannelStatus
|
||
|
||
with patch.object(channel, "_status", ChannelStatus.CONNECTED):
|
||
result = await channel.health_check()
|
||
assert result.status == "healthy"
|
||
|
||
|
||
class TestSignalChannelCapabilities:
|
||
def test_capabilities_structure(self):
|
||
caps = SignalChannel.capabilities
|
||
assert caps.reactions is True
|
||
assert caps.edit is True
|
||
assert caps.unsend is True
|
||
assert caps.media is True
|
||
assert caps.pin is True
|
||
|
||
|
||
class TestSignalChannelMeta:
|
||
def test_meta_structure(self):
|
||
assert SignalChannel.meta.id == "signal"
|
||
assert SignalChannel.meta.label == "Signal"
|
||
|
||
|
||
class TestSignalChannelStreamingModes:
|
||
def test_streaming_modes_list(self):
|
||
assert "off" in SignalChannel.streaming_modes
|
||
assert "block" in SignalChannel.streaming_modes
|
||
assert "progress" in SignalChannel.streaming_modes
|
||
|
||
|
||
# ─── entity_cache.py – additional edge cases ─────────────────────────────────
|
||
|
||
|
||
class TestEntityCacheEdge:
|
||
def test_set_none_value(self):
|
||
cache = EntityCache()
|
||
|
||
async def _t():
|
||
await cache.set("k", None)
|
||
assert await cache.get("k") is None
|
||
|
||
asyncio.run(_t())
|
||
|
||
def test_get_miss_increments_counter(self):
|
||
cache = EntityCache()
|
||
|
||
async def _t():
|
||
_ = await cache.get("no-exist")
|
||
s = await cache.stats()
|
||
assert "size" in s
|
||
|
||
asyncio.run(_t())
|
||
|
||
def test_set_updates_hit(self):
|
||
cache = EntityCache()
|
||
|
||
async def _t():
|
||
await cache.set("k", "v1")
|
||
await cache.get("k")
|
||
s = await cache.stats()
|
||
assert s["size"] >= 1
|
||
|
||
asyncio.run(_t())
|
||
|
||
def test_max_size_boundary(self):
|
||
cache = EntityCache(max_size=1)
|
||
|
||
async def _t():
|
||
await cache.set("k1", "v1")
|
||
await cache.set("k2", "v2")
|
||
assert await cache.get("k1") is None
|
||
assert await cache.get("k2") == "v2"
|
||
|
||
asyncio.run(_t())
|
||
|
||
|
||
# ─── event_queue.py – additional edge cases ──────────────────────────────────
|
||
|
||
|
||
class TestEventQueueEdge:
|
||
def test_push_overflow_drops_oldest(self):
|
||
queue = OrderedEventQueue(max_size=2)
|
||
|
||
async def _t():
|
||
await queue.push({"m": "a"}, timestamp=1)
|
||
await queue.push({"m": "b"}, timestamp=2)
|
||
await queue.push({"m": "c"}, timestamp=3)
|
||
assert queue.size <= 2
|
||
|
||
asyncio.run(_t())
|
||
|
||
def test_start_twice_noop(self):
|
||
queue = OrderedEventQueue(max_size=10)
|
||
|
||
async def _t():
|
||
await queue.start()
|
||
await queue.start()
|
||
await queue.stop()
|
||
|
||
asyncio.run(_t())
|
||
|
||
def test_stop_not_started_noop(self):
|
||
queue = OrderedEventQueue(max_size=10)
|
||
|
||
async def _t():
|
||
await queue.stop()
|
||
|
||
asyncio.run(_t())
|
||
|
||
def test_same_timestamp_fifo(self):
|
||
events = []
|
||
|
||
async def handler(event):
|
||
events.append(event)
|
||
|
||
queue = OrderedEventQueue(max_size=10)
|
||
|
||
async def _t():
|
||
queue.set_handler(handler)
|
||
await queue.push({"m": "x"}, timestamp=100)
|
||
await queue.push({"m": "y"}, timestamp=100)
|
||
await queue.start()
|
||
await asyncio.sleep(0.1)
|
||
await queue.stop()
|
||
msgs = [e["m"] for e in events]
|
||
assert msgs == ["x", "y"]
|
||
|
||
asyncio.run(_t())
|
||
|
||
|
||
# ─── exec_auth.py – additional edge cases ────────────────────────────────────
|
||
|
||
|
||
class TestExecAuthEdge:
|
||
def test_auto_approve_returns_approved(self):
|
||
adapter = ExecAuthAdapter(auto_approve=True)
|
||
|
||
async def _t():
|
||
r1 = await adapter.request_approval("a1", {}, "+1")
|
||
r2 = await adapter.request_approval("a2", {}, "+2")
|
||
assert r1 == ExecAuthResult.APPROVED
|
||
assert r2 == ExecAuthResult.APPROVED
|
||
|
||
asyncio.run(_t())
|
||
|
||
def test_approve_nonexistent_returns_denied(self):
|
||
adapter = ExecAuthAdapter()
|
||
result = adapter.approve("nonexistent")
|
||
assert result == ExecAuthResult.DENIED
|
||
|
||
def test_pending_requests_appear_in_list(self):
|
||
adapter = ExecAuthAdapter()
|
||
|
||
async def _t():
|
||
task = asyncio.create_task(adapter.request_approval("a1", {}, "+1", timeout=0.1))
|
||
await asyncio.sleep(0.05)
|
||
pending = adapter.get_pending()
|
||
assert len(pending) == 1
|
||
adapter.approve(pending[0]["id"])
|
||
result = await task
|
||
assert result == ExecAuthResult.APPROVED
|
||
|
||
asyncio.run(_t())
|
||
|
||
|
||
# ─── media_vision.py – full coverage ─────────────────────────────────────────
|
||
|
||
|
||
class TestMediaVisionAnalyzer:
|
||
def test_disabled_returns_none(self):
|
||
analyzer = MediaVisionAnalyzer(None, enabled=False)
|
||
result = asyncio.run(analyzer.analyze_image(b"data"))
|
||
assert result is None
|
||
|
||
def test_no_llm_call_returns_none(self):
|
||
analyzer = MediaVisionAnalyzer(None, enabled=True)
|
||
result = asyncio.run(analyzer.analyze_image(b"data"))
|
||
assert result is None
|
||
|
||
async def test_analyze_image_success(self):
|
||
llm = AsyncMock(return_value={"content": "A cat on a sofa"})
|
||
analyzer = MediaVisionAnalyzer(llm, enabled=True)
|
||
result = await analyzer.analyze_image(b"fake_image_data")
|
||
assert result == "A cat on a sofa"
|
||
|
||
async def test_analyze_image_str_return(self):
|
||
llm = AsyncMock(return_value="simple string result")
|
||
analyzer = MediaVisionAnalyzer(llm, enabled=True)
|
||
result = await analyzer.analyze_image(b"fake_data")
|
||
assert result == "simple string result"
|
||
|
||
async def test_analyze_image_exception(self):
|
||
llm = AsyncMock(side_effect=RuntimeError("vision failed"))
|
||
analyzer = MediaVisionAnalyzer(llm, enabled=True)
|
||
result = await analyzer.analyze_image(b"data")
|
||
assert result is None
|
||
|
||
def test_analyze_attachment_video(self):
|
||
analyzer = MediaVisionAnalyzer(None, enabled=True)
|
||
result = asyncio.run(analyzer.analyze_attachment(b"video_data", filename="clip.mp4", mime_type="video/mp4"))
|
||
assert "video" in result
|
||
assert "clip.mp4" in result
|
||
|
||
def test_analyze_attachment_audio(self):
|
||
analyzer = MediaVisionAnalyzer(None, enabled=True)
|
||
result = asyncio.run(analyzer.analyze_attachment(b"audio", filename="song.ogg", mime_type="audio/ogg"))
|
||
assert "audio" in result
|
||
|
||
def test_analyze_attachment_file(self):
|
||
analyzer = MediaVisionAnalyzer(None, enabled=True)
|
||
result = asyncio.run(analyzer.analyze_attachment(b"data", filename="doc.pdf", mime_type="application/pdf"))
|
||
assert "file" in result or "doc.pdf" in result
|
||
|
||
def test_analyze_attachment_image_delegates(self):
|
||
llm = AsyncMock(return_value={"content": "photo desc"})
|
||
analyzer = MediaVisionAnalyzer(llm, enabled=True)
|
||
result = asyncio.run(analyzer.analyze_attachment(b"img", mime_type="image/png"))
|
||
assert result == "photo desc"
|
||
|
||
|
||
# ─── split_text – additional modes ───────────────────────────────────────────
|
||
|
||
|
||
class TestSplitTextLengthMode:
|
||
def test_exact_multiple(self):
|
||
chunks = split_text("A" * 8000, 4000, "length")
|
||
assert len(chunks) == 2
|
||
assert all(len(c) == 4000 for c in chunks)
|
||
|
||
def test_uneven_split(self):
|
||
chunks = split_text("B" * 5000, 4000, "length")
|
||
assert len(chunks) == 2
|
||
assert len(chunks[0]) == 4000
|
||
assert len(chunks[1]) == 1000
|
||
|
||
|
||
# ─── parse_signal_message – full metadata paths ──────────────────────────────
|
||
|
||
|
||
class TestParseSignalMessageFullMetadata:
|
||
def test_all_metadata_paths(self):
|
||
raw = {
|
||
"envelope": {"source": "+1234", "sourceName": "Alice"},
|
||
"dataMessage": {
|
||
"timestamp": 1715000000000,
|
||
"message": "full test",
|
||
"expiresInSeconds": 3600,
|
||
"viewOnce": True,
|
||
"sticker": {"packId": "pack-1", "stickerId": 42},
|
||
"forwarded": True,
|
||
"contacts": [{"name": "Bob", "number": "+9999"}],
|
||
"location": {"latitude": 40.0, "longitude": -74.0, "label": "NYC"},
|
||
"quote": {"id": 1715000000001},
|
||
},
|
||
}
|
||
msg = parse_signal_message(raw)
|
||
assert msg is not None
|
||
assert msg.metadata["expires_in_seconds"] == 3600
|
||
assert msg.metadata["view_once"] is True
|
||
assert msg.metadata["sticker_pack_id"] == "pack-1"
|
||
assert msg.metadata["forwarded"] is True
|
||
assert len(msg.metadata["contacts"]) == 1
|
||
assert msg.metadata["location"]["latitude"] == 40.0
|
||
assert msg.metadata["sender_display_name"] == "Alice"
|
||
assert msg.reply_to_message_id == "1715000000001"
|
||
|
||
|
||
# ─── dedup – additional key variants ─────────────────────────────────────────
|
||
|
||
|
||
class TestDedupKeyVariants:
|
||
def test_reaction_key(self):
|
||
data = {
|
||
"envelope": {"source": "+1234"},
|
||
"reaction": {"targetSentTimestamp": 1715000000000},
|
||
}
|
||
key = build_dedup_key(data, "acct1")
|
||
assert key is not None
|
||
assert "1715000000000" in key
|
||
|
||
def test_delete_key(self):
|
||
data = {
|
||
"envelope": {"source": "+1234"},
|
||
"deleteMessage": {"targetSentTimestamp": 1715000000000},
|
||
}
|
||
key = build_dedup_key(data, "acct1")
|
||
assert key is not None
|
||
|
||
def test_receipt_key(self):
|
||
data = {
|
||
"envelope": {"source": "+1234"},
|
||
"receiptMessage": {"timestamps": [1715000000000]},
|
||
}
|
||
key = build_dedup_key(data, "acct1")
|
||
assert key is not None
|
||
|
||
def test_no_source_returns_none(self):
|
||
data = {
|
||
"dataMessage": {"timestamp": 1715000000000},
|
||
}
|
||
key = build_dedup_key(data, "acct1")
|
||
assert key is None
|
||
|
||
def test_no_timestamp_returns_none(self):
|
||
data = {
|
||
"envelope": {"source": "+1234"},
|
||
}
|
||
key = build_dedup_key(data, "acct1")
|
||
assert key is None
|
||
|
||
|
||
# ─── normalize_target edge cases ─────────────────────────────────────────────
|
||
|
||
|
||
class TestNormalizeTargetEdge:
|
||
def test_e164_with_country_code(self):
|
||
assert normalize_target("+442071838750") == "+442071838750"
|
||
|
||
def test_already_normalized_uuid(self):
|
||
assert normalize_target("uuid:abcd-1234-efgh") == "uuid:abcd-1234-efgh"
|
||
|
||
def test_already_normalized_group(self):
|
||
assert normalize_target("group:base64encoded") == "group:base64encoded"
|
||
|
||
def test_digits_only_with_spaces(self):
|
||
assert normalize_target(" 86 138 0013 8000 ") == "+8613800138000"
|
||
|
||
def test_invalid_raises_value_error(self):
|
||
with pytest.raises(ValueError, match="Unable to normalize"):
|
||
normalize_target("abc")
|
||
|
||
|
||
class TestIsOwnMessageUuidOnly:
|
||
def test_match_by_uuid_alone(self):
|
||
data = {
|
||
"envelope": {"source": "+8611111111111", "sourceUuid": "uuid-me"},
|
||
}
|
||
assert is_own_message(data, "+8612222222222", "uuid-me") is True
|
||
|
||
def test_no_match(self):
|
||
data = {
|
||
"envelope": {"source": "+8611111111111", "sourceUuid": "uuid-other"},
|
||
}
|
||
assert is_own_message(data, "+8612222222222", "uuid-me") is False
|