新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
494 lines
17 KiB
Python
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"] |