from __future__ import annotations import time from unittest.mock import AsyncMock, MagicMock, patch import pytest from yuxi.channels.adapters.nostr.adapter import NostrAdapter from yuxi.channels.adapters.nostr.config import NostrConfig from yuxi.channels.models import ( ChannelIdentity, ChannelMessage, ChannelResponse, ChannelType, ChatType, EventType, MessageType, ) class TestNostrAdapterIntegration: @pytest.fixture def adapter(self): config = {"private_key": None, "relays": ["wss://test.relay"], "dm_policy": "open"} return NostrAdapter(config=config) def test_capabilities_after_p0_calibration(self, adapter): assert adapter.capabilities.media is False assert adapter.capabilities.reactions is True assert adapter.capabilities.edit is True assert adapter.capabilities.unsend is True assert adapter.capabilities.reply is True def test_channel_meta(self, adapter): assert adapter.meta.id == "nostr" assert adapter.meta.label == "Nostr" assert "NIP-04" in adapter.meta.blurb def test_normalize_inbound_full_flow(self, adapter): now = int(time.time()) raw = { "id": "int_test_001", "kind": 1, "content": "integration test message", "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb", "tags": [["e", "root_event", "", "root"]], "created_at": now, } msg = adapter.normalize_inbound(raw) assert isinstance(msg, ChannelMessage) assert msg.content == "integration test message" assert msg.event_type == EventType.MESSAGE_RECEIVED assert msg.chat_type == ChatType.GROUP def test_normalize_kind1_direct(self, adapter): raw = { "id": "direct_001", "kind": 1, "content": "direct message", "pubkey": "sender_hex_64", "tags": [["p", "receiver_hex_64"]], "created_at": int(time.time()), } msg = adapter.normalize_inbound(raw) assert msg.chat_type == ChatType.DIRECT @pytest.mark.asyncio async def test_handle_relay_event_full_flow(self, adapter): adapter._nostr_config = NostrConfig(relays=[]) crypto_mock = MagicMock() crypto_mock.verify_event.return_value = True crypto_mock.pubkey_hex.return_value = "own_pubkey_hex_64" adapter._crypto = crypto_mock guard_mock = MagicMock() guard_mock.check.return_value = None guard_mock.inflight = frozenset() guard_mock.done_processing = MagicMock() guard_mock._seen = MagicMock() guard_mock._seen.__len__ = MagicMock(return_value=0) adapter._guard = guard_mock handle_called = False async def fake_handle(msg): nonlocal handle_called handle_called = True adapter._handle_message = fake_handle now = int(time.time()) raw = { "id": "handle_test_001", "kind": 1, "content": "test event", "pubkey": "b" * 64, "tags": [], "created_at": now, } await adapter._handle_relay_event(raw) assert handle_called is True @pytest.mark.asyncio async def test_handle_relay_event_rejects_invalid_sig(self, adapter): adapter._nostr_config = NostrConfig(relays=[]) crypto_mock = MagicMock() crypto_mock.verify_event.return_value = False adapter._crypto = crypto_mock handle_called = False adapter._handle_message = AsyncMock() adapter._handle_message.side_effect = lambda _: setattr( type(self), "_flag", True ) raw = {"id": "bad_sig_event", "kind": 1, "content": "bad"} await adapter._handle_relay_event(raw) @pytest.mark.asyncio async def test_handle_relay_event_nip17_decryption(self, adapter): adapter._nostr_config = NostrConfig(relays=[]) crypto_mock = MagicMock() crypto_mock.verify_event.return_value = True crypto_mock.pubkey_hex.return_value = "own_pubkey_hex_64" crypto_mock.decrypt_nip17 = AsyncMock(return_value="decrypted nip17 content") adapter._crypto = crypto_mock guard_mock = MagicMock() guard_mock.check.return_value = None guard_mock.inflight = frozenset() guard_mock.done_processing = MagicMock() guard_mock._seen = MagicMock() guard_mock._seen.__len__ = MagicMock(return_value=0) adapter._guard = guard_mock received_msg = None async def capture_handle(msg): nonlocal received_msg received_msg = msg adapter._handle_message = capture_handle now = int(time.time()) raw = { "id": "nip17_test_001", "kind": 1059, "content": "encrypted_wrapped_json", "pubkey": "b" * 64, "tags": [], "created_at": now, } await adapter._handle_relay_event(raw) assert received_msg is not None assert received_msg.content == "decrypted nip17 content" def test_resolve_state_dir(self, adapter): state_dir = adapter._resolve_state_dir() assert ".forcepilot" in state_dir assert "nostr" in state_dir class TestNostrIntegrationMediaCalibration: def test_send_media_returns_error_when_no_sender(self): adapter = NostrAdapter(config={"private_key": None, "relays": []}) from yuxi.channels.adapters.nostr.adapter import DeliveryResult result = asyncio_get(adapter.send_media("dm:test", "image", "http://example.com/img.jpg")) assert result.success is False def test_download_media_returns_bytes(self): adapter = NostrAdapter(config={"private_key": None, "relays": []}) import aiohttp with patch("aiohttp.ClientSession.get") as mock_get: mock_resp = MagicMock() mock_resp.status = 200 mock_resp.content_length = 100 mock_resp.read = AsyncMock(return_value=b"fake_image_data") mock_get.return_value.__aenter__ = AsyncMock(return_value=mock_resp) mock_get.return_value.__aexit__ = AsyncMock() result = asyncio_get(adapter.download_media("http://example.com/img.jpg")) assert result == b"fake_image_data" def asyncio_get(coro): import asyncio try: loop = asyncio.get_running_loop() except RuntimeError: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) return loop.run_until_complete(coro) import concurrent.futures future = asyncio.run_coroutine_threadsafe(coro, loop) return future.result(timeout=5)