新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
246 lines
8.4 KiB
Python
246 lines
8.4 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
class TestEventVerification:
|
|
def test_verify_event_valid(self):
|
|
from yuxi.channels.adapters.nostr.crypto import NostrCrypto
|
|
|
|
with patch("yuxi.channels.adapters.nostr.crypto.Keys") as mk:
|
|
mk.parse.return_value = _make_mock_keys()
|
|
crypto = NostrCrypto("nsec1test")
|
|
|
|
raw = {
|
|
"id": "a" * 64,
|
|
"pubkey": "b" * 64,
|
|
"kind": 1,
|
|
"content": "hello",
|
|
"tags": [],
|
|
"created_at": int(time.time()),
|
|
"sig": "c" * 128,
|
|
}
|
|
|
|
with patch("yuxi.channels.adapters.nostr.crypto.Event") as me:
|
|
mock_event = MagicMock()
|
|
mock_event.verify.return_value = None
|
|
me.from_json.return_value = mock_event
|
|
assert crypto.verify_event(raw) is True
|
|
|
|
def test_verify_event_invalid_rejected(self):
|
|
from yuxi.channels.adapters.nostr.crypto import NostrCrypto
|
|
|
|
with patch("yuxi.channels.adapters.nostr.crypto.Keys") as mk:
|
|
mk.parse.return_value = _make_mock_keys()
|
|
crypto = NostrCrypto("nsec1test")
|
|
|
|
raw = {"id": "x" * 64, "pubkey": "y" * 64}
|
|
|
|
with patch("yuxi.channels.adapters.nostr.crypto.Event") as me:
|
|
mock_event = MagicMock()
|
|
mock_event.verify.side_effect = Exception("bad sig")
|
|
me.from_json.return_value = mock_event
|
|
assert crypto.verify_event(raw) is False
|
|
|
|
def test_verify_event_strict_id_mismatch_rejected(self):
|
|
from yuxi.channels.adapters.nostr.crypto import NostrCrypto
|
|
|
|
with patch("yuxi.channels.adapters.nostr.crypto.Keys") as mk:
|
|
mk.parse.return_value = _make_mock_keys()
|
|
crypto = NostrCrypto("nsec1test")
|
|
|
|
raw = {"id": "wrong_id", "pubkey": "b" * 64}
|
|
|
|
with patch("yuxi.channels.adapters.nostr.crypto.Event") as me:
|
|
mock_event = MagicMock()
|
|
mock_event.id.return_value.to_hex.return_value = "real_id"
|
|
mock_event.id.return_value.to_bech32.return_value = "note1real"
|
|
me.from_json.return_value = mock_event
|
|
|
|
assert crypto.verify_event_strict(raw) is False
|
|
|
|
def test_verify_event_strict_passes_with_matching_id(self):
|
|
from yuxi.channels.adapters.nostr.crypto import NostrCrypto
|
|
|
|
with patch("yuxi.channels.adapters.nostr.crypto.Keys") as mk:
|
|
mk.parse.return_value = _make_mock_keys()
|
|
crypto = NostrCrypto("nsec1test")
|
|
|
|
event_id = "a" * 64
|
|
raw = {"id": event_id, "pubkey": "b" * 64}
|
|
|
|
with patch("yuxi.channels.adapters.nostr.crypto.Event") as me:
|
|
mock_event = MagicMock()
|
|
mock_event.id.return_value.to_hex.return_value = event_id
|
|
mock_event.id.return_value.to_bech32.return_value = "note1other"
|
|
mock_event.verify.return_value = None
|
|
me.from_json.return_value = mock_event
|
|
|
|
assert crypto.verify_event_strict(raw) is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_adapter_rejects_invalid_event(self):
|
|
from yuxi.channels.adapters.nostr.adapter import NostrAdapter
|
|
from yuxi.channels.adapters.nostr.config import NostrConfig
|
|
|
|
adapter = NostrAdapter(config={"private_key": None, "relays": []})
|
|
adapter._nostr_config = NostrConfig(relays=[])
|
|
|
|
crypto_mock = MagicMock()
|
|
crypto_mock.verify_event.return_value = False
|
|
adapter._crypto = crypto_mock
|
|
|
|
raw = {"id": "fake_id_123"}
|
|
await adapter._handle_relay_event(raw)
|
|
|
|
|
|
class TestMultiRelayQuery:
|
|
@pytest.mark.asyncio
|
|
async def test_query_aggregates_multiple_relays(self):
|
|
from yuxi.channels.adapters.nostr.relay_manager import RelayManager
|
|
|
|
mgr = RelayManager(relay_urls=["wss://a.relay", "wss://b.relay"])
|
|
|
|
ws_a = AsyncMock()
|
|
ws_a.open = True
|
|
ws_a.send = AsyncMock()
|
|
|
|
ws_b = AsyncMock()
|
|
ws_b.open = True
|
|
ws_b.send = AsyncMock()
|
|
|
|
mgr._connections = {"wss://a.relay": ws_a, "wss://b.relay": ws_b}
|
|
|
|
event_a = json.dumps(
|
|
[
|
|
"EVENT",
|
|
"sub",
|
|
{"id": "evt_a", "pubkey": "p1", "kind": 1, "content": "from a", "tags": [], "created_at": 1},
|
|
]
|
|
)
|
|
eose = json.dumps(["EOSE", "sub"])
|
|
event_b = json.dumps(
|
|
[
|
|
"EVENT",
|
|
"sub",
|
|
{"id": "evt_b", "pubkey": "p2", "kind": 1, "content": "from b", "tags": [], "created_at": 2},
|
|
]
|
|
)
|
|
|
|
ws_a.recv = AsyncMock(side_effect=[event_a, eose, TimeoutError()])
|
|
ws_b.recv = AsyncMock(side_effect=[event_b, eose, TimeoutError()])
|
|
|
|
results = await mgr.query([{"kinds": [1]}], timeout=1.0)
|
|
assert len(results) == 2
|
|
result_ids = {r["id"] for r in results}
|
|
assert result_ids == {"evt_a", "evt_b"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_dedup_across_relays(self):
|
|
from yuxi.channels.adapters.nostr.relay_manager import RelayManager
|
|
|
|
mgr = RelayManager(relay_urls=["wss://a.relay", "wss://b.relay"])
|
|
|
|
ws_a = AsyncMock()
|
|
ws_a.open = True
|
|
ws_a.send = AsyncMock()
|
|
ws_b = AsyncMock()
|
|
ws_b.open = True
|
|
ws_b.send = AsyncMock()
|
|
mgr._connections = {"wss://a.relay": ws_a, "wss://b.relay": ws_b}
|
|
|
|
dup_event = json.dumps(
|
|
["EVENT", "sub", {"id": "duplicate", "kind": 1, "content": "dup", "tags": [], "created_at": 1}]
|
|
)
|
|
eose = json.dumps(["EOSE", "sub"])
|
|
|
|
ws_a.recv = AsyncMock(side_effect=[dup_event, eose, TimeoutError()])
|
|
ws_b.recv = AsyncMock(side_effect=[dup_event, eose, TimeoutError()])
|
|
|
|
results = await mgr.query([{"kinds": [1]}], timeout=1.0)
|
|
assert len(results) == 1
|
|
assert results[0]["id"] == "duplicate"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_no_active_connections_returns_empty(self):
|
|
from yuxi.channels.adapters.nostr.relay_manager import RelayManager
|
|
|
|
mgr = RelayManager(relay_urls=["wss://a.relay"])
|
|
mgr._connections = {}
|
|
results = await mgr.query([{"kinds": [1]}])
|
|
assert results == []
|
|
|
|
|
|
class TestPersistentCursor:
|
|
@pytest.mark.asyncio
|
|
async def test_subscribe_first_call_uses_24h_ago(self):
|
|
from yuxi.channels.adapters.nostr.monitor import NostrMonitor
|
|
|
|
crypto_mock = MagicMock()
|
|
relay_mgr = MagicMock()
|
|
relay_mgr.subscribe = AsyncMock()
|
|
|
|
monitor = NostrMonitor(crypto_mock, relay_mgr)
|
|
now = int(time.time())
|
|
|
|
with patch("time.time", return_value=now):
|
|
await monitor.subscribe("pairing")
|
|
|
|
relay_mgr.subscribe.assert_called_once()
|
|
filters = relay_mgr.subscribe.call_args[0][0]
|
|
since = filters[0]["since"]
|
|
assert now - 86400 <= since <= now
|
|
assert monitor._last_subscribe_ts == now
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subscribe_second_call_uses_previous_ts(self):
|
|
from yuxi.channels.adapters.nostr.monitor import NostrMonitor
|
|
|
|
crypto_mock = MagicMock()
|
|
relay_mgr = MagicMock()
|
|
relay_mgr.subscribe = AsyncMock()
|
|
|
|
monitor = NostrMonitor(crypto_mock, relay_mgr)
|
|
monitor._last_subscribe_ts = 1000
|
|
|
|
now = 5000
|
|
with patch("time.time", return_value=now):
|
|
await monitor.subscribe("pairing")
|
|
|
|
filters = relay_mgr.subscribe.call_args[0][0]
|
|
assert filters[0]["since"] == 1000
|
|
assert monitor._last_subscribe_ts == now
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subscribe_updates_timestamp_each_call(self):
|
|
from yuxi.channels.adapters.nostr.monitor import NostrMonitor
|
|
|
|
crypto_mock = MagicMock()
|
|
relay_mgr = MagicMock()
|
|
relay_mgr.subscribe = AsyncMock()
|
|
|
|
monitor = NostrMonitor(crypto_mock, relay_mgr)
|
|
|
|
with patch("time.time", return_value=1000):
|
|
await monitor.subscribe("pairing")
|
|
assert monitor._last_subscribe_ts == 1000
|
|
|
|
with patch("time.time", return_value=5000):
|
|
await monitor.subscribe("pairing")
|
|
assert monitor._last_subscribe_ts == 5000
|
|
|
|
|
|
def _make_mock_keys():
|
|
mk = MagicMock()
|
|
mpk = MagicMock()
|
|
mpk.to_bech32.return_value = "npub1test"
|
|
mpk.to_hex.return_value = "a" * 64
|
|
msk = MagicMock()
|
|
msk.to_bech32.return_value = "nsec1test"
|
|
mk.public_key.return_value = mpk
|
|
mk.secret_key.return_value = msk
|
|
return mk |