693 lines
24 KiB
Python
693 lines
24 KiB
Python
|
|
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 == []
|