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 == []