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