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重连相关测试
1719 lines
59 KiB
Python
1719 lines
59 KiB
Python
"""Unit tests for Signal channel adapter.
|
|
|
|
Tests the normalize, security, send, and message parsing modules.
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.signal.normalize import normalize_target, normalize_e164, parse_signal_message
|
|
from yuxi.channels.adapters.signal.security import SignalSecurityPolicy
|
|
from yuxi.channels.adapters.signal.send import SignalSender
|
|
from yuxi.channels.models import (
|
|
Attachment,
|
|
ChannelIdentity,
|
|
ChannelMessage,
|
|
ChannelResponse,
|
|
ChatType,
|
|
EventType,
|
|
MessageType,
|
|
)
|
|
|
|
|
|
class TestNormalizeTarget:
|
|
def test_e164_number(self):
|
|
assert normalize_target("+8613800138000") == "+8613800138000"
|
|
|
|
def test_number_without_plus(self):
|
|
assert normalize_target("8613800138000") == "+8613800138000"
|
|
|
|
def test_number_with_spaces_and_dash(self):
|
|
assert normalize_target("+1 (555) 123-4567") == "+15551234567"
|
|
|
|
def test_group_prefix_passthrough(self):
|
|
assert normalize_target("group:abc123def456") == "group:abc123def456"
|
|
|
|
def test_uuid_prefix_passthrough(self):
|
|
assert normalize_target("uuid:abcd-1234-efgh") == "uuid:abcd-1234-efgh"
|
|
|
|
def test_stripped_whitespace(self):
|
|
assert normalize_target(" +8613800138000 ") == "+8613800138000"
|
|
|
|
def test_invalid_number_raises(self):
|
|
with pytest.raises(ValueError, match="Unable to normalize"):
|
|
normalize_target("123")
|
|
|
|
def test_short_digit_only_raises(self):
|
|
with pytest.raises(ValueError, match="Unable to normalize"):
|
|
normalize_target("123456")
|
|
|
|
|
|
class TestNormalizeE164:
|
|
def test_strips_plus_and_non_digits(self):
|
|
assert normalize_e164("+86 138-0013-8000") == "8613800138000"
|
|
|
|
def test_already_clean(self):
|
|
assert normalize_e164("8613800138000") == "8613800138000"
|
|
|
|
|
|
class TestParseSignalMessage:
|
|
def test_direct_message(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000000,
|
|
"message": "Hello world",
|
|
},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is not None
|
|
assert msg.chat_type == ChatType.DIRECT
|
|
assert msg.content == "Hello world"
|
|
assert msg.identity.channel_user_id == "+1234567890"
|
|
assert msg.identity.channel_chat_id == "+1234567890"
|
|
assert msg.identity.channel_message_id == "1715000000000"
|
|
assert msg.message_type == MessageType.TEXT
|
|
|
|
def test_group_message(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000001,
|
|
"message": "Group hello",
|
|
"groupInfo": {"groupId": "Z3JvdXAtaWQ="},
|
|
},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is not None
|
|
assert msg.chat_type == ChatType.GROUP
|
|
assert msg.identity.channel_chat_id == "group:Z3JvdXAtaWQ="
|
|
|
|
def test_message_with_body_fallback(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000002,
|
|
"body": "Fallback body",
|
|
},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is not None
|
|
assert msg.content == "Fallback body"
|
|
|
|
def test_message_with_quote(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000003,
|
|
"message": "Reply message",
|
|
"quote": {"id": 1715000000001},
|
|
},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is not None
|
|
assert msg.reply_to_message_id == "1715000000001"
|
|
|
|
def test_message_with_image_attachment(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000004,
|
|
"message": "",
|
|
"attachments": [{"contentType": "image/png"}],
|
|
},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is not None
|
|
assert msg.message_type == MessageType.IMAGE
|
|
|
|
def test_message_with_video_attachment(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000005,
|
|
"attachments": [{"contentType": "video/mp4"}],
|
|
},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is not None
|
|
assert msg.message_type == MessageType.VIDEO
|
|
|
|
def test_message_with_audio_attachment(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000006,
|
|
"attachments": [{"contentType": "audio/ogg"}],
|
|
},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is not None
|
|
assert msg.message_type == MessageType.AUDIO
|
|
|
|
def test_message_with_file_attachment(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000007,
|
|
"attachments": [{"contentType": "application/pdf"}],
|
|
},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is not None
|
|
assert msg.message_type == MessageType.FILE
|
|
|
|
def test_no_data_message_returns_none(self):
|
|
raw = {"envelope": {"source": "+1234567890"}}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is None
|
|
|
|
def test_empty_data_message_returns_none(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"dataMessage": {},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is None
|
|
|
|
def test_unknown_source(self):
|
|
raw = {
|
|
"envelope": {},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000008,
|
|
"message": "test",
|
|
},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is not None
|
|
assert msg.identity.channel_user_id == "unknown"
|
|
|
|
|
|
class TestSignalSecurityPolicy:
|
|
@staticmethod
|
|
def _make_msg(user_id: str, chat_id: str | None = None):
|
|
from yuxi.channels.models import ChannelIdentity
|
|
|
|
return ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id="signal",
|
|
channel_type="signal",
|
|
channel_user_id=user_id,
|
|
channel_chat_id=chat_id or user_id,
|
|
),
|
|
content="hello",
|
|
)
|
|
|
|
def test_dm_open(self):
|
|
policy = SignalSecurityPolicy(dm_policy="open")
|
|
assert policy.check_dm_permission(self._make_msg("+1234")) is True
|
|
|
|
def test_dm_disabled(self):
|
|
policy = SignalSecurityPolicy(dm_policy="disabled")
|
|
assert policy.check_dm_permission(self._make_msg("+1234")) is False
|
|
|
|
def test_dm_allowlist_allowed(self):
|
|
policy = SignalSecurityPolicy(dm_policy="allowlist", allow_from=["+1234"])
|
|
assert policy.check_dm_permission(self._make_msg("+1234")) is True
|
|
|
|
def test_dm_allowlist_denied(self):
|
|
policy = SignalSecurityPolicy(dm_policy="allowlist", allow_from=["+1234"])
|
|
assert policy.check_dm_permission(self._make_msg("+9999")) is False
|
|
|
|
def test_dm_pairing_already_approved(self):
|
|
policy = SignalSecurityPolicy(dm_policy="pairing", allow_from=["+1234"])
|
|
assert policy.check_dm_permission(self._make_msg("+1234")) is True
|
|
|
|
def test_dm_pairing_new_user_pending(self):
|
|
policy = SignalSecurityPolicy(dm_policy="pairing")
|
|
msg = self._make_msg("+new_user")
|
|
assert policy.check_dm_permission(msg) is False
|
|
assert "+new_user" in policy.pending_pairings
|
|
|
|
async def test_dm_pairing_approve(self):
|
|
policy = SignalSecurityPolicy(dm_policy="pairing")
|
|
msg = self._make_msg("+new_user")
|
|
assert policy.check_dm_permission(msg) is False
|
|
await policy.approve_pairing("+new_user")
|
|
assert policy.check_dm_permission(msg) is True
|
|
assert "+new_user" not in policy.pending_pairings
|
|
|
|
def test_dm_pairing_reject(self):
|
|
policy = SignalSecurityPolicy(dm_policy="pairing")
|
|
msg = self._make_msg("+new_user")
|
|
policy.check_dm_permission(msg)
|
|
assert "+new_user" in policy.pending_pairings
|
|
policy.reject_pairing("+new_user")
|
|
assert "+new_user" not in policy.pending_pairings
|
|
assert policy.check_dm_permission(msg) is False
|
|
|
|
def test_group_open(self):
|
|
policy = SignalSecurityPolicy(group_policy="open")
|
|
assert policy.check_group_permission(self._make_msg("+1234", "group:abc")) is True
|
|
|
|
def test_group_disabled(self):
|
|
policy = SignalSecurityPolicy(group_policy="disabled")
|
|
assert policy.check_group_permission(self._make_msg("+1234", "group:abc")) is False
|
|
|
|
def test_group_allowlist_allowed(self):
|
|
policy = SignalSecurityPolicy(group_policy="allowlist", group_allow_from=["group:abc"])
|
|
assert policy.check_group_permission(self._make_msg("+1234", "group:abc")) is True
|
|
|
|
def test_group_allowlist_denied(self):
|
|
policy = SignalSecurityPolicy(group_policy="allowlist", group_allow_from=["group:abc"])
|
|
assert policy.check_group_permission(self._make_msg("+1234", "group:xyz")) is False
|
|
|
|
def test_add_remove_allowlist(self):
|
|
policy = SignalSecurityPolicy()
|
|
policy.add_to_allowlist("+1234", "dm")
|
|
assert "+1234" in policy._dm_allowlist
|
|
policy.remove_from_allowlist("+1234", "dm")
|
|
assert "+1234" not in policy._dm_allowlist
|
|
|
|
policy.add_to_allowlist("group:abc", "group")
|
|
assert "group:abc" in policy._group_allowlist
|
|
policy.remove_from_allowlist("group:abc", "group")
|
|
assert "group:abc" not in policy._group_allowlist
|
|
|
|
|
|
class TestSignalSenderRecipientParam:
|
|
def test_direct_message_uses_recipient(self):
|
|
sender = SignalSender(MagicMock(), "+1234567890")
|
|
result = sender._recipient_param("+8613800138000")
|
|
assert "recipient" in result
|
|
assert result["recipient"] == "+8613800138000"
|
|
assert "groupId" not in result
|
|
|
|
def test_group_message_uses_group_id(self):
|
|
sender = SignalSender(MagicMock(), "+1234567890")
|
|
result = sender._recipient_param("group:abc123")
|
|
assert "groupId" in result
|
|
assert result["groupId"] == "group:abc123"
|
|
assert "recipient" not in result
|
|
|
|
|
|
class TestSignalSenderSplitText:
|
|
def test_short_text_no_split(self):
|
|
from yuxi.channels.adapters.signal.format import split_text
|
|
|
|
chunks = split_text("Hello world", 4000)
|
|
assert chunks == ["Hello world"]
|
|
|
|
def test_long_text_splits_on_paragraphs(self):
|
|
from yuxi.channels.adapters.signal.format import split_text
|
|
|
|
para = "A" * 3000
|
|
text = f"{para}\n\n{para}"
|
|
chunks = split_text(text, 4000)
|
|
assert len(chunks) == 2
|
|
|
|
def test_very_long_paragraph_splits_on_length(self):
|
|
from yuxi.channels.adapters.signal.format import split_text
|
|
|
|
text = "B" * 5000
|
|
chunks = split_text(text, 4000)
|
|
assert len(chunks) == 2
|
|
|
|
def test_text_exactly_at_limit(self):
|
|
from yuxi.channels.adapters.signal.format import split_text
|
|
|
|
text = "C" * 4000
|
|
chunks = split_text(text, 4000)
|
|
assert len(chunks) == 1
|
|
|
|
def test_empty_text(self):
|
|
from yuxi.channels.adapters.signal.format import split_text
|
|
|
|
chunks = split_text("", 4000)
|
|
assert chunks == [""]
|
|
|
|
|
|
class TestParseSignalMessageMentions:
|
|
def test_mentions_with_uuid(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000000,
|
|
"message": "Hello @user",
|
|
"bodyRanges": [{"mentionUuid": "uuid-1234", "start": 6, "length": 5}],
|
|
},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is not None
|
|
assert msg.mentions is not None
|
|
assert "uuid-1234" in msg.mentions.mentioned_user_ids
|
|
|
|
def test_mentions_with_no_body_ranges(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000000,
|
|
"message": "Plain message",
|
|
},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is not None
|
|
assert msg.mentions is None
|
|
|
|
def test_mentions_with_empty_body_ranges(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000001,
|
|
"message": "No mentions",
|
|
"bodyRanges": [],
|
|
},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is not None
|
|
assert msg.mentions is None
|
|
|
|
|
|
class TestParseSignalMessageUrls:
|
|
def test_urls_from_previews(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000000,
|
|
"message": "Check this out",
|
|
"previews": [{"url": "https://example.com"}],
|
|
},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is not None
|
|
assert "https://example.com" in msg.extracted_urls
|
|
|
|
def test_urls_from_content_regex(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000001,
|
|
"message": "Visit https://signal.org for more",
|
|
},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is not None
|
|
assert "https://signal.org" in msg.extracted_urls
|
|
|
|
def test_no_urls(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000002,
|
|
"message": "No links here",
|
|
},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is not None
|
|
assert msg.extracted_urls == []
|
|
|
|
|
|
class TestParseSignalMessageMetadata:
|
|
def test_disappearing_message(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000000,
|
|
"message": "This will disappear",
|
|
"expiresInSeconds": 3600,
|
|
},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is not None
|
|
assert msg.metadata.get("expires_in_seconds") == 3600
|
|
|
|
def test_view_once_message(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000001,
|
|
"message": "One time view",
|
|
"viewOnce": True,
|
|
},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is not None
|
|
assert msg.metadata.get("view_once") is True
|
|
|
|
def test_sticker_message(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000002,
|
|
"message": "",
|
|
"sticker": {"packId": "sticker-pack-001"},
|
|
},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is not None
|
|
assert msg.metadata.get("sticker_pack_id") == "sticker-pack-001"
|
|
|
|
def test_timestamp_parsing(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000000,
|
|
"message": "Hello",
|
|
},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is not None
|
|
assert msg.timestamp is not None
|
|
assert msg.timestamp.year == 2024
|
|
assert msg.timestamp.month == 5
|
|
assert msg.timestamp.day == 6
|
|
|
|
|
|
class TestParseSignalDelete:
|
|
def test_delete_message(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"deleteMessage": {"targetSentTimestamp": 1715000000000},
|
|
}
|
|
from yuxi.channels.adapters.signal.normalize import parse_signal_delete
|
|
|
|
msg = parse_signal_delete(raw)
|
|
assert msg is not None
|
|
assert msg.event_type.value == "message.deleted"
|
|
assert msg.content == "(message deleted)"
|
|
assert msg.identity.channel_user_id == "+1234567890"
|
|
|
|
def test_delete_message_with_group(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"deleteMessage": {
|
|
"targetSentTimestamp": 1715000000001,
|
|
"groupInfo": {"groupId": "group-abc"},
|
|
},
|
|
}
|
|
from yuxi.channels.adapters.signal.normalize import parse_signal_delete
|
|
|
|
msg = parse_signal_delete(raw)
|
|
assert msg is not None
|
|
assert msg.chat_type == ChatType.GROUP
|
|
assert msg.identity.channel_chat_id == "group:group-abc"
|
|
|
|
def test_delete_message_no_delete_message_field(self):
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"dataMessage": {"timestamp": 1715000000000, "message": "normal"},
|
|
}
|
|
from yuxi.channels.adapters.signal.normalize import parse_signal_delete
|
|
|
|
msg = parse_signal_delete(raw)
|
|
assert msg is None
|
|
|
|
|
|
class TestSignalSecurityPolicyInvalidTargetType:
|
|
def test_add_to_allowlist_invalid_type_raises(self):
|
|
policy = SignalSecurityPolicy()
|
|
with pytest.raises(ValueError, match="Unknown target_type"):
|
|
policy.add_to_allowlist("+1234", "invalid")
|
|
|
|
def test_remove_from_allowlist_invalid_type_raises(self):
|
|
policy = SignalSecurityPolicy()
|
|
with pytest.raises(ValueError, match="Unknown target_type"):
|
|
policy.remove_from_allowlist("+1234", "invalid")
|
|
|
|
|
|
class TestSignalChannelMediaTypeDispatch:
|
|
def test_media_message_types_includes_all_media(self):
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
|
|
assert MessageType.IMAGE in SignalChannel.MEDIA_MESSAGE_TYPES
|
|
assert MessageType.VIDEO in SignalChannel.MEDIA_MESSAGE_TYPES
|
|
assert MessageType.AUDIO in SignalChannel.MEDIA_MESSAGE_TYPES
|
|
assert MessageType.FILE in SignalChannel.MEDIA_MESSAGE_TYPES
|
|
assert MessageType.TEXT not in SignalChannel.MEDIA_MESSAGE_TYPES
|
|
|
|
def test_mtype_to_media_mapping(self):
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
|
|
assert SignalChannel._MTYPE_TO_MEDIA[MessageType.IMAGE] == "image"
|
|
assert SignalChannel._MTYPE_TO_MEDIA[MessageType.VIDEO] == "video"
|
|
assert SignalChannel._MTYPE_TO_MEDIA[MessageType.AUDIO] == "audio"
|
|
assert SignalChannel._MTYPE_TO_MEDIA[MessageType.FILE] == "file"
|
|
|
|
|
|
class TestSignalChannelFormatOutbound:
|
|
def test_basic_text(self):
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
from yuxi.channels.models import ChannelType
|
|
|
|
channel = SignalChannel()
|
|
resp = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="signal",
|
|
channel_type=ChannelType.SIGNAL,
|
|
channel_user_id="+1234",
|
|
channel_chat_id="+5678",
|
|
),
|
|
content="Hello",
|
|
)
|
|
result = channel.format_outbound(resp)
|
|
assert result["recipient"] == "+5678"
|
|
assert result["messageBody"] == "Hello"
|
|
assert "quoteTimestamp" not in result
|
|
|
|
def test_with_reply_to(self):
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
from yuxi.channels.models import ChannelType
|
|
|
|
channel = SignalChannel()
|
|
resp = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="signal",
|
|
channel_type=ChannelType.SIGNAL,
|
|
channel_user_id="+1234",
|
|
channel_chat_id="+5678",
|
|
),
|
|
content="Reply",
|
|
reply_to_message_id="1715000000000",
|
|
)
|
|
result = channel.format_outbound(resp)
|
|
assert result["quoteTimestamp"] == 1715000000000
|
|
|
|
def test_with_attachments(self):
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
from yuxi.channels.models import ChannelType
|
|
|
|
channel = SignalChannel()
|
|
resp = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="signal",
|
|
channel_type=ChannelType.SIGNAL,
|
|
channel_user_id="+1234",
|
|
channel_chat_id="+5678",
|
|
),
|
|
content="Image caption",
|
|
message_type=MessageType.IMAGE,
|
|
attachments=[
|
|
Attachment(
|
|
type="image",
|
|
mime_type="image/png",
|
|
filename="photo.png",
|
|
file_id="att-001",
|
|
)
|
|
],
|
|
)
|
|
result = channel.format_outbound(resp)
|
|
assert len(result["attachments"]) == 1
|
|
assert result["attachments"][0]["contentType"] == "image/png"
|
|
assert result["attachments"][0]["filename"] == "photo.png"
|
|
assert result["attachments"][0]["id"] == "att-001"
|
|
|
|
|
|
class TestSignalChannelNormalizeInbound:
|
|
def test_normalize_message(self):
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
|
|
channel = SignalChannel()
|
|
raw = {
|
|
"envelope": {"source": "+1234"},
|
|
"dataMessage": {"timestamp": 1715000000000, "message": "Hello"},
|
|
}
|
|
msg = channel.normalize_inbound(raw)
|
|
assert msg.content == "Hello"
|
|
|
|
def test_normalize_reaction(self):
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
|
|
channel = SignalChannel()
|
|
raw = {
|
|
"envelope": {"source": "+1234"},
|
|
"reaction": {"targetAuthor": "+5678", "emoji": "👍", "targetSentTimestamp": 1715000000000},
|
|
}
|
|
msg = channel.normalize_inbound(raw)
|
|
assert msg.event_type == EventType.MESSAGE_UPDATED
|
|
assert "👍" in msg.content
|
|
|
|
def test_normalize_receipt(self):
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
|
|
channel = SignalChannel()
|
|
raw = {
|
|
"envelope": {"source": "+1234"},
|
|
"receiptMessage": {"type": "READ", "timestamps": [1715000000000]},
|
|
}
|
|
msg = channel.normalize_inbound(raw)
|
|
assert msg.event_type == EventType.READ_RECEIPT
|
|
|
|
def test_normalize_delete(self):
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
|
|
channel = SignalChannel()
|
|
raw = {
|
|
"envelope": {"source": "+1234"},
|
|
"deleteMessage": {"targetSentTimestamp": 1715000000000},
|
|
}
|
|
msg = channel.normalize_inbound(raw)
|
|
assert msg.event_type == EventType.MESSAGE_DELETED
|
|
|
|
def test_unknown_event_raises(self):
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
|
|
channel = SignalChannel()
|
|
raw = {"envelope": {"source": "+1234"}, "unknownField": {}}
|
|
with pytest.raises(ValueError, match="no recognized event found"):
|
|
channel.normalize_inbound(raw)
|
|
|
|
|
|
class TestSignalChannelEditMessageVersionCheck:
|
|
def test_edit_when_not_supported(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
|
|
channel = SignalChannel()
|
|
channel._sender = MagicMock()
|
|
channel._supports_edit = False
|
|
result = asyncio.run(channel.edit_message("+1234", "123", "new content"))
|
|
assert result.success is False
|
|
assert "Edit not supported" in result.error
|
|
|
|
|
|
class TestSignalChannelCallWithCB:
|
|
def test_cb_passes_result(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
from yuxi.channels.models import DeliveryResult
|
|
|
|
channel = SignalChannel()
|
|
channel._sender = MagicMock()
|
|
channel._sender.send_text = AsyncMock(return_value=DeliveryResult(success=True, message_id="1"))
|
|
|
|
async def _test():
|
|
return await channel._call_with_cb(
|
|
channel._sender.send_text,
|
|
recipient="+1234",
|
|
message_body="hello",
|
|
)
|
|
|
|
result = asyncio.run(_test())
|
|
assert result.success is True
|
|
assert result.message_id == "1"
|
|
|
|
def test_cb_returns_error_on_circuit_breaker_open(self):
|
|
import time
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
from yuxi.channels.infra.circuit_breaker import CircuitState
|
|
|
|
channel = SignalChannel()
|
|
channel._sender = MagicMock()
|
|
channel._sender.send_text = AsyncMock()
|
|
channel._circuit_breaker.state = CircuitState.OPEN
|
|
channel._circuit_breaker._last_failure_time = time.monotonic()
|
|
|
|
async def _test():
|
|
return await channel._call_with_cb(
|
|
channel._sender.send_text,
|
|
recipient="+1234",
|
|
message_body="hello",
|
|
)
|
|
|
|
result = asyncio.run(_test())
|
|
assert result.success is False
|
|
assert "Circuit breaker open" in result.error
|
|
|
|
|
|
class TestSignalChannelSendReadReceipt:
|
|
def test_send_read_receipt(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
from yuxi.channels.models import DeliveryResult
|
|
|
|
channel = SignalChannel()
|
|
channel._sender = AsyncMock()
|
|
channel._sender.send_read_receipt = AsyncMock(return_value=DeliveryResult(success=True))
|
|
|
|
result = asyncio.run(channel.send_read_receipt("+1234", "1715000000000"))
|
|
assert result.success is True
|
|
channel._sender.send_read_receipt.assert_called_once_with(
|
|
recipient="+1234",
|
|
timestamps=[1715000000000],
|
|
)
|
|
|
|
def test_send_read_receipt_no_sender(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
|
|
channel = SignalChannel()
|
|
result = asyncio.run(channel.send_read_receipt("+1234", "123"))
|
|
assert result.success is False
|
|
assert "not initialized" in result.error.lower()
|
|
|
|
|
|
class TestSignalChannelSendReactionRemove:
|
|
def test_send_reaction_remove(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
from yuxi.channels.models import DeliveryResult
|
|
|
|
channel = SignalChannel()
|
|
channel._sender = AsyncMock()
|
|
channel._sender.send_reaction = AsyncMock(return_value=DeliveryResult(success=True))
|
|
|
|
result = asyncio.run(
|
|
channel.send_reaction("+1234", "1715000000000", "👍", remove=True),
|
|
)
|
|
assert result.success is True
|
|
channel._sender.send_reaction.assert_called_once_with(
|
|
recipient="+1234",
|
|
target_author=channel._account_number,
|
|
target_sent_timestamp=1715000000000,
|
|
reaction="👍",
|
|
remove=True,
|
|
)
|
|
|
|
|
|
class TestSignalChannelSendStreamChunkCB:
|
|
def test_stream_chunk_first_send_passes_cb(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
from yuxi.channels.models import DeliveryResult
|
|
|
|
channel = SignalChannel({"block_streaming": True})
|
|
channel._sender = AsyncMock()
|
|
channel._rpc_client = AsyncMock()
|
|
channel._sender.send_text = AsyncMock(
|
|
return_value=DeliveryResult(success=True, message_id="msg-001"),
|
|
)
|
|
|
|
result = asyncio.run(
|
|
channel.send_stream_chunk("+1234", "", "hello", finished=False),
|
|
)
|
|
assert result.success is True
|
|
assert result.message_id == "msg-001"
|
|
|
|
def test_stream_chunk_edit_passes_cb(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
from yuxi.channels.models import DeliveryResult
|
|
|
|
channel = SignalChannel()
|
|
channel._sender = AsyncMock()
|
|
channel._rpc_client = AsyncMock()
|
|
channel._supports_edit = True
|
|
channel._sender.edit_message = AsyncMock(
|
|
return_value=DeliveryResult(success=True),
|
|
)
|
|
|
|
result = asyncio.run(
|
|
channel.send_stream_chunk("+1234", "1715000000000", "updated chunk", finished=False),
|
|
)
|
|
assert result.success is True
|
|
|
|
def test_stream_chunk_final_fallback_passes_cb(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
from yuxi.channels.models import DeliveryResult
|
|
|
|
channel = SignalChannel()
|
|
channel._sender = AsyncMock()
|
|
channel._rpc_client = AsyncMock()
|
|
channel._supports_edit = False
|
|
channel._sender.send_text = AsyncMock(
|
|
return_value=DeliveryResult(success=True),
|
|
)
|
|
|
|
result = asyncio.run(
|
|
channel.send_stream_chunk("+1234", "1715000000000", "final chunk", finished=True),
|
|
)
|
|
assert result.success is True
|
|
|
|
|
|
class TestSignalChannelSendMediaMetadata:
|
|
def test_send_media_passes_filename_and_caption(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
from yuxi.channels.models import DeliveryResult
|
|
|
|
channel = SignalChannel()
|
|
channel._sender = AsyncMock()
|
|
channel._sender.send_media = AsyncMock(return_value=DeliveryResult(success=True))
|
|
|
|
result = asyncio.run(
|
|
channel.send_media(
|
|
"+1234",
|
|
"image",
|
|
b"fake_data",
|
|
filename="photo.png",
|
|
caption="A nice photo",
|
|
),
|
|
)
|
|
assert result.success is True
|
|
channel._sender.send_media.assert_called_once_with(
|
|
recipient="+1234",
|
|
media_data=b"fake_data",
|
|
media_type="image",
|
|
filename="photo.png",
|
|
caption="A nice photo",
|
|
)
|
|
|
|
|
|
class TestIsOwnMessage:
|
|
def test_own_message_by_number(self):
|
|
from yuxi.channels.adapters.signal.normalize import is_own_message
|
|
|
|
data = {
|
|
"envelope": {"source": "+8613800138000"},
|
|
"dataMessage": {"timestamp": 1715000000000, "message": "Hello"},
|
|
}
|
|
assert is_own_message(data, "+8613800138000") is True
|
|
assert is_own_message(data, "+99999999999") is False
|
|
|
|
def test_own_message_by_uuid(self):
|
|
from yuxi.channels.adapters.signal.normalize import is_own_message
|
|
|
|
data = {
|
|
"envelope": {"source": "+12345678901", "sourceUuid": "uuid-self-001"},
|
|
"dataMessage": {"timestamp": 1715000000000, "message": "Hello"},
|
|
}
|
|
assert is_own_message(data, "+12345678901", "uuid-self-001") is True
|
|
assert is_own_message(data, "+99998888777", "uuid-self-001") is True
|
|
assert is_own_message(data, "+12345678901", "uuid-other") is True
|
|
|
|
def test_not_own_message(self):
|
|
from yuxi.channels.adapters.signal.normalize import is_own_message
|
|
|
|
data = {
|
|
"envelope": {"source": "+99998888777", "sourceUuid": "uuid-other"},
|
|
"dataMessage": {"timestamp": 1715000000000, "message": "Hello"},
|
|
}
|
|
assert is_own_message(data, "+12345678901", "uuid-self-001") is False
|
|
|
|
|
|
class TestIsSyncMessage:
|
|
def test_sync_message_detected(self):
|
|
from yuxi.channels.adapters.signal.normalize import is_sync_message
|
|
|
|
data = {"syncMessage": {"sentTranscript": {}}}
|
|
assert is_sync_message(data) is True
|
|
|
|
def test_normal_message_not_sync(self):
|
|
from yuxi.channels.adapters.signal.normalize import is_sync_message
|
|
|
|
data = {"envelope": {"source": "+1234"}, "dataMessage": {}}
|
|
assert is_sync_message(data) is False
|
|
|
|
|
|
class TestDedup:
|
|
def test_dedup_first_seeing(self):
|
|
from yuxi.channels.adapters.signal.normalize import build_dedup_key, check_and_add_dedup
|
|
|
|
data = {
|
|
"envelope": {"source": "+1234"},
|
|
"dataMessage": {"timestamp": 1715000000000, "message": "Hello"},
|
|
}
|
|
key = build_dedup_key(data, "+1234567890")
|
|
assert key is not None
|
|
assert check_and_add_dedup(key) is False
|
|
|
|
def test_dedup_duplicate(self):
|
|
from yuxi.channels.adapters.signal.normalize import build_dedup_key, check_and_add_dedup
|
|
|
|
data = {
|
|
"envelope": {"source": "+99998888999"},
|
|
"dataMessage": {"timestamp": 1815000000000, "message": "Hello"},
|
|
}
|
|
key = build_dedup_key(data, "+1234567890")
|
|
assert key is not None
|
|
assert check_and_add_dedup(key) is False
|
|
assert check_and_add_dedup(key) is True
|
|
|
|
def test_dedup_key_includes_group_id(self):
|
|
from yuxi.channels.adapters.signal.normalize import build_dedup_key
|
|
|
|
data = {
|
|
"envelope": {"source": "+1234"},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000000,
|
|
"message": "Hello",
|
|
"groupInfo": {"groupId": "group-abc"},
|
|
},
|
|
}
|
|
key = build_dedup_key(data, "+1234567890")
|
|
assert "group-abc" in key
|
|
|
|
|
|
class TestReactionLevel:
|
|
def test_off_level_rejects_all(self):
|
|
from yuxi.channels.adapters.signal.reaction_level import ReactionLevelController
|
|
|
|
ctrl = ReactionLevelController("off")
|
|
assert ctrl.should_send_reaction("chat-1") is False
|
|
assert ctrl.should_send_reaction("chat-1", "\U0001f440") is False
|
|
assert ctrl.should_send_auto_ack("chat-1") is False
|
|
|
|
def test_ack_level_only_allows_eyes(self):
|
|
from yuxi.channels.adapters.signal.reaction_level import ReactionLevelController
|
|
|
|
ctrl = ReactionLevelController("ack")
|
|
assert ctrl.should_send_reaction("chat-1", "\U0001f440") is True
|
|
assert ctrl.should_send_reaction("chat-1", "\U0001f44d") is False
|
|
|
|
def test_minimal_level_allows_all(self):
|
|
from yuxi.channels.adapters.signal.reaction_level import ReactionLevelController
|
|
|
|
ctrl = ReactionLevelController("minimal")
|
|
assert ctrl.should_send_reaction("chat-1", "\U0001f44d") is True
|
|
|
|
def test_auto_ack_only_once(self):
|
|
from yuxi.channels.adapters.signal.reaction_level import ReactionLevelController
|
|
|
|
ctrl = ReactionLevelController("minimal")
|
|
assert ctrl.should_send_auto_ack("chat-1") is True
|
|
assert ctrl.should_send_auto_ack("chat-1") is False
|
|
ctrl.reset_ack("chat-1")
|
|
assert ctrl.should_send_auto_ack("chat-1") is True
|
|
|
|
|
|
class TestRequireMention:
|
|
def test_require_mention_without_mention(self):
|
|
from yuxi.channels.adapters.signal.security import SignalSecurityPolicy
|
|
from yuxi.channels.models import ChannelIdentity, ChannelMessage, ChatType
|
|
|
|
policy = SignalSecurityPolicy(require_mention=True)
|
|
msg = ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id="signal",
|
|
channel_type="signal",
|
|
channel_user_id="+1234",
|
|
channel_chat_id="group:abc",
|
|
),
|
|
content="hello",
|
|
chat_type=ChatType.GROUP,
|
|
)
|
|
assert policy.check_require_mention(msg) is False
|
|
|
|
def test_require_mention_with_mention(self):
|
|
from yuxi.channels.adapters.signal.security import SignalSecurityPolicy
|
|
from yuxi.channels.models import ChannelIdentity, ChannelMessage, ChatType, MentionsInfo
|
|
|
|
policy = SignalSecurityPolicy(require_mention=True)
|
|
msg = ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id="signal",
|
|
channel_type="signal",
|
|
channel_user_id="+1234",
|
|
channel_chat_id="group:abc",
|
|
),
|
|
content="hello @bot",
|
|
chat_type=ChatType.GROUP,
|
|
mentions=MentionsInfo(
|
|
mentioned_user_ids=["uuid-bot"],
|
|
is_bot_mentioned=True,
|
|
),
|
|
)
|
|
assert policy.check_require_mention(msg) is True
|
|
|
|
def test_require_mention_dm_always_passes(self):
|
|
from yuxi.channels.adapters.signal.security import SignalSecurityPolicy
|
|
from yuxi.channels.models import ChannelIdentity, ChannelMessage, ChatType
|
|
|
|
policy = SignalSecurityPolicy(require_mention=True)
|
|
msg = ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id="signal",
|
|
channel_type="signal",
|
|
channel_user_id="+1234",
|
|
channel_chat_id="+1234",
|
|
),
|
|
content="hello",
|
|
chat_type=ChatType.DIRECT,
|
|
)
|
|
assert policy.check_require_mention(msg) is True
|
|
|
|
|
|
class TestReactionNotification:
|
|
def test_off_policy(self):
|
|
from yuxi.channels.adapters.signal.security import SignalSecurityPolicy
|
|
|
|
policy = SignalSecurityPolicy(reaction_notifications="off")
|
|
assert policy.check_reaction_notification("+1234") is False
|
|
|
|
def test_all_policy(self):
|
|
from yuxi.channels.adapters.signal.security import SignalSecurityPolicy
|
|
|
|
policy = SignalSecurityPolicy(reaction_notifications="all")
|
|
assert policy.check_reaction_notification("+1234") is True
|
|
|
|
def test_allowlist_policy(self):
|
|
from yuxi.channels.adapters.signal.security import SignalSecurityPolicy
|
|
|
|
policy = SignalSecurityPolicy(
|
|
reaction_notifications="allowlist",
|
|
reaction_allowlist=["+1234"],
|
|
)
|
|
assert policy.check_reaction_notification("+1234") is True
|
|
assert policy.check_reaction_notification("+9999") is False
|
|
|
|
|
|
class TestConfigOverrides:
|
|
def test_text_chunk_limit_from_config(self):
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
|
|
channel = SignalChannel({"text_chunk_limit": 2000})
|
|
assert channel.text_chunk_limit == 2000
|
|
|
|
def test_media_max_mb_from_config(self):
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
|
|
channel = SignalChannel({"media_max_mb": 8})
|
|
assert channel.max_media_size_mb == 8
|
|
|
|
def test_enabled_false(self):
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
|
|
channel = SignalChannel({"enabled": False, "signal_number": "+1234"})
|
|
assert channel._enabled is False
|
|
|
|
|
|
class TestE164Validation:
|
|
def test_valid_e164(self):
|
|
from yuxi.channels.adapters.signal.setup import validate_e164
|
|
|
|
assert validate_e164("+1234567890") is True
|
|
assert validate_e164("+8613800138000") is True
|
|
assert validate_e164("+15551234567") is True
|
|
|
|
def test_invalid_e164(self):
|
|
from yuxi.channels.adapters.signal.setup import validate_e164
|
|
|
|
assert validate_e164("123") is False
|
|
assert validate_e164("+12") is False
|
|
assert validate_e164("not-a-number") is False
|
|
|
|
|
|
class TestBlockStreaming:
|
|
def test_block_streaming_disabled_coalesces(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
from yuxi.channels.models import DeliveryResult
|
|
|
|
channel = SignalChannel({"block_streaming": False})
|
|
channel._sender = AsyncMock()
|
|
channel._rpc_client = AsyncMock()
|
|
channel._sender.send_text = AsyncMock(
|
|
return_value=DeliveryResult(success=True, message_id="msg-001"),
|
|
)
|
|
|
|
result = asyncio.run(
|
|
channel.send_stream_chunk("+1234", "", "hello world", finished=False),
|
|
)
|
|
assert result.success is True
|
|
|
|
def test_block_streaming_enabled_sends_immediately(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
from yuxi.channels.models import DeliveryResult
|
|
|
|
channel = SignalChannel({"block_streaming": True})
|
|
channel._sender = AsyncMock()
|
|
channel._rpc_client = AsyncMock()
|
|
channel._sender.send_text = AsyncMock(
|
|
return_value=DeliveryResult(success=True, message_id="msg-001"),
|
|
)
|
|
|
|
result = asyncio.run(
|
|
channel.send_stream_chunk("+1234", "", "hello world", finished=False),
|
|
)
|
|
assert result.success is True
|
|
|
|
|
|
class TestLooksLikeSignalTargetId:
|
|
def test_e164(self):
|
|
from yuxi.channels.adapters.signal.normalize import looks_like_signal_target_id
|
|
|
|
assert looks_like_signal_target_id("+8613800138000") is True
|
|
|
|
def test_group_prefix(self):
|
|
from yuxi.channels.adapters.signal.normalize import looks_like_signal_target_id
|
|
|
|
assert looks_like_signal_target_id("group:abc123") is True
|
|
|
|
def test_uuid_prefix(self):
|
|
from yuxi.channels.adapters.signal.normalize import looks_like_signal_target_id
|
|
|
|
assert looks_like_signal_target_id("uuid:abcd-1234") is True
|
|
|
|
def test_number_without_plus(self):
|
|
from yuxi.channels.adapters.signal.normalize import looks_like_signal_target_id
|
|
|
|
assert looks_like_signal_target_id("8613800138000") is True
|
|
|
|
def test_invalid_short(self):
|
|
from yuxi.channels.adapters.signal.normalize import looks_like_signal_target_id
|
|
|
|
assert looks_like_signal_target_id("123") is False
|
|
|
|
def test_plain_text(self):
|
|
from yuxi.channels.adapters.signal.normalize import looks_like_signal_target_id
|
|
|
|
assert looks_like_signal_target_id("hello world") is False
|
|
|
|
|
|
class TestFormatMarkdownToSignalStyles:
|
|
def test_bold_style(self):
|
|
from yuxi.channels.adapters.signal.format import markdown_to_signal_styles
|
|
|
|
result = markdown_to_signal_styles("Hello **world**")
|
|
assert result is not None
|
|
assert any(s.style == "BOLD" for s in result.styles)
|
|
|
|
def test_italic_style(self):
|
|
from yuxi.channels.adapters.signal.format import markdown_to_signal_styles
|
|
|
|
result = markdown_to_signal_styles("Hello *world*")
|
|
assert result is not None
|
|
assert any(s.style == "ITALIC" for s in result.styles)
|
|
|
|
def test_strikethrough_style(self):
|
|
from yuxi.channels.adapters.signal.format import markdown_to_signal_styles
|
|
|
|
result = markdown_to_signal_styles("Hello ~~world~~")
|
|
assert result is not None
|
|
assert any(s.style == "STRIKETHROUGH" for s in result.styles)
|
|
|
|
def test_monospace_style(self):
|
|
from yuxi.channels.adapters.signal.format import markdown_to_signal_styles
|
|
|
|
result = markdown_to_signal_styles("Hello `world`")
|
|
assert result is not None
|
|
assert any(s.style == "MONOSPACE" for s in result.styles)
|
|
|
|
def test_mixed_styles(self):
|
|
from yuxi.channels.adapters.signal.format import markdown_to_signal_styles
|
|
|
|
result = markdown_to_signal_styles("**bold** and *italic* and ~~strike~~")
|
|
assert result is not None
|
|
style_names = {s.style for s in result.styles}
|
|
assert "BOLD" in style_names
|
|
assert "ITALIC" in style_names
|
|
assert "STRIKETHROUGH" in style_names
|
|
|
|
def test_table_to_bullets(self):
|
|
from yuxi.channels.adapters.signal.format import markdown_to_signal_styles
|
|
|
|
result = markdown_to_signal_styles(
|
|
"| Col1 | Col2 |\n|------|------|\n| A | B |",
|
|
table_mode="bullets",
|
|
)
|
|
assert result is not None
|
|
assert "A" in result.body or len(result.body) > 0
|
|
|
|
def test_split_text_newline_mode(self):
|
|
from yuxi.channels.adapters.signal.format import split_text
|
|
|
|
chunks = split_text("Hello\n\nWorld", 4000, "newline")
|
|
assert len(chunks) >= 1
|
|
|
|
def test_split_text_length_mode(self):
|
|
from yuxi.channels.adapters.signal.format import split_text
|
|
|
|
long_text = "A" * 5000
|
|
chunks = split_text(long_text, 4000, "length")
|
|
assert len(chunks) == 2
|
|
|
|
def test_split_text_short(self):
|
|
from yuxi.channels.adapters.signal.format import split_text
|
|
|
|
chunks = split_text("Hello", 4000, "newline")
|
|
assert chunks == ["Hello"]
|
|
|
|
def test_clamp_styles(self):
|
|
from yuxi.channels.adapters.signal.format import StyleRange, clamp_styles_to_length
|
|
|
|
styles = [StyleRange(start=5, length=10, style="BOLD")]
|
|
result = clamp_styles_to_length(styles, "0123456789") # 10 chars
|
|
assert len(result) >= 0
|
|
|
|
|
|
class TestRpcClient:
|
|
def test_rpc_client_connect(self):
|
|
from unittest.mock import patch
|
|
|
|
with patch("aiohttp.ClientSession"):
|
|
from yuxi.channels.adapters.signal.client import RpcClient
|
|
|
|
client = RpcClient("http://127.0.0.1:8080")
|
|
assert client.base_url == "http://127.0.0.1:8080"
|
|
|
|
def test_validate_daemon_url_loopback(self):
|
|
from yuxi.channels.adapters.signal.client import _validate_daemon_url
|
|
|
|
_validate_daemon_url("http://127.0.0.1:8080")
|
|
|
|
def test_validate_daemon_url_localhost(self):
|
|
from yuxi.channels.adapters.signal.client import _validate_daemon_url
|
|
|
|
_validate_daemon_url("http://localhost:8080")
|
|
|
|
def test_validate_daemon_url_private(self):
|
|
from yuxi.channels.adapters.signal.client import _validate_daemon_url
|
|
|
|
_validate_daemon_url("http://192.168.1.1:8080")
|
|
|
|
def test_validate_daemon_url_remote_rejected(self):
|
|
from yuxi.channels.adapters.signal.client import _validate_daemon_url
|
|
|
|
import pytest
|
|
|
|
with pytest.raises(ValueError, match="SSRF guard"):
|
|
_validate_daemon_url("http://93.184.216.34:8080")
|
|
|
|
def test_rate_limit_detection(self):
|
|
from yuxi.channels.adapters.signal.client import _detect_rate_limit
|
|
|
|
result = _detect_rate_limit("Rate limit exceeded", {"retry_after_seconds": 30})
|
|
assert result is not None
|
|
assert result.retry_after_seconds == 30
|
|
|
|
def test_rate_limit_no_match(self):
|
|
from yuxi.channels.adapters.signal.client import _detect_rate_limit
|
|
|
|
result = _detect_rate_limit("Unknown error", {})
|
|
assert result is None
|
|
|
|
def test_unauthorized_error(self):
|
|
from yuxi.channels.adapters.signal.client import UnauthorizedError
|
|
|
|
err = UnauthorizedError()
|
|
assert err.code == -32001
|
|
assert "Unauthorized" in str(err)
|
|
|
|
|
|
class TestMonitorEventRouting:
|
|
def test_is_message_event(self):
|
|
from yuxi.channels.adapters.signal.monitor import SSEMonitor
|
|
|
|
data = {"envelope": {"source": "+1234"}, "dataMessage": {"message": "hello"}}
|
|
assert SSEMonitor._is_message_event(data) is True
|
|
|
|
def test_is_not_message_event(self):
|
|
from yuxi.channels.adapters.signal.monitor import SSEMonitor
|
|
|
|
data = {"envelope": {"source": "+1234"}}
|
|
assert SSEMonitor._is_message_event(data) is False
|
|
|
|
def test_is_edit_message_event(self):
|
|
from yuxi.channels.adapters.signal.monitor import SSEMonitor
|
|
|
|
data = {
|
|
"envelope": {
|
|
"source": "+1234",
|
|
"editMessage": {"dataMessage": {"message": "edited"}},
|
|
},
|
|
}
|
|
assert SSEMonitor._is_edit_message_event(data) is True
|
|
|
|
def test_is_reaction_event(self):
|
|
from yuxi.channels.adapters.signal.monitor import SSEMonitor
|
|
|
|
data = {
|
|
"envelope": {"source": "+1234"},
|
|
"reaction": {"emoji": "👍", "targetSentTimestamp": 123},
|
|
}
|
|
assert SSEMonitor._is_reaction_event(data) is True
|
|
|
|
def test_is_delete_event(self):
|
|
from yuxi.channels.adapters.signal.monitor import SSEMonitor
|
|
|
|
data = {
|
|
"envelope": {"source": "+1234"},
|
|
"deleteMessage": {"targetSentTimestamp": 123},
|
|
}
|
|
assert SSEMonitor._is_delete_event(data) is True
|
|
|
|
def test_is_receipt_event(self):
|
|
from yuxi.channels.adapters.signal.monitor import SSEMonitor
|
|
|
|
data = {
|
|
"envelope": {"source": "+1234"},
|
|
"receiptMessage": {"type": "READ", "timestamps": [123]},
|
|
}
|
|
assert SSEMonitor._is_receipt_event(data) is True
|
|
|
|
def test_is_story_event(self):
|
|
from yuxi.channels.adapters.signal.monitor import SSEMonitor
|
|
|
|
data = {
|
|
"envelope": {"source": "+1234"},
|
|
"storyMessage": {},
|
|
}
|
|
assert SSEMonitor._is_story_event(data) is True
|
|
|
|
def test_extract_edit_data(self):
|
|
from yuxi.channels.adapters.signal.monitor import SSEMonitor
|
|
|
|
data = {
|
|
"envelope": {
|
|
"source": "+1234",
|
|
"editMessage": {"dataMessage": {"timestamp": 123, "message": "new"}},
|
|
},
|
|
}
|
|
extracted = SSEMonitor._extract_edit_data(data)
|
|
assert extracted["dataMessage"]["message"] == "new"
|
|
|
|
|
|
class TestMessageActions:
|
|
def test_describe_all_actions(self):
|
|
from yuxi.channels.adapters.signal.message_actions import (
|
|
SIGNAL_MESSAGE_ACTIONS,
|
|
describe_message_tools,
|
|
)
|
|
|
|
tools = describe_message_tools()
|
|
assert len(tools) == len(SIGNAL_MESSAGE_ACTIONS)
|
|
|
|
def test_describe_filter_actions(self):
|
|
from yuxi.channels.adapters.signal.message_actions import describe_message_tools
|
|
|
|
enabled = {"send": True, "react": False}
|
|
tools = describe_message_tools(enabled)
|
|
{t["name"] for t in tools}
|
|
|
|
def test_action_definitions(self):
|
|
from yuxi.channels.adapters.signal.message_actions import SIGNAL_MESSAGE_ACTIONS
|
|
|
|
assert "send" in SIGNAL_MESSAGE_ACTIONS
|
|
assert "react" in SIGNAL_MESSAGE_ACTIONS
|
|
assert "edit" in SIGNAL_MESSAGE_ACTIONS
|
|
assert "delete" in SIGNAL_MESSAGE_ACTIONS
|
|
assert "send_media" in SIGNAL_MESSAGE_ACTIONS
|
|
assert "send_typing" in SIGNAL_MESSAGE_ACTIONS
|
|
assert "send_read_receipt" in SIGNAL_MESSAGE_ACTIONS
|
|
assert "get_user_info" in SIGNAL_MESSAGE_ACTIONS
|
|
|
|
|
|
class TestExecAuth:
|
|
def test_auto_approve(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.exec_auth import ExecAuthAdapter, ExecAuthResult
|
|
|
|
adapter = ExecAuthAdapter(auto_approve=True)
|
|
result = asyncio.run(
|
|
adapter.request_approval("delete", {}, "+1234"),
|
|
)
|
|
assert result == ExecAuthResult.APPROVED
|
|
|
|
def test_request_pending(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.exec_auth import ExecAuthAdapter, ExecAuthResult
|
|
|
|
async def _t():
|
|
adapter = ExecAuthAdapter()
|
|
task = asyncio.create_task(adapter.request_approval("delete", {}, "+1234", timeout=0.1))
|
|
await asyncio.sleep(0.05)
|
|
pending = adapter.get_pending()
|
|
assert len(pending) == 1
|
|
assert pending[0]["action"] == "delete"
|
|
adapter.approve(pending[0]["id"])
|
|
result = await task
|
|
assert result == ExecAuthResult.APPROVED
|
|
|
|
asyncio.run(_t())
|
|
|
|
def test_approve(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.exec_auth import ExecAuthAdapter, ExecAuthResult
|
|
|
|
async def _t():
|
|
adapter = ExecAuthAdapter()
|
|
task = asyncio.create_task(adapter.request_approval("delete", {}, "+1234", timeout=0.1))
|
|
await asyncio.sleep(0.05)
|
|
result = adapter.approve("exec_auth_1")
|
|
assert result == ExecAuthResult.APPROVED
|
|
task_result = await task
|
|
assert task_result == ExecAuthResult.APPROVED
|
|
|
|
asyncio.run(_t())
|
|
|
|
def test_deny(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.exec_auth import ExecAuthAdapter, ExecAuthResult
|
|
|
|
adapter = ExecAuthAdapter()
|
|
asyncio.run(adapter.request_approval("delete", {}, "+1234"))
|
|
result = adapter.deny("exec_auth_1")
|
|
assert result == ExecAuthResult.DENIED
|
|
|
|
def test_deny_nonexistent(self):
|
|
from yuxi.channels.adapters.signal.exec_auth import ExecAuthAdapter, ExecAuthResult
|
|
|
|
adapter = ExecAuthAdapter()
|
|
result = adapter.deny("nonexistent")
|
|
assert result == ExecAuthResult.DENIED
|
|
|
|
def test_pending_empty(self):
|
|
from yuxi.channels.adapters.signal.exec_auth import ExecAuthAdapter
|
|
|
|
adapter = ExecAuthAdapter()
|
|
pending = adapter.get_pending()
|
|
assert len(pending) == 0
|
|
|
|
|
|
class TestEntityCache:
|
|
def test_set_and_get(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.entity_cache import EntityCache
|
|
|
|
cache = EntityCache(ttl=60, max_size=10)
|
|
|
|
async def _test():
|
|
await cache.set("key1", {"name": "Alice"})
|
|
result = await cache.get("key1")
|
|
assert result == {"name": "Alice"}
|
|
|
|
asyncio.run(_test())
|
|
|
|
def test_expired_ttl(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.entity_cache import EntityCache
|
|
|
|
cache = EntityCache(ttl=-1, max_size=10)
|
|
|
|
async def _test():
|
|
await cache.set("key1", "value")
|
|
result = await cache.get("key1")
|
|
assert result is None
|
|
|
|
asyncio.run(_test())
|
|
|
|
def test_lru_eviction(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.entity_cache import EntityCache
|
|
|
|
cache = EntityCache(ttl=600, max_size=3)
|
|
|
|
async def _test():
|
|
await cache.set("k1", "v1")
|
|
await cache.set("k2", "v2")
|
|
await cache.set("k3", "v3")
|
|
await cache.set("k4", "v4")
|
|
assert await cache.get("k1") is None
|
|
assert await cache.get("k2") == "v2"
|
|
|
|
asyncio.run(_test())
|
|
|
|
def test_delete(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.entity_cache import EntityCache
|
|
|
|
cache = EntityCache()
|
|
|
|
async def _test():
|
|
await cache.set("key1", "value")
|
|
await cache.delete("key1")
|
|
assert await cache.get("key1") is None
|
|
|
|
asyncio.run(_test())
|
|
|
|
def test_clear(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.entity_cache import EntityCache
|
|
|
|
cache = EntityCache()
|
|
|
|
async def _test():
|
|
await cache.set("k1", "v1")
|
|
await cache.set("k2", "v2")
|
|
await cache.clear()
|
|
assert await cache.get("k1") is None
|
|
|
|
asyncio.run(_test())
|
|
|
|
def test_stats(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.entity_cache import EntityCache
|
|
|
|
cache = EntityCache()
|
|
|
|
async def _test():
|
|
await cache.set("k1", "v1")
|
|
s = await cache.stats()
|
|
assert s["size"] == 1
|
|
assert s["max_size"] == 500
|
|
assert s["ttl"] == 600
|
|
|
|
asyncio.run(_test())
|
|
|
|
|
|
class TestOrderedEventQueue:
|
|
def test_push_and_process(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.event_queue import OrderedEventQueue
|
|
|
|
events_received = []
|
|
|
|
async def handler(event):
|
|
events_received.append(event)
|
|
|
|
queue = OrderedEventQueue(max_size=10)
|
|
|
|
async def _test():
|
|
queue.set_handler(handler)
|
|
await queue.push({"msg": "hello"}, timestamp=100)
|
|
await queue.start()
|
|
await asyncio.sleep(0.1)
|
|
await queue.stop()
|
|
assert len(events_received) == 1
|
|
assert events_received[0] == {"msg": "hello"}
|
|
|
|
asyncio.run(_test())
|
|
|
|
def test_ordered_delivery(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.event_queue import OrderedEventQueue
|
|
|
|
events_received = []
|
|
|
|
async def handler(event):
|
|
events_received.append(event)
|
|
|
|
queue = OrderedEventQueue(max_size=10)
|
|
|
|
async def _test():
|
|
queue.set_handler(handler)
|
|
await queue.push({"msg": "c"}, timestamp=300)
|
|
await queue.push({"msg": "a"}, timestamp=100)
|
|
await queue.push({"msg": "b"}, timestamp=200)
|
|
await queue.start()
|
|
await asyncio.sleep(0.1)
|
|
await queue.stop()
|
|
msgs = [e["msg"] for e in events_received]
|
|
assert msgs == ["a", "b", "c"]
|
|
|
|
asyncio.run(_test())
|
|
|
|
def test_size(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.event_queue import OrderedEventQueue
|
|
|
|
queue = OrderedEventQueue(max_size=10)
|
|
|
|
async def _test():
|
|
await queue.push({"msg": "1"}, timestamp=1)
|
|
await queue.push({"msg": "2"}, timestamp=2)
|
|
assert queue.size == 2
|
|
|
|
asyncio.run(_test())
|
|
|
|
|
|
class TestDisplayName:
|
|
def test_source_name_extracted(self):
|
|
from yuxi.channels.adapters.signal.normalize import parse_signal_message
|
|
|
|
raw = {
|
|
"envelope": {"source": "+1234567890", "sourceName": "Alice"},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000000,
|
|
"message": "Hello",
|
|
},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is not None
|
|
assert msg.metadata.get("sender_display_name") == "Alice"
|
|
|
|
def test_source_name_fallback_to_number(self):
|
|
from yuxi.channels.adapters.signal.normalize import parse_signal_message
|
|
|
|
raw = {
|
|
"envelope": {"source": "+1234567890"},
|
|
"dataMessage": {
|
|
"timestamp": 1715000000000,
|
|
"message": "Hello",
|
|
},
|
|
}
|
|
msg = parse_signal_message(raw)
|
|
assert msg is not None
|
|
assert "sender_display_name" not in msg.metadata
|
|
|
|
|
|
class TestConfigWrites:
|
|
def test_allow_config_writes(self):
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
|
|
channel = SignalChannel({"config_writes": True})
|
|
assert channel.allow_config_writes() is True
|
|
|
|
def test_deny_config_writes(self):
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
|
|
channel = SignalChannel({"config_writes": False})
|
|
assert channel.allow_config_writes() is False
|
|
|
|
def test_deny_default(self):
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
|
|
channel = SignalChannel()
|
|
assert channel.allow_config_writes() is False
|
|
|
|
|
|
class TestDirectorySearch:
|
|
def test_match_query_by_number(self):
|
|
from yuxi.channels.adapters.signal.directory import _match_query
|
|
|
|
entry = {"number": "+8613800138000", "name": "Test"}
|
|
assert _match_query(entry, "+86138") is True
|
|
|
|
def test_match_query_by_name(self):
|
|
from yuxi.channels.adapters.signal.directory import _match_query
|
|
|
|
entry = {"number": "+8613800138000", "name": "Alice"}
|
|
assert _match_query(entry, "alice") is True
|
|
|
|
def test_match_query_no_match(self):
|
|
from yuxi.channels.adapters.signal.directory import _match_query
|
|
|
|
entry = {"number": "+8613800138000", "name": "Alice"}
|
|
assert _match_query(entry, "Bob") is False
|
|
|
|
|
|
class TestReactionLevelIntegration:
|
|
def test_send_reaction_respects_off_level(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
|
|
|
channel = SignalChannel({"reaction_level": "off"})
|
|
channel._sender = AsyncMock()
|
|
|
|
result = asyncio.run(
|
|
channel.send_reaction("+1234", "123", "👍"),
|
|
)
|
|
assert result.success is True
|
|
|
|
|
|
class TestConfigSchema:
|
|
def test_new_config_items(self):
|
|
from yuxi.channels.adapters.signal.config_schema import SIGNAL_CONFIG_SCHEMA
|
|
|
|
assert "allow_remote_daemon" in SIGNAL_CONFIG_SCHEMA
|
|
assert "download_max_mb" in SIGNAL_CONFIG_SCHEMA
|
|
assert "entity_cache_ttl" in SIGNAL_CONFIG_SCHEMA
|
|
assert "event_queue_max_size" in SIGNAL_CONFIG_SCHEMA
|
|
assert "prefer_native_binary" in SIGNAL_CONFIG_SCHEMA
|
|
|
|
|
|
class TestProbeResult:
|
|
def test_signal_probe_result_defaults(self):
|
|
from yuxi.channels.adapters.signal.probe import SignalProbeResult
|
|
|
|
result = SignalProbeResult()
|
|
assert result.status == "unknown"
|
|
assert result.success is False
|
|
|
|
def test_signal_probe_result_healthy(self):
|
|
from yuxi.channels.adapters.signal.probe import SignalProbeResult
|
|
|
|
result = SignalProbeResult(status="healthy", version="1.0", latency_ms=15.0)
|
|
assert result.success is True
|
|
assert result.version == "1.0"
|
|
assert result.latency_ms == 15.0
|
|
|
|
def test_signal_probe_result_unhealthy(self):
|
|
from yuxi.channels.adapters.signal.probe import SignalProbeResult
|
|
|
|
result = SignalProbeResult(status="unhealthy", error_type="TIMEOUT", latency_ms=7500)
|
|
assert result.success is False
|