248 lines
9.3 KiB
Python
248 lines
9.3 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import time
|
||
|
|
from unittest.mock import AsyncMock, MagicMock
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from yuxi.channels.adapters.nostr.send import NostrSender
|
||
|
|
from yuxi.channels.models import ChannelIdentity, ChannelResponse, ChannelType, DeliveryResult
|
||
|
|
|
||
|
|
|
||
|
|
class TestNostrSenderTypingAndMedia:
|
||
|
|
@pytest.fixture
|
||
|
|
def mock_components(self):
|
||
|
|
crypto = MagicMock()
|
||
|
|
crypto.build_and_sign_event.return_value = {
|
||
|
|
"id": "typing_event",
|
||
|
|
"kind": 4,
|
||
|
|
"content": "typing",
|
||
|
|
"tags": [],
|
||
|
|
"created_at": int(time.time()),
|
||
|
|
}
|
||
|
|
crypto.encrypt_nip17 = AsyncMock(return_value='{"id":"gw","kind":1059}')
|
||
|
|
crypto.encrypt_nip04.return_value = "encrypted_content"
|
||
|
|
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_typing_success(self, mock_components):
|
||
|
|
crypto, relay_manager, config = mock_components
|
||
|
|
sender = NostrSender(crypto, relay_manager, config)
|
||
|
|
result = await sender.send_typing("dm:receiver_hex", "receiver_hex")
|
||
|
|
assert result.success is True
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_send_typing_throttled(self, mock_components):
|
||
|
|
crypto, relay_manager, config = mock_components
|
||
|
|
sender = NostrSender(crypto, relay_manager, config)
|
||
|
|
sender._typing_throttle["dm:receiver_hex"] = time.monotonic()
|
||
|
|
result = await sender.send_typing("dm:receiver_hex", "receiver_hex")
|
||
|
|
assert result.success is True
|
||
|
|
assert result.message_id is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_send_media_text_basic(self, mock_components):
|
||
|
|
crypto, relay_manager, config = mock_components
|
||
|
|
sender = NostrSender(crypto, relay_manager, config)
|
||
|
|
result = await sender.send_media_text(
|
||
|
|
"https://example.com/img.jpg", "Check this!", "receiver_hex"
|
||
|
|
)
|
||
|
|
assert result.success is True
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_send_media_text_group(self, mock_components):
|
||
|
|
crypto, relay_manager, config = mock_components
|
||
|
|
sender = NostrSender(crypto, relay_manager, config)
|
||
|
|
result = await sender.send_media_text(
|
||
|
|
"https://example.com/file.pdf", "Document", "", "group"
|
||
|
|
)
|
||
|
|
assert result.success is True
|
||
|
|
|
||
|
|
|
||
|
|
class TestNostrSenderEditDeleteExtended:
|
||
|
|
@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()),
|
||
|
|
}
|
||
|
|
crypto.encrypt_nip17 = AsyncMock(return_value='{"id":"gw","kind":1059}')
|
||
|
|
crypto.encrypt_nip04.return_value = "encrypted_content"
|
||
|
|
relay_manager = MagicMock()
|
||
|
|
relay_manager.broadcast = AsyncMock(return_value=0)
|
||
|
|
|
||
|
|
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_all_relays_fail(self, mock_components):
|
||
|
|
crypto, relay_manager, config = mock_components
|
||
|
|
sender = NostrSender(crypto, relay_manager, config)
|
||
|
|
result = await sender.send_edit("original_id", "new_content", "receiver_hex")
|
||
|
|
assert result.success is False
|
||
|
|
assert result.error is not None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_send_delete_all_relays_fail(self, mock_components):
|
||
|
|
crypto, relay_manager, config = mock_components
|
||
|
|
sender = NostrSender(crypto, relay_manager, config)
|
||
|
|
result = await sender.send_delete("original_id")
|
||
|
|
assert result.success is False
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_remove_reaction_all_relays_fail(self, mock_components):
|
||
|
|
crypto, relay_manager, config = mock_components
|
||
|
|
sender = NostrSender(crypto, relay_manager, config)
|
||
|
|
result = await sender.remove_reaction("target_id", "target_pubkey")
|
||
|
|
assert result.success is False
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_send_reaction_all_relays_fail(self, mock_components):
|
||
|
|
crypto, relay_manager, config = mock_components
|
||
|
|
sender = NostrSender(crypto, relay_manager, config)
|
||
|
|
result = await sender.send_reaction("target_id", "target_pubkey", "👍")
|
||
|
|
assert result.success is False
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_send_media_text_all_relays_fail(self, mock_components):
|
||
|
|
crypto, relay_manager, config = mock_components
|
||
|
|
sender = NostrSender(crypto, relay_manager, config)
|
||
|
|
result = await sender.send_media_text("https://img.url", "caption", "receiver_hex")
|
||
|
|
assert result.success is False
|
||
|
|
|
||
|
|
|
||
|
|
class TestNostrSenderChunkingExtended:
|
||
|
|
@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_at_whitespace_fallback(self, mock_components):
|
||
|
|
crypto, relay_manager, config = mock_components
|
||
|
|
sender = NostrSender(crypto, relay_manager, config)
|
||
|
|
|
||
|
|
chunk_size = 4096
|
||
|
|
text = "A" * (chunk_size - 5) + " " + "B" * 100
|
||
|
|
chunks = sender._chunk_text(text)
|
||
|
|
assert len(chunks) == 2
|
||
|
|
|
||
|
|
def test_chunk_no_newline_no_space(self, mock_components):
|
||
|
|
crypto, relay_manager, config = mock_components
|
||
|
|
sender = NostrSender(crypto, relay_manager, config)
|
||
|
|
|
||
|
|
chunk_size = 4096
|
||
|
|
text = "A" * (chunk_size + 200)
|
||
|
|
chunks = sender._chunk_text(text)
|
||
|
|
assert len(chunks) == 2
|
||
|
|
|
||
|
|
def test_chunk_multiple_splits(self, mock_components):
|
||
|
|
crypto, relay_manager, config = mock_components
|
||
|
|
sender = NostrSender(crypto, relay_manager, config)
|
||
|
|
|
||
|
|
chunk_size = 4096
|
||
|
|
part = "A" * (chunk_size - 1000)
|
||
|
|
text = part + "\n" + part + "\n" + part
|
||
|
|
chunks = sender._chunk_text(text)
|
||
|
|
assert len(chunks) == 3
|
||
|
|
|
||
|
|
def test_chunk_space_split_in_second_half(self, mock_components):
|
||
|
|
crypto, relay_manager, config = mock_components
|
||
|
|
sender = NostrSender(crypto, relay_manager, config)
|
||
|
|
|
||
|
|
chunk_size = 4096
|
||
|
|
text = "A" * (chunk_size + 500) + " B" * 100
|
||
|
|
chunks = sender._chunk_text(text)
|
||
|
|
assert len(chunks) == 2
|
||
|
|
|
||
|
|
|
||
|
|
class TestMarkdownTableExtended:
|
||
|
|
def test_table_with_alignment_all_left(self):
|
||
|
|
from yuxi.channels.adapters.nostr.send import NostrSender
|
||
|
|
|
||
|
|
markdown = "| A | B | C |\n| :--- | :--- | :--- |\n| 1 | 2 | 3 |\n"
|
||
|
|
result = NostrSender.convert_markdown_tables(markdown)
|
||
|
|
assert "|" not in result
|
||
|
|
assert "A" in result
|
||
|
|
assert "1" in result
|
||
|
|
|
||
|
|
def test_table_with_mixed_alignment(self):
|
||
|
|
from yuxi.channels.adapters.nostr.send import NostrSender
|
||
|
|
|
||
|
|
markdown = "| Left | Center | Right |\n| :--- | :---: | ---: |\n| a | b | c |\n| d | e | f |\n"
|
||
|
|
result = NostrSender.convert_markdown_tables(markdown)
|
||
|
|
assert "|" not in result
|
||
|
|
assert "a" in result
|
||
|
|
assert "f" in result
|
||
|
|
|
||
|
|
def test_no_table_mixed_content(self):
|
||
|
|
from yuxi.channels.adapters.nostr.send import NostrSender
|
||
|
|
|
||
|
|
text = "Some intro text\n| A | B |\n| --- | --- |\n| 1 | 2 |\nMore text after"
|
||
|
|
result = NostrSender.convert_markdown_tables(text)
|
||
|
|
assert "Some intro" in result
|
||
|
|
assert "More text" in result
|
||
|
|
|
||
|
|
def test_multiple_tables(self):
|
||
|
|
from yuxi.channels.adapters.nostr.send import NostrSender
|
||
|
|
|
||
|
|
markdown = "| X | Y |\n| --- | --- |\n| 1 | 2 |\n\n| P | Q |\n| --- | --- |\n| 3 | 4 |\n"
|
||
|
|
result = NostrSender.convert_markdown_tables(markdown)
|
||
|
|
lines = result.strip().split("\n")
|
||
|
|
assert len(lines) >= 4
|
||
|
|
|
||
|
|
|
||
|
|
class TestNostrSenderSendProgress:
|
||
|
|
@pytest.fixture
|
||
|
|
def mock_components(self):
|
||
|
|
crypto = MagicMock()
|
||
|
|
crypto.build_and_sign_event.return_value = {
|
||
|
|
"id": "chunk_event",
|
||
|
|
"kind": 4,
|
||
|
|
"content": "chunk_content",
|
||
|
|
"tags": [],
|
||
|
|
"created_at": int(time.time()),
|
||
|
|
}
|
||
|
|
crypto.encrypt_nip17 = AsyncMock(return_value='{"id":"gw","kind":1059}')
|
||
|
|
crypto.encrypt_nip04.return_value = "encrypted_content"
|
||
|
|
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_long_message_chunked(self, mock_components):
|
||
|
|
crypto, relay_manager, config = mock_components
|
||
|
|
sender = NostrSender(crypto, relay_manager, config)
|
||
|
|
|
||
|
|
long_content = "A" * 5000
|
||
|
|
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=long_content)
|
||
|
|
result = await sender.send(response)
|
||
|
|
|
||
|
|
assert result.success is True
|
||
|
|
assert result.message_id is not None
|