新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
1690 lines
58 KiB
Python
1690 lines
58 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 AsyncMock, 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)
|
|
names = {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
|
|
|
|
adapter = ExecAuthAdapter()
|
|
result = asyncio.run(
|
|
adapter.request_approval("delete", {}, "+1234"),
|
|
)
|
|
assert result == ExecAuthResult.PENDING
|
|
pending = adapter.get_pending()
|
|
assert len(pending) == 1
|
|
assert pending[0]["action"] == "delete"
|
|
|
|
def test_approve(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.signal.exec_auth import ExecAuthAdapter, ExecAuthResult
|
|
|
|
adapter = ExecAuthAdapter()
|
|
asyncio.run(adapter.request_approval("delete", {}, "+1234"))
|
|
result = adapter.approve("exec_auth_1")
|
|
assert result == ExecAuthResult.APPROVED
|
|
|
|
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
|
|
from yuxi.channels.models import DeliveryResult
|
|
|
|
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
|