from __future__ import annotations import json import time from unittest.mock import AsyncMock, MagicMock, patch import pytest from yuxi.channels.models import ChannelIdentity, ChannelResponse, ChannelType, DeliveryResult class TestNostrSenderFormatOutbound: @pytest.fixture def mock_components(self): crypto = MagicMock() crypto.build_and_sign_event.return_value = { "id": "test_event_id", "pubkey": "sender_hex", "kind": 4, "content": "test_content", "tags": [], "created_at": int(time.time()), } relay_manager = MagicMock() relay_manager.broadcast = AsyncMock(return_value=2) from yuxi.channels.adapters.nostr.config import NostrConfig config = NostrConfig(relays=["wss://test.relay"]) return crypto, relay_manager, config @pytest.mark.asyncio async def test_format_outbound_direct_message(self, mock_components): from yuxi.channels.adapters.nostr.send import NostrSender crypto, relay_manager, config = mock_components sender = NostrSender(crypto, relay_manager, config) identity = ChannelIdentity( channel_id="nostr", channel_type=ChannelType.NOSTR, channel_user_id="sender_hex", channel_chat_id="dm:receiver_hex", ) response = ChannelResponse(identity=identity, content="hello") event = await sender.format_outbound(response) assert event["kind"] == 4 assert isinstance(event["content"], str) @pytest.mark.asyncio async def test_format_outbound_group_message(self, mock_components): from yuxi.channels.adapters.nostr.send import NostrSender crypto, relay_manager, config = mock_components sender = NostrSender(crypto, relay_manager, config) identity = ChannelIdentity( channel_id="nostr", channel_type=ChannelType.NOSTR, channel_user_id="sender_hex", channel_chat_id="channel:group_001", ) response = ChannelResponse(identity=identity, content="hello group") event = await sender.format_outbound(response) assert event["kind"] == 1 @pytest.mark.asyncio async def test_format_outbound_with_reply(self, mock_components): from yuxi.channels.adapters.nostr.send import NostrSender crypto, relay_manager, config = mock_components sender = NostrSender(crypto, relay_manager, config) identity = ChannelIdentity( channel_id="nostr", channel_type=ChannelType.NOSTR, channel_user_id="sender_hex", channel_chat_id="dm:receiver_hex", ) response = ChannelResponse( identity=identity, content="reply text", reply_to_message_id="parent_event_001", ) event = await sender.format_outbound(response) tags = event.get("tags", []) e_tags = [t for t in tags if t[0] == "e"] assert len(e_tags) >= 1 @pytest.mark.asyncio async def test_format_outbound_markdown_table_conversion(self, mock_components): from yuxi.channels.adapters.nostr.send import NostrSender crypto, relay_manager, config = mock_components config.markdown_table_mode = "convert" sender = NostrSender(crypto, relay_manager, config) identity = ChannelIdentity( channel_id="nostr", channel_type=ChannelType.NOSTR, channel_user_id="sender_hex", channel_chat_id="dm:receiver_hex", ) markdown_with_table = "| A | B |\n| --- | --- |\n| 1 | 2 |\n" response = ChannelResponse(identity=identity, content=markdown_with_table) event = await sender.format_outbound(response) assert "|" not in event["content"] @pytest.mark.asyncio async def test_format_outbound_markdown_no_conversion_when_off(self, mock_components): from yuxi.channels.adapters.nostr.send import NostrSender crypto, relay_manager, config = mock_components config.markdown_table_mode = "off" sender = NostrSender(crypto, relay_manager, config) identity = ChannelIdentity( channel_id="nostr", channel_type=ChannelType.NOSTR, channel_user_id="sender_hex", channel_chat_id="dm:receiver_hex", ) markdown_with_table = "plain text without tables" response = ChannelResponse(identity=identity, content=markdown_with_table) event = await sender.format_outbound(response) assert "plain text" in event["content"] @pytest.mark.asyncio async def test_send_success(self, mock_components): from yuxi.channels.adapters.nostr.send import NostrSender crypto, relay_manager, config = mock_components sender = NostrSender(crypto, relay_manager, config) identity = ChannelIdentity( channel_id="nostr", channel_type=ChannelType.NOSTR, channel_user_id="sender_hex", channel_chat_id="dm:receiver_hex", ) response = ChannelResponse(identity=identity, content="hello world") result = await sender.send(response) assert result.success is True assert result.message_id == "test_event_id" @pytest.mark.asyncio async def test_send_all_relays_fail(self, mock_components): from yuxi.channels.adapters.nostr.send import NostrSender crypto, relay_manager, config = mock_components relay_manager.broadcast = AsyncMock(return_value=0) sender = NostrSender(crypto, relay_manager, config) identity = ChannelIdentity( channel_id="nostr", channel_type=ChannelType.NOSTR, channel_user_id="sender_hex", channel_chat_id="dm:receiver_hex", ) response = ChannelResponse(identity=identity, content="hello world") result = await sender.send(response) assert result.success is False class TestNostrSenderReactions: @pytest.fixture def mock_components(self): crypto = MagicMock() crypto.build_and_sign_event.return_value = { "id": "reaction_event_id", "kind": 7, "content": "❤", "tags": [], "created_at": int(time.time()), } relay_manager = MagicMock() relay_manager.broadcast = AsyncMock(return_value=1) from yuxi.channels.adapters.nostr.config import NostrConfig config = NostrConfig(relays=["wss://test.relay"]) return crypto, relay_manager, config @pytest.mark.asyncio async def test_send_reaction_success(self, mock_components): from yuxi.channels.adapters.nostr.send import NostrSender crypto, relay_manager, config = mock_components sender = NostrSender(crypto, relay_manager, config) result = await sender.send_reaction("target_event_001", "target_pubkey_hex", "👍") assert result.success is True @pytest.mark.asyncio async def test_remove_reaction_success(self, mock_components): from yuxi.channels.adapters.nostr.send import NostrSender crypto, relay_manager, config = mock_components sender = NostrSender(crypto, relay_manager, config) result = await sender.remove_reaction("target_event_001", "target_pubkey_hex") assert result.success is True class TestNostrSenderEditDelete: @pytest.fixture def mock_components(self): crypto = MagicMock() crypto.build_and_sign_event.return_value = { "id": "edit_event_id", "kind": 4, "content": "edited", "tags": [], "created_at": int(time.time()), } relay_manager = MagicMock() relay_manager.broadcast = AsyncMock(return_value=1) from yuxi.channels.adapters.nostr.config import NostrConfig config = NostrConfig(relays=["wss://test.relay"]) return crypto, relay_manager, config @pytest.mark.asyncio async def test_send_edit_success(self, mock_components): from yuxi.channels.adapters.nostr.send import NostrSender crypto, relay_manager, config = mock_components sender = NostrSender(crypto, relay_manager, config) result = await sender.send_edit("original_event_001", "edited content", "receiver_hex") assert result.success is True @pytest.mark.asyncio async def test_send_delete_success(self, mock_components): from yuxi.channels.adapters.nostr.send import NostrSender crypto, relay_manager, config = mock_components sender = NostrSender(crypto, relay_manager, config) result = await sender.send_delete("original_event_001") assert result.success is True class TestTextChunking: @pytest.fixture def mock_components(self): crypto = MagicMock() relay_manager = MagicMock() from yuxi.channels.adapters.nostr.config import NostrConfig config = NostrConfig(relays=["wss://test.relay"]) return crypto, relay_manager, config def test_chunk_short_text(self, mock_components): from yuxi.channels.adapters.nostr.send import NostrSender crypto, relay_manager, config = mock_components sender = NostrSender(crypto, relay_manager, config) chunks = sender._chunk_text("short text") assert len(chunks) == 1 assert chunks[0] == "short text" def test_chunk_long_text(self, mock_components): from yuxi.channels.adapters.nostr.send import NostrSender crypto, relay_manager, config = mock_components sender = NostrSender(crypto, relay_manager, config) chunk_size = 4096 base = "A" * (chunk_size - 10) + "\n" text = base + "B" * 100 chunks = sender._chunk_text(text) assert len(chunks) == 2 def test_chunk_at_newline_preferred(self, mock_components): from yuxi.channels.adapters.nostr.send import NostrSender crypto, relay_manager, config = mock_components sender = NostrSender(crypto, relay_manager, config) line1 = "A" * 2000 + "\n" line2 = "B" * 3000 text = line1 + line2 chunks = sender._chunk_text(text) assert len(chunks) == 2 def test_chunk_exact_limit(self, mock_components): from yuxi.channels.adapters.nostr.send import NostrSender crypto, relay_manager, config = mock_components sender = NostrSender(crypto, relay_manager, config) text = "A" * 4096 chunks = sender._chunk_text(text) assert len(chunks) == 1 class TestMarkdownTableConversion: def test_simple_table_converted(self): from yuxi.channels.adapters.nostr.send import NostrSender markdown = "| Name | Age |\n| --- | --- |\n| Alice | 30 |\n| Bob | 25 |\n" result = NostrSender.convert_markdown_tables(markdown) assert "|" not in result assert "Alice" in result assert "Bob" in result def test_no_table_unchanged(self): from yuxi.channels.adapters.nostr.send import NostrSender text = "plain text without any tables" result = NostrSender.convert_markdown_tables(text) assert result == text def test_aligned_table_converted(self): from yuxi.channels.adapters.nostr.send import NostrSender markdown = "| Left | Center | Right |\n| :--- | :---: | ---: |\n| a | b | c |\n" result = NostrSender.convert_markdown_tables(markdown) assert "|" not in result