ForcePilot/backend/test/unit/channels/test_channels_signal_comprehensive.py
Kris 69fe97a90d test: 批量修复并新增单元测试用例
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重连相关测试
2026-05-13 16:43:01 +08:00

1863 lines
65 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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