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

494 lines
17 KiB
Python

from __future__ import annotations
import time
from unittest.mock import MagicMock, patch
import pytest
from yuxi.channels.adapters.nostr.adapter import NostrAdapter
from yuxi.channels.adapters.nostr.config import NostrConfig, GuardPolicyConfig
from yuxi.channels.adapters.nostr.guard import GuardPolicy, NostrGuard, RateLimiter, SeenTracker
from yuxi.channels.adapters.nostr.health import RelayHealthTracker
from yuxi.channels.adapters.nostr.relay_manager import RelayManager
from yuxi.channels.models import (
ChannelIdentity,
ChannelMessage,
ChannelResponse,
ChannelType,
ChatType,
EventType,
MessageType,
)
class TestNostrConfig:
def test_from_empty_dict(self):
cfg = NostrConfig.from_dict(None)
assert cfg.private_key == ""
assert len(cfg.relays) >= 4
assert cfg.dm_policy == "pairing"
def test_from_dict_defaults(self):
cfg = NostrConfig.from_dict({})
assert cfg.streaming_mode == "block"
assert cfg.nip17_enabled is True
def test_from_dict_with_accounts(self):
cfg = NostrConfig.from_dict({
"accounts": {
"default": {
"private_key": "nsec1test",
"relays": ["wss://test.relay"],
"dm_policy": "open",
}
}
})
assert cfg.private_key == "nsec1test"
assert len(cfg.relays) == 1
assert cfg.relays[0] == "wss://test.relay"
assert cfg.dm_policy == "open"
def test_from_flat_dict(self):
cfg = NostrConfig.from_dict({
"private_key": "hex_key_123",
"relays": ["wss://a.relay", "wss://b.relay"],
})
assert cfg.private_key == "hex_key_123"
assert len(cfg.relays) == 2
class TestNostrAdapterBasic:
@pytest.fixture
def adapter(self):
config = {
"private_key": None,
"relays": [],
"dm_policy": "open",
}
return NostrAdapter(config=config)
def test_channel_id(self, adapter):
assert adapter.channel_id == "nostr"
def test_channel_type(self, adapter):
assert adapter.channel_type == ChannelType.NOSTR
def test_capabilities(self, adapter):
assert adapter.supports_streaming is True
assert adapter.streaming_modes == ["off", "block", "progress"]
assert adapter.max_media_size_mb == 10
assert adapter.supports_markdown is False
class TestNormalizeInbound:
@pytest.fixture
def adapter(self):
config = {"private_key": None, "relays": [], "dm_policy": "open"}
return NostrAdapter(config=config)
def test_normalize_kind1_text_note(self, adapter):
raw = {
"id": "evt001",
"kind": 1,
"content": "hello nostr",
"pubkey": "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6",
"tags": [],
"created_at": 1715000000,
"sig": "sig_data",
}
msg = adapter.normalize_inbound(raw)
assert isinstance(msg, ChannelMessage)
assert msg.content == "hello nostr"
assert msg.identity.channel_user_id == raw["pubkey"]
assert msg.identity.channel_message_id == "evt001"
assert msg.message_type == MessageType.TEXT
def test_normalize_kind1_group_chat(self, adapter):
raw = {
"id": "evt002",
"kind": 1,
"content": "hello group",
"pubkey": "abc123",
"tags": [
["e", "root_event_001", "", "root"],
],
"created_at": 1715000000,
}
msg = adapter.normalize_inbound(raw)
assert msg.chat_type == ChatType.GROUP
assert "channel:root_event_001" in msg.identity.channel_chat_id
def test_normalize_kind4_direct_message(self, adapter):
raw = {
"id": "dm001",
"kind": 4,
"content": "encrypted_content_here",
"pubkey": "sender_pubkey_hex",
"tags": [
["p", "receiver_pubkey_hex"],
],
"created_at": 1715000000,
}
msg = adapter.normalize_inbound(raw)
assert msg.chat_type == ChatType.DIRECT
assert "dm:receiver_pubkey_hex" in msg.identity.channel_chat_id
def test_normalize_kind1_direct_with_p_tag(self, adapter):
raw = {
"id": "direct_text",
"kind": 1,
"content": "direct message via kind1",
"pubkey": "sender_hex",
"tags": [
["p", "receiver_hex"],
],
"created_at": 1715000000,
}
msg = adapter.normalize_inbound(raw)
assert msg.chat_type == ChatType.DIRECT
def test_normalize_with_reply_tags(self, adapter):
raw = {
"id": "reply_msg",
"kind": 1,
"content": "this is a reply",
"pubkey": "pub_hex",
"tags": [
["e", "parent_id_001"],
],
"created_at": 1715000000,
}
msg = adapter.normalize_inbound(raw)
assert msg.reply_to_message_id == "parent_id_001"
def test_normalize_with_nip10_reply_chain(self, adapter):
raw = {
"id": "nip10_reply",
"kind": 1,
"content": "nip10 style reply",
"pubkey": "pub_hex",
"tags": [
["e", "root_id", "", "root"],
["e", "parent_id", "", "reply"],
["e", "extra_ref"],
],
"created_at": 1715000000,
}
msg = adapter.normalize_inbound(raw)
assert msg.reply_to_message_id == "extra_ref"
def test_resolve_chat_id_direct(self, adapter):
raw = {
"id": "test_chat_id",
"kind": 4,
"content": "enc",
"pubkey": "sender_hex",
"tags": [["p", "receiver_hex"]],
"created_at": 1715000000,
}
msg = adapter.normalize_inbound(raw)
assert msg.identity.channel_chat_id == "dm:receiver_hex"
class TestFormatOutbound:
@pytest.fixture
def adapter(self):
config = {"private_key": None, "relays": [], "dm_policy": "open"}
return NostrAdapter(config=config)
def test_format_outbound_returns_dict(self, adapter):
identity = ChannelIdentity(
channel_id="nostr",
channel_type=ChannelType.NOSTR,
channel_user_id="user_hex",
channel_chat_id="dm:receiver_hex",
)
response = ChannelResponse(identity=identity, content="test reply")
result = adapter.format_outbound(response)
assert isinstance(result, dict)
assert result["content"] == "test reply"
assert result["identity"]["channel_id"] == "nostr"
class TestHealthCheck:
@pytest.fixture
def adapter(self):
config = {"private_key": None, "relays": [], "dm_policy": "open"}
return NostrAdapter(config=config)
@pytest.mark.asyncio
async def test_health_check_unhealthy_no_manager(self, adapter):
result = await adapter.health_check()
assert result.status == "unhealthy"
class TestSeenTracker:
def test_is_seen_new_event(self):
tracker = SeenTracker(max_size=100)
assert tracker.is_seen("event_001") is False
def test_mark_and_is_seen(self):
tracker = SeenTracker(max_size=100)
tracker.mark_seen("event_001")
assert tracker.is_seen("event_001") is True
def test_size_eviction(self):
tracker = SeenTracker(max_size=3)
for i in range(5):
tracker.mark_seen(f"event_{i}")
assert len(tracker) == 3
def test_is_seen_after_ttl(self):
tracker = SeenTracker(max_size=100, ttl_sec=0)
tracker.mark_seen("event_001")
assert tracker.is_seen("event_001") is False
class TestRateLimiter:
def test_within_limit(self):
limiter = RateLimiter(window_ms=10_000, max_per_sender=5, max_global=20)
for _ in range(3):
assert limiter.check_and_record("pubkey_a") is True
def test_sender_limit_exceeded(self):
limiter = RateLimiter(window_ms=60_000, max_per_sender=2, max_global=20)
assert limiter.check_and_record("pubkey_a") is True
assert limiter.check_and_record("pubkey_a") is True
assert limiter.check_and_record("pubkey_a") is False
def test_global_limit_exceeded(self):
limiter = RateLimiter(window_ms=60_000, max_per_sender=10, max_global=2)
assert limiter.check_and_record("pubkey_a") is True
assert limiter.check_and_record("pubkey_b") is True
assert limiter.check_and_record("pubkey_c") is False
def test_separate_senders(self):
limiter = RateLimiter(window_ms=60_000, max_per_sender=1, max_global=20)
assert limiter.check_and_record("pubkey_a") is True
assert limiter.check_and_record("pubkey_a") is False
assert limiter.check_and_record("pubkey_b") is True
class TestNostrGuard:
@pytest.fixture
def guard(self):
return NostrGuard(own_pubkey="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
def test_pass_valid_event(self, guard):
now = int(time.time())
event = {
"id": "valid_event_001",
"kind": 1,
"content": "hello",
"pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"created_at": now,
}
assert guard.check(event) is None
def test_reject_self_message(self, guard):
now = int(time.time())
event = {
"id": "self_event",
"kind": 1,
"content": "echo",
"pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"created_at": now,
}
assert guard.check(event) == "self-message (echo)"
def test_reject_duplicate(self, guard):
now = int(time.time())
event = {
"id": "dup_event",
"kind": 1,
"content": "hello",
"pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"created_at": now,
}
assert guard.check(event) is None
assert guard.check(event) == "duplicate event"
def test_reject_disallowed_kind(self, guard):
now = int(time.time())
event = {
"id": "bad_kind",
"kind": 999,
"content": "hello",
"pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"created_at": now,
}
assert guard.check(event) == "disallowed kind: 999"
def test_reject_future_event(self, guard):
future = int(time.time()) + 3600
event = {
"id": "future_event",
"kind": 1,
"content": "from the future",
"pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"created_at": future,
}
reason = guard.check(event)
assert reason is not None
assert "future event" in reason
def test_reject_oversized_content(self, guard):
guard.policy.max_ciphertext_bytes = 10
now = int(time.time())
event = {
"id": "big_event",
"kind": 4,
"content": "x" * 100,
"pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"created_at": now,
}
assert guard.check(event) == "ciphertext too large: 100 > 10"
class TestRelayHealthTracker:
def test_default_score(self):
tracker = RelayHealthTracker()
assert tracker.get_score("wss://relay.example.com") == 0.5
def test_score_increases_with_success(self):
tracker = RelayHealthTracker()
tracker.record_success("wss://relay.example.com", latency_ms=100)
score = tracker.get_score("wss://relay.example.com")
assert score > 0.5
def test_score_decreases_with_failure(self):
tracker = RelayHealthTracker()
tracker.record_success("wss://relay.example.com", latency_ms=100)
good_score = tracker.get_score("wss://relay.example.com")
for _ in range(10):
tracker.record_failure("wss://relay.example.com")
bad_score = tracker.get_score("wss://relay.example.com")
assert bad_score < good_score
def test_sorted_relays(self):
tracker = RelayHealthTracker()
tracker.record_success("wss://good.relay", latency_ms=50)
tracker.record_success("wss://good.relay", latency_ms=60)
tracker.record_failure("wss://bad.relay")
tracker.record_failure("wss://bad.relay")
tracker.record_success("wss://bad.relay", latency_ms=500)
sorted_relays = tracker.sorted_relays(["wss://good.relay", "wss://bad.relay"])
assert sorted_relays[0] == "wss://good.relay"
def test_snapshot(self):
tracker = RelayHealthTracker()
tracker.record_success("wss://relay.example.com", latency_ms=200)
snapshot = tracker.snapshot("wss://relay.example.com")
assert snapshot.url == "wss://relay.example.com"
assert snapshot.total_successes == 1
assert snapshot.total_failures == 0
assert snapshot.avg_latency_ms == 200.0
class TestNostrConfigNew:
def test_from_dict_with_guard_policy(self):
cfg = NostrConfig.from_dict({
"accounts": {
"default": {
"private_key": "nsec1guardtest",
"guard_policy": {
"allowed_kinds": [1, 4],
"max_ciphertext_bytes": 20000,
"max_future_skew_sec": 60,
"rate_limit_window_ms": 5000,
"rate_limit_max_per_sender_per_window": 10,
}
}
}
})
assert cfg.guard_policy.allowed_kinds == [1, 4]
assert cfg.guard_policy.max_ciphertext_bytes == 20000
assert cfg.guard_policy.max_future_skew_sec == 60
assert cfg.guard_policy.rate_limit.window_ms == 5000
assert cfg.guard_policy.rate_limit.max_per_sender_per_window == 10
def test_from_dict_nip42_auth(self):
cfg = NostrConfig.from_dict({
"accounts": {
"default": {
"private_key": "nsec1test",
"nip42_auth_enabled": True,
"nip42_auth_urls": ["wss://auth.relay"],
"backfill_window_sec": 300,
}
}
})
assert cfg.nip42_auth_enabled is True
assert cfg.nip42_auth_urls == ["wss://auth.relay"]
assert cfg.backfill_window_sec == 300
class TestNormalizeInboundKind7:
@pytest.fixture
def adapter(self):
config = {"private_key": None, "relays": [], "dm_policy": "open"}
return NostrAdapter(config=config)
def test_normalize_kind7_reaction(self, adapter):
raw = {
"id": "reaction_001",
"kind": 7,
"content": "",
"pubkey": "sender_hex",
"tags": [
["e", "original_event_id"],
["p", "author_pubkey"],
],
"created_at": 1715000000,
}
msg = adapter.normalize_inbound(raw)
assert msg.content == "reacted with ❤"
assert msg.event_type == EventType.REACTION_ADDED
class TestCapabilitiesAndMeta:
def test_capabilities_reactions(self):
assert NostrAdapter.capabilities.reactions is True
def test_capabilities_edit(self):
assert NostrAdapter.capabilities.edit is True
def test_capabilities_unsend(self):
assert NostrAdapter.capabilities.unsend is True
def test_capabilities_reply(self):
assert NostrAdapter.capabilities.reply is True
def test_capabilities_media(self):
assert NostrAdapter.capabilities.media is False
def test_meta_selection_label(self):
assert NostrAdapter.meta.selection_label == "Nostr (Decentralized)"
def test_meta_blurb(self):
assert "NIP-04" in NostrAdapter.meta.blurb
def test_meta_docs_path(self):
assert NostrAdapter.meta.docs_path == "/docs/channels/nostr"
def test_meta_order(self):
assert NostrAdapter.meta.order == 60
class TestRelayManagerPerRelayCB:
def test_per_relay_circuit_breakers_created(self):
urls = ["wss://relay1.example.com", "wss://relay2.example.com"]
manager = RelayManager(urls)
assert len(manager._circuit_breakers) == 2
for url in urls:
assert manager.get_circuit_breaker(url) is not None
def test_set_pubkey_filter(self):
urls = ["wss://relay1.example.com"]
manager = RelayManager(urls)
manager.set_pubkey_filter(["abc123"])
filters = manager._build_subscription_filters(
[{"kinds": [1, 4], "since": 1000}]
)
assert "#p" in filters[0]
assert filters[0]["#p"] == ["abc123"]