ForcePilot/backend/test/unit/channels/test_nostr_adapter_ext.py

693 lines
24 KiB
Python
Raw Normal View History

from __future__ import annotations
import asyncio
import json as _json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from yuxi.channels.adapters.nostr.adapter import NostrAdapter
from yuxi.channels.models import (
Attachment,
ChannelIdentity,
ChannelMessage,
ChannelResponse,
ChannelType,
ChatType,
DeliveryResult,
EventType,
MessageType,
)
class AsyncContextManagerMock:
def __init__(self, return_value=None):
self.return_value = return_value
async def __aenter__(self):
return self.return_value
async def __aexit__(self, *args, **kwargs):
pass
class TestNostrAdapterSendMethods:
@pytest.fixture
def adapter(self):
config = {"private_key": None, "relays": [], "dm_policy": "open"}
return NostrAdapter(config=config)
@pytest.mark.asyncio
async def test_send_no_sender(self, adapter):
identity = ChannelIdentity(
channel_id="nostr", channel_type=ChannelType.NOSTR,
channel_user_id="user", channel_chat_id="dm:receiver",
)
response = ChannelResponse(identity=identity, content="test")
result = await adapter.send(response)
assert result.success is False
assert "Sender 未初始化" in result.error
@pytest.mark.asyncio
async def test_send_with_mock_sender(self, adapter):
mock_sender = MagicMock()
mock_sender.send = AsyncMock(return_value=DeliveryResult(success=True, message_id="evt_id"))
adapter._sender = mock_sender
identity = ChannelIdentity(
channel_id="nostr", channel_type=ChannelType.NOSTR,
channel_user_id="user", channel_chat_id="dm:receiver",
)
response = ChannelResponse(identity=identity, content="hello")
result = await adapter.send(response)
assert result.success is True
assert result.message_id == "evt_id"
@pytest.mark.asyncio
async def test_send_media_no_sender(self, adapter):
result = await adapter.send_media("dm:receiver", "image", "http://img.url")
assert result.success is False
@pytest.mark.asyncio
async def test_edit_message_no_sender(self, adapter):
result = await adapter.edit_message("dm:receiver", "msg_001", "edited")
assert result.success is False
@pytest.mark.asyncio
async def test_delete_message_no_sender(self, adapter):
result = await adapter.delete_message("dm:receiver", "msg_001")
assert result.success is False
@pytest.mark.asyncio
async def test_send_reaction_no_sender(self, adapter):
result = await adapter.send_reaction("dm:receiver", "msg_001", "")
assert result.success is False
@pytest.mark.asyncio
async def test_remove_reaction_no_sender(self, adapter):
result = await adapter.remove_reaction("dm:receiver", "msg_001")
assert result.success is False
@pytest.mark.asyncio
async def test_send_chat_action_no_sender(self, adapter):
result = await adapter.send_chat_action("dm:receiver", "typing")
assert result.success is False
class TestNormalizeInboundExtended:
@pytest.fixture
def adapter(self):
config = {"private_key": None, "relays": [], "dm_policy": "open"}
return NostrAdapter(config=config)
def test_normalize_kind5_deletion(self, adapter):
raw = {
"id": "del_001",
"kind": 5,
"content": "",
"pubkey": "author_hex",
"tags": [
["e", "deleted_event_001"],
["e", "deleted_event_002"],
],
"created_at": 1715000000,
}
msg = adapter.normalize_inbound(raw)
assert msg.event_type == EventType.MESSAGE_DELETED
assert "deleted_event_001" in msg.content
def test_normalize_kind5_deletion_with_p_tag(self, adapter):
raw = {
"id": "del_dm",
"kind": 5,
"content": "",
"pubkey": "sender_hex",
"tags": [
["e", "evt_to_delete"],
["p", "receiver_hex"],
],
"created_at": 1715000000,
}
msg = adapter.normalize_inbound(raw)
assert msg.chat_type == ChatType.DIRECT
def test_normalize_kind7_reaction_removed_empty_content(self, adapter):
raw = {
"id": "react_rem_001",
"kind": 7,
"content": "",
"pubkey": "sender_hex",
"tags": [
["e", "original_event_id"],
],
"created_at": 1715000000,
}
msg = adapter.normalize_inbound(raw)
assert msg.event_type == EventType.REACTION_REMOVED
def test_normalize_kind7_reaction_removed_plus(self, adapter):
raw = {
"id": "react_rem_002",
"kind": 7,
"content": "+",
"pubkey": "sender_hex",
"tags": [
["e", "original_event_id"],
],
"created_at": 1715000000,
}
msg = adapter.normalize_inbound(raw)
assert msg.event_type == EventType.REACTION_REMOVED
def test_normalize_kind7_reaction_no_e_tag(self, adapter):
raw = {
"id": "react_no_e",
"kind": 7,
"content": "👍",
"pubkey": "sender_hex",
"tags": [],
"created_at": 1715000000,
}
msg = adapter.normalize_inbound(raw)
assert msg.event_type == EventType.REACTION_ADDED
def test_normalize_kind1059_direct(self, adapter):
raw = {
"id": "nip17_001",
"kind": 1059,
"content": "gift_wrapped_json",
"pubkey": "sender_hex",
"tags": [],
"created_at": 1715000000,
}
msg = adapter.normalize_inbound(raw)
assert msg.chat_type == ChatType.DIRECT
def test_normalize_edit_event(self, adapter):
raw = {
"id": "edit_001",
"kind": 1,
"content": "edited content",
"pubkey": "editor_hex",
"tags": [
["e", "original_event_id", "", "edit"],
],
"created_at": 1715000000,
}
msg = adapter.normalize_inbound(raw)
assert msg.event_type == EventType.MESSAGE_UPDATED
def test_normalize_kind1_group_multiple_p_tags(self, adapter):
raw = {
"id": "group_msg",
"kind": 1,
"content": "group chat message",
"pubkey": "sender_hex",
"tags": [
["p", "user1"],
["p", "user2"],
["e", "root_event", "", "root"],
],
"created_at": 1715000000,
}
msg = adapter.normalize_inbound(raw)
assert msg.chat_type == ChatType.GROUP
class TestExtractAttachments:
@pytest.fixture
def adapter(self):
config = {"private_key": None, "relays": [], "dm_policy": "open"}
return NostrAdapter(config=config)
def test_extract_url_tag_attachment(self, adapter):
raw = {
"id": "media_001",
"kind": 1,
"content": "check this image",
"pubkey": "sender_hex",
"tags": [
["url", "https://example.com/image.jpg"],
],
"created_at": 1715000000,
}
msg = adapter.normalize_inbound(raw)
assert len(msg.attachments) == 1
assert msg.attachments[0].type == "image"
def test_extract_imeta_tag_attachment(self, adapter):
raw = {
"id": "media_002",
"kind": 1,
"content": "",
"pubkey": "sender_hex",
"tags": [
["imeta", "https://example.com/photo.png"],
],
"created_at": 1715000000,
}
msg = adapter.normalize_inbound(raw)
assert len(msg.attachments) == 1
assert msg.attachments[0].type == "image"
def test_extract_content_url_fallback(self, adapter):
raw = {
"id": "media_003",
"kind": 1,
"content": "https://example.com/video.mp4",
"pubkey": "sender_hex",
"tags": [],
"created_at": 1715000000,
}
msg = adapter.normalize_inbound(raw)
assert len(msg.attachments) == 1
assert msg.attachments[0].type == "video"
def test_guess_media_type_audio(self, adapter):
assert adapter._guess_media_type("song.mp3") == "audio"
def test_guess_media_type_file(self, adapter):
assert adapter._guess_media_type("document.pdf") == "file"
def test_no_attachments(self, adapter):
raw = {
"id": "no_media",
"kind": 1,
"content": "plain text",
"pubkey": "sender_hex",
"tags": [],
"created_at": 1715000000,
}
msg = adapter.normalize_inbound(raw)
assert len(msg.attachments) == 0
class TestExtractMentions:
@pytest.fixture
def adapter(self):
config = {"private_key": None, "relays": [], "dm_policy": "open"}
return NostrAdapter(config=config)
def test_extract_mentions_hex(self, adapter):
hex_key = "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6"
mentions = adapter._extract_mentions(f"nostr:{hex_key}")
assert hex_key in mentions
def test_extract_mentions_no_match(self, adapter):
mentions = adapter._extract_mentions("hello world")
assert mentions == []
def test_extract_mentions_at_prefix(self, adapter):
mentions = adapter._extract_mentions("hello @npub1user")
assert len(mentions) == 0
class TestHealthCheckExtended:
@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_no_relays(self, adapter):
adapter._relay_manager = MagicMock()
adapter._relay_manager.active_count.return_value = (0, 0)
adapter._nostr_config = MagicMock()
adapter._nostr_config.relays = []
adapter._nostr_config.relay_degraded_threshold = 0.5
result = await adapter.health_check()
assert result.status == "unhealthy"
@pytest.mark.asyncio
async def test_health_check_degraded(self, adapter):
adapter._relay_manager = MagicMock()
adapter._relay_manager.active_count.return_value = (1, 10)
adapter._nostr_config = MagicMock()
adapter._nostr_config.relays = ["wss://r1"]
adapter._nostr_config.relay_degraded_threshold = 0.5
from yuxi.channels.models import HealthStatus
adapter._health_tracker.snapshot = MagicMock(return_value=MagicMock(url="wss://r1"))
result = await adapter.health_check()
assert result.status in ("degraded", "healthy")
@pytest.mark.asyncio
async def test_health_check_healthy(self, adapter):
adapter._relay_manager = MagicMock()
adapter._relay_manager.active_count.return_value = (3, 3)
adapter._nostr_config = MagicMock()
adapter._nostr_config.relays = ["wss://r1", "wss://r2", "wss://r3"]
adapter._nostr_config.relay_degraded_threshold = 0.5
adapter._crypto = MagicMock()
adapter._crypto.npub = "npub1test"
adapter._health_tracker.snapshot = MagicMock(return_value=MagicMock(url="wss://r1"))
result = await adapter.health_check()
assert result.status == "healthy"
class TestGetStatusSnapshot:
@pytest.fixture
def adapter(self):
config = {"private_key": None, "relays": [], "dm_policy": "open"}
return NostrAdapter(config=config)
def test_status_snapshot_no_manager(self, adapter):
snapshot = adapter.get_status_snapshot()
assert snapshot["active_relays"] == 0
assert snapshot["total_relays"] == 0
assert snapshot["inflight"] == 0
def test_status_snapshot_with_manager(self, adapter):
adapter._relay_manager = MagicMock()
adapter._relay_manager.active_count.return_value = (2, 4)
adapter._relay_manager._connections = {}
adapter._relay_manager._circuit_breakers = {}
adapter._nostr_config = MagicMock()
adapter._nostr_config.relays = []
adapter._crypto = MagicMock()
adapter._crypto.npub = "npub1test"
adapter._status = MagicMock()
adapter._status.value = "connected"
mock_snap = MagicMock()
mock_snap.score = 0.8
adapter._health_tracker.snapshot = MagicMock(return_value=mock_snap)
snapshot = adapter.get_status_snapshot()
assert snapshot["active_relays"] == 2
assert snapshot["total_relays"] == 4
class TestGetMetricsSnapshot:
@pytest.fixture
def adapter(self):
config = {"private_key": None, "relays": [], "dm_policy": "open"}
return NostrAdapter(config=config)
def test_metrics_snapshot(self, adapter):
snapshot = adapter.get_metrics_snapshot()
assert "counters" in snapshot
assert "gauges" in snapshot
assert "timestamp" in snapshot
class TestPreConnect:
@pytest.fixture
def adapter(self):
config = {"private_key": None, "relays": [], "dm_policy": "open"}
return NostrAdapter(config=config)
@pytest.mark.asyncio
async def test_pre_connect_missing_private_key(self, adapter):
with patch.object(adapter, "config", {}):
result = await adapter.pre_connect()
assert result["status"] == "error"
@pytest.mark.asyncio
async def test_pre_connect_with_private_key(self, adapter):
from yuxi.channels.adapters.nostr.crypto import NostrCrypto
temp_crypto = NostrCrypto()
nsec = temp_crypto.nsec()
with patch.object(adapter, "config", {"private_key": nsec, "relays": ["wss://test.relay"]}):
with patch("yuxi.channels.adapters.nostr.adapter.probe_relay") as mock_probe:
from yuxi.channels.adapters.nostr.probe import ProbeResult
mock_probe.return_value = ProbeResult(
url="wss://test.relay", connected=True, latency_ms=50.0
)
result = await adapter.pre_connect()
assert result["status"] == "ok"
assert result["npub"].startswith("npub1")
@pytest.mark.asyncio
async def test_pre_connect_invalid_key(self, adapter):
with patch.object(adapter, "config", {"private_key": "invalid_key_123", "relays": ["wss://test.relay"]}):
result = await adapter.pre_connect()
assert result["status"] == "error"
class TestResolveChatId:
@pytest.fixture
def adapter(self):
config = {"private_key": None, "relays": [], "dm_policy": "open"}
return NostrAdapter(config=config)
def test_resolve_chat_id_group_with_root(self, adapter):
chat_id = adapter._resolve_chat_id(
"group",
[["e", "root_id", "", "root"], ["e", "reply_id", "", "reply"]],
"pubkey",
)
assert chat_id == "channel:root_id"
def test_resolve_chat_id_group_without_root(self, adapter):
chat_id = adapter._resolve_chat_id(
"group",
[["e", "first_e_tag"]],
"pubkey",
)
assert chat_id == "channel:first_e_tag"
def test_resolve_chat_id_group_fallback(self, adapter):
chat_id = adapter._resolve_chat_id("group", [], "pubkey")
assert chat_id == "channel:pubkey"
def test_resolve_chat_id_direct(self, adapter):
chat_id = adapter._resolve_chat_id(
"direct",
[["p", "receiver_hex", "", "wss://relay"]],
"sender_hex",
)
assert chat_id == "dm:receiver_hex"
class TestExtractReplyTo:
@pytest.fixture
def adapter(self):
config = {"private_key": None, "relays": [], "dm_policy": "open"}
return NostrAdapter(config=config)
def test_extract_reply_to_with_reply_tag(self, adapter):
reply_to = adapter._extract_reply_to([
["e", "root_id", "", "root"],
["e", "parent_id", "", "reply"],
["e", "extra_id"],
])
assert reply_to == "extra_id"
def test_extract_reply_to_no_e_tags(self, adapter):
reply_to = adapter._extract_reply_to([["p", "user_hex"]])
assert reply_to is None
def test_extract_reply_to_only_root_and_reply(self, adapter):
reply_to = adapter._extract_reply_to([
["e", "root_id", "", "root"],
["e", "reply_id", "", "reply"],
])
assert reply_to == "reply_id"
class TestSendStreamChunk:
@pytest.fixture
def adapter(self):
config = {"private_key": None, "relays": [], "dm_policy": "open"}
return NostrAdapter(config=config)
@pytest.mark.asyncio
async def test_stream_chunk_off_mode_not_finished(self, adapter):
adapter._nostr_config = MagicMock()
adapter._nostr_config.streaming_mode = "off"
result = await adapter.send_stream_chunk("dm:receiver", "", "chunk", False)
assert result.success is True
assert result.message_id is None
@pytest.mark.asyncio
async def test_stream_chunk_block_mode_not_finished(self, adapter):
adapter._nostr_config = MagicMock()
adapter._nostr_config.streaming_mode = "block"
result = await adapter.send_stream_chunk("dm:receiver", "", "chunk", False)
assert result.success is True
assert result.message_id is None
@pytest.mark.asyncio
async def test_stream_chunk_off_mode_finished_no_sender(self, adapter):
adapter._nostr_config = MagicMock()
adapter._nostr_config.streaming_mode = "off"
result = await adapter.send_stream_chunk("dm:receiver", "", "final chunk", True)
assert result.success is False
@pytest.mark.asyncio
async def test_stream_chunk_progress_mode_not_finished_no_sender(self, adapter):
adapter._nostr_config = MagicMock()
adapter._nostr_config.streaming_mode = "progress"
result = await adapter.send_stream_chunk("dm:receiver", "", "chunk", False)
assert result.success is False
class TestDownloadMedia:
@pytest.fixture
def adapter(self):
config = {"private_key": None, "relays": [], "dm_policy": "open"}
return NostrAdapter(config=config)
@pytest.mark.asyncio
async def test_download_media_success(self, adapter):
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.content_length = 100
mock_resp.read = AsyncMock(return_value=b"fake_image_data")
mock_session = AsyncContextManagerMock()
mock_session.return_value = mock_session
mock_get_cm = AsyncContextManagerMock(return_value=mock_resp)
mock_session.get = MagicMock(return_value=mock_get_cm)
with patch("yuxi.channels.adapters.nostr.adapter.aiohttp.ClientSession", return_value=mock_session):
result = await adapter.download_media("https://example.com/img.jpg")
assert result == b"fake_image_data"
@pytest.mark.asyncio
async def test_download_media_too_large(self, adapter):
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.content_length = 50 * 1024 * 1024
mock_session = AsyncContextManagerMock()
mock_session.return_value = mock_session
mock_get_cm = AsyncContextManagerMock(return_value=mock_resp)
mock_session.get = MagicMock(return_value=mock_get_cm)
with patch("yuxi.channels.adapters.nostr.adapter.aiohttp.ClientSession", return_value=mock_session):
with pytest.raises(Exception):
await adapter.download_media("https://example.com/huge.jpg")
class TestListPeers:
@pytest.fixture
def adapter(self):
config = {"private_key": None, "relays": [], "dm_policy": "open"}
return NostrAdapter(config=config)
@pytest.mark.asyncio
async def test_list_peers_no_manager(self, adapter):
result = await adapter.list_peers()
assert result == []
@pytest.mark.asyncio
async def test_list_peers_with_manager(self, adapter):
adapter._relay_manager = MagicMock()
adapter._relay_manager.query = AsyncMock(return_value=[
{"tags": [["p", "pubkey_a"]], "pubkey": "own_pubkey_hex"},
])
adapter._crypto = MagicMock()
adapter._crypto.pubkey_hex.return_value = "own_pubkey_hex"
result = await adapter.list_peers()
assert isinstance(result, list)
class TestGetUserInfo:
@pytest.fixture
def adapter(self):
config = {"private_key": None, "relays": [], "dm_policy": "open"}
return NostrAdapter(config=config)
@pytest.mark.asyncio
async def test_get_user_info_no_manager(self, adapter):
result = await adapter.get_user_info("pubkey_hex")
assert result == {}
@pytest.mark.asyncio
async def test_get_user_info_with_metadata(self, adapter):
adapter._relay_manager = MagicMock()
metadata = _json.dumps({
"name": "Alice",
"display_name": "Alice W.",
"picture": "https://example.com/pic.jpg",
"about": "Hello World",
"nip05": "alice@example.com",
})
adapter._relay_manager.query = AsyncMock(return_value=[{
"content": metadata,
"pubkey": "pubkey_hex",
}])
result = await adapter.get_user_info("pubkey_hex")
assert result["name"] == "Alice"
assert result["nip05"] == "alice@example.com"
@pytest.mark.asyncio
async def test_get_user_info_query_fails(self, adapter):
adapter._relay_manager = MagicMock()
adapter._relay_manager.query = AsyncMock(side_effect=Exception("timeout"))
result = await adapter.get_user_info("pubkey_hex")
assert result == {"pubkey": "pubkey_hex"}
class TestResolveChatType:
@pytest.fixture
def adapter(self):
config = {"private_key": None, "relays": [], "dm_policy": "open"}
return NostrAdapter(config=config)
def test_kind4_direct(self, adapter):
assert adapter._resolve_chat_type(4, []) == "direct"
def test_kind1059_direct(self, adapter):
assert adapter._resolve_chat_type(1059, []) == "direct"
def test_kind5_direct_single_p(self, adapter):
assert adapter._resolve_chat_type(5, [["p", "single_pubkey"]]) == "direct"
def test_kind5_group_multiple_p(self, adapter):
assert adapter._resolve_chat_type(5, [["p", "p1"], ["p", "p2"]]) == "group"
def test_kind7_direct_single(self, adapter):
assert adapter._resolve_chat_type(7, [["e", "e1"], ["p", "p1"]]) == "direct"
def test_kind1_with_root_group(self, adapter):
assert adapter._resolve_chat_type(1, [["e", "root_id", "", "root"]]) == "group"
class TestBuildGuardPolicy:
def test_build_guard_policy(self, adapter=None):
from yuxi.channels.adapters.nostr.config import NostrConfig
cfg = NostrConfig()
result = NostrAdapter._build_guard_policy(cfg)
assert result.allowed_kinds == {1, 4, 5, 7, 1059}
assert result.max_ciphertext_bytes == 50_000
assert result.max_plaintext_bytes == 10_000
class TestNormalizeInboundReactions:
@pytest.fixture
def adapter(self):
config = {"private_key": None, "relays": [], "dm_policy": "open"}
return NostrAdapter(config=config)
def test_normalize_kind7_reaction_with_emoji_and_e_tag(self, adapter):
raw = {
"id": "reaction_emoji",
"kind": 7,
"content": "🎉",
"pubkey": "sender_hex",
"tags": [
["e", "original_event_id"],
["p", "author_pubkey"],
],
"created_at": 1715000000,
}
msg = adapter.normalize_inbound(raw)
assert msg.event_type == EventType.REACTION_ADDED
assert "🎉" in msg.content
class TestGetSendCache:
@pytest.fixture
def adapter(self):
config = {"private_key": None, "relays": [], "dm_policy": "open"}
return NostrAdapter(config=config)
def test_get_send_cache_empty(self, adapter):
cache = adapter.get_send_cache()
assert cache == []