新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
199 lines
6.7 KiB
Python
199 lines
6.7 KiB
Python
from __future__ import annotations
|
|
|
|
import time
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.nostr.adapter import NostrAdapter
|
|
from yuxi.channels.adapters.nostr.config import NostrConfig
|
|
from yuxi.channels.models import (
|
|
ChannelIdentity,
|
|
ChannelMessage,
|
|
ChannelResponse,
|
|
ChannelType,
|
|
ChatType,
|
|
EventType,
|
|
MessageType,
|
|
)
|
|
|
|
|
|
class TestNostrAdapterIntegration:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
config = {"private_key": None, "relays": ["wss://test.relay"], "dm_policy": "open"}
|
|
return NostrAdapter(config=config)
|
|
|
|
def test_capabilities_after_p0_calibration(self, adapter):
|
|
assert adapter.capabilities.media is False
|
|
assert adapter.capabilities.reactions is True
|
|
assert adapter.capabilities.edit is True
|
|
assert adapter.capabilities.unsend is True
|
|
assert adapter.capabilities.reply is True
|
|
|
|
def test_channel_meta(self, adapter):
|
|
assert adapter.meta.id == "nostr"
|
|
assert adapter.meta.label == "Nostr"
|
|
assert "NIP-04" in adapter.meta.blurb
|
|
|
|
def test_normalize_inbound_full_flow(self, adapter):
|
|
now = int(time.time())
|
|
raw = {
|
|
"id": "int_test_001",
|
|
"kind": 1,
|
|
"content": "integration test message",
|
|
"pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb",
|
|
"tags": [["e", "root_event", "", "root"]],
|
|
"created_at": now,
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert isinstance(msg, ChannelMessage)
|
|
assert msg.content == "integration test message"
|
|
assert msg.event_type == EventType.MESSAGE_RECEIVED
|
|
assert msg.chat_type == ChatType.GROUP
|
|
|
|
def test_normalize_kind1_direct(self, adapter):
|
|
raw = {
|
|
"id": "direct_001",
|
|
"kind": 1,
|
|
"content": "direct message",
|
|
"pubkey": "sender_hex_64",
|
|
"tags": [["p", "receiver_hex_64"]],
|
|
"created_at": int(time.time()),
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.chat_type == ChatType.DIRECT
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_relay_event_full_flow(self, adapter):
|
|
adapter._nostr_config = NostrConfig(relays=[])
|
|
crypto_mock = MagicMock()
|
|
crypto_mock.verify_event.return_value = True
|
|
crypto_mock.pubkey_hex.return_value = "own_pubkey_hex_64"
|
|
adapter._crypto = crypto_mock
|
|
|
|
guard_mock = MagicMock()
|
|
guard_mock.check.return_value = None
|
|
guard_mock.inflight = frozenset()
|
|
guard_mock.done_processing = MagicMock()
|
|
guard_mock._seen = MagicMock()
|
|
guard_mock._seen.__len__ = MagicMock(return_value=0)
|
|
adapter._guard = guard_mock
|
|
|
|
handle_called = False
|
|
async def fake_handle(msg):
|
|
nonlocal handle_called
|
|
handle_called = True
|
|
|
|
adapter._handle_message = fake_handle
|
|
|
|
now = int(time.time())
|
|
raw = {
|
|
"id": "handle_test_001",
|
|
"kind": 1,
|
|
"content": "test event",
|
|
"pubkey": "b" * 64,
|
|
"tags": [],
|
|
"created_at": now,
|
|
}
|
|
await adapter._handle_relay_event(raw)
|
|
assert handle_called is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_relay_event_rejects_invalid_sig(self, adapter):
|
|
adapter._nostr_config = NostrConfig(relays=[])
|
|
crypto_mock = MagicMock()
|
|
crypto_mock.verify_event.return_value = False
|
|
adapter._crypto = crypto_mock
|
|
|
|
handle_called = False
|
|
adapter._handle_message = AsyncMock()
|
|
adapter._handle_message.side_effect = lambda _: setattr(
|
|
type(self), "_flag", True
|
|
)
|
|
|
|
raw = {"id": "bad_sig_event", "kind": 1, "content": "bad"}
|
|
await adapter._handle_relay_event(raw)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_relay_event_nip17_decryption(self, adapter):
|
|
adapter._nostr_config = NostrConfig(relays=[])
|
|
crypto_mock = MagicMock()
|
|
crypto_mock.verify_event.return_value = True
|
|
crypto_mock.pubkey_hex.return_value = "own_pubkey_hex_64"
|
|
crypto_mock.decrypt_nip17 = AsyncMock(return_value="decrypted nip17 content")
|
|
adapter._crypto = crypto_mock
|
|
|
|
guard_mock = MagicMock()
|
|
guard_mock.check.return_value = None
|
|
guard_mock.inflight = frozenset()
|
|
guard_mock.done_processing = MagicMock()
|
|
guard_mock._seen = MagicMock()
|
|
guard_mock._seen.__len__ = MagicMock(return_value=0)
|
|
adapter._guard = guard_mock
|
|
|
|
received_msg = None
|
|
|
|
async def capture_handle(msg):
|
|
nonlocal received_msg
|
|
received_msg = msg
|
|
|
|
adapter._handle_message = capture_handle
|
|
|
|
now = int(time.time())
|
|
raw = {
|
|
"id": "nip17_test_001",
|
|
"kind": 1059,
|
|
"content": "encrypted_wrapped_json",
|
|
"pubkey": "b" * 64,
|
|
"tags": [],
|
|
"created_at": now,
|
|
}
|
|
await adapter._handle_relay_event(raw)
|
|
|
|
assert received_msg is not None
|
|
assert received_msg.content == "decrypted nip17 content"
|
|
|
|
def test_resolve_state_dir(self, adapter):
|
|
state_dir = adapter._resolve_state_dir()
|
|
assert ".forcepilot" in state_dir
|
|
assert "nostr" in state_dir
|
|
|
|
|
|
class TestNostrIntegrationMediaCalibration:
|
|
def test_send_media_returns_error_when_no_sender(self):
|
|
adapter = NostrAdapter(config={"private_key": None, "relays": []})
|
|
from yuxi.channels.adapters.nostr.adapter import DeliveryResult
|
|
|
|
result = asyncio_get(adapter.send_media("dm:test", "image", "http://example.com/img.jpg"))
|
|
assert result.success is False
|
|
|
|
def test_download_media_returns_bytes(self):
|
|
adapter = NostrAdapter(config={"private_key": None, "relays": []})
|
|
import aiohttp
|
|
|
|
with patch("aiohttp.ClientSession.get") as mock_get:
|
|
mock_resp = MagicMock()
|
|
mock_resp.status = 200
|
|
mock_resp.content_length = 100
|
|
mock_resp.read = AsyncMock(return_value=b"fake_image_data")
|
|
mock_get.return_value.__aenter__ = AsyncMock(return_value=mock_resp)
|
|
mock_get.return_value.__aexit__ = AsyncMock()
|
|
|
|
result = asyncio_get(adapter.download_media("http://example.com/img.jpg"))
|
|
assert result == b"fake_image_data"
|
|
|
|
|
|
def asyncio_get(coro):
|
|
import asyncio
|
|
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
return loop.run_until_complete(coro)
|
|
import concurrent.futures
|
|
|
|
future = asyncio.run_coroutine_threadsafe(coro, loop)
|
|
return future.result(timeout=5) |