ForcePilot/backend/test/unit/channels/test_nostr_send.py
Kris 3264900bc9 test: 新增多渠道单元测试用例并配置测试环境变量
新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块
同时在测试配置中添加了测试用的OpenAI API密钥环境变量
2026-05-12 00:56:47 +08:00

330 lines
11 KiB
Python

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