from __future__ import annotations import json import time from unittest.mock import AsyncMock, MagicMock, patch import pytest class TestEventVerification: def test_verify_event_valid(self): from yuxi.channels.adapters.nostr.crypto import NostrCrypto with patch("yuxi.channels.adapters.nostr.crypto.Keys") as mk: mk.parse.return_value = _make_mock_keys() crypto = NostrCrypto("nsec1test") raw = { "id": "a" * 64, "pubkey": "b" * 64, "kind": 1, "content": "hello", "tags": [], "created_at": int(time.time()), "sig": "c" * 128, } with patch("yuxi.channels.adapters.nostr.crypto.Event") as me: mock_event = MagicMock() mock_event.verify.return_value = None me.from_json.return_value = mock_event assert crypto.verify_event(raw) is True def test_verify_event_invalid_rejected(self): from yuxi.channels.adapters.nostr.crypto import NostrCrypto with patch("yuxi.channels.adapters.nostr.crypto.Keys") as mk: mk.parse.return_value = _make_mock_keys() crypto = NostrCrypto("nsec1test") raw = {"id": "x" * 64, "pubkey": "y" * 64} with patch("yuxi.channels.adapters.nostr.crypto.Event") as me: mock_event = MagicMock() mock_event.verify.side_effect = Exception("bad sig") me.from_json.return_value = mock_event assert crypto.verify_event(raw) is False def test_verify_event_strict_id_mismatch_rejected(self): from yuxi.channels.adapters.nostr.crypto import NostrCrypto with patch("yuxi.channels.adapters.nostr.crypto.Keys") as mk: mk.parse.return_value = _make_mock_keys() crypto = NostrCrypto("nsec1test") raw = {"id": "wrong_id", "pubkey": "b" * 64} with patch("yuxi.channels.adapters.nostr.crypto.Event") as me: mock_event = MagicMock() mock_event.id.return_value.to_hex.return_value = "real_id" mock_event.id.return_value.to_bech32.return_value = "note1real" me.from_json.return_value = mock_event assert crypto.verify_event_strict(raw) is False def test_verify_event_strict_passes_with_matching_id(self): from yuxi.channels.adapters.nostr.crypto import NostrCrypto with patch("yuxi.channels.adapters.nostr.crypto.Keys") as mk: mk.parse.return_value = _make_mock_keys() crypto = NostrCrypto("nsec1test") event_id = "a" * 64 raw = {"id": event_id, "pubkey": "b" * 64} with patch("yuxi.channels.adapters.nostr.crypto.Event") as me: mock_event = MagicMock() mock_event.id.return_value.to_hex.return_value = event_id mock_event.id.return_value.to_bech32.return_value = "note1other" mock_event.verify.return_value = None me.from_json.return_value = mock_event assert crypto.verify_event_strict(raw) is True @pytest.mark.asyncio async def test_adapter_rejects_invalid_event(self): from yuxi.channels.adapters.nostr.adapter import NostrAdapter from yuxi.channels.adapters.nostr.config import NostrConfig adapter = NostrAdapter(config={"private_key": None, "relays": []}) adapter._nostr_config = NostrConfig(relays=[]) crypto_mock = MagicMock() crypto_mock.verify_event.return_value = False adapter._crypto = crypto_mock raw = {"id": "fake_id_123"} await adapter._handle_relay_event(raw) class TestMultiRelayQuery: @pytest.mark.asyncio async def test_query_aggregates_multiple_relays(self): from yuxi.channels.adapters.nostr.relay_manager import RelayManager mgr = RelayManager(relay_urls=["wss://a.relay", "wss://b.relay"]) ws_a = AsyncMock() ws_a.open = True ws_a.send = AsyncMock() ws_b = AsyncMock() ws_b.open = True ws_b.send = AsyncMock() mgr._connections = {"wss://a.relay": ws_a, "wss://b.relay": ws_b} event_a = json.dumps( [ "EVENT", "sub", {"id": "evt_a", "pubkey": "p1", "kind": 1, "content": "from a", "tags": [], "created_at": 1}, ] ) eose = json.dumps(["EOSE", "sub"]) event_b = json.dumps( [ "EVENT", "sub", {"id": "evt_b", "pubkey": "p2", "kind": 1, "content": "from b", "tags": [], "created_at": 2}, ] ) ws_a.recv = AsyncMock(side_effect=[event_a, eose, TimeoutError()]) ws_b.recv = AsyncMock(side_effect=[event_b, eose, TimeoutError()]) results = await mgr.query([{"kinds": [1]}], timeout=1.0) assert len(results) == 2 result_ids = {r["id"] for r in results} assert result_ids == {"evt_a", "evt_b"} @pytest.mark.asyncio async def test_query_dedup_across_relays(self): from yuxi.channels.adapters.nostr.relay_manager import RelayManager mgr = RelayManager(relay_urls=["wss://a.relay", "wss://b.relay"]) ws_a = AsyncMock() ws_a.open = True ws_a.send = AsyncMock() ws_b = AsyncMock() ws_b.open = True ws_b.send = AsyncMock() mgr._connections = {"wss://a.relay": ws_a, "wss://b.relay": ws_b} dup_event = json.dumps( ["EVENT", "sub", {"id": "duplicate", "kind": 1, "content": "dup", "tags": [], "created_at": 1}] ) eose = json.dumps(["EOSE", "sub"]) ws_a.recv = AsyncMock(side_effect=[dup_event, eose, TimeoutError()]) ws_b.recv = AsyncMock(side_effect=[dup_event, eose, TimeoutError()]) results = await mgr.query([{"kinds": [1]}], timeout=1.0) assert len(results) == 1 assert results[0]["id"] == "duplicate" @pytest.mark.asyncio async def test_query_no_active_connections_returns_empty(self): from yuxi.channels.adapters.nostr.relay_manager import RelayManager mgr = RelayManager(relay_urls=["wss://a.relay"]) mgr._connections = {} results = await mgr.query([{"kinds": [1]}]) assert results == [] class TestPersistentCursor: @pytest.mark.asyncio async def test_subscribe_first_call_uses_24h_ago(self): from yuxi.channels.adapters.nostr.monitor import NostrMonitor crypto_mock = MagicMock() relay_mgr = MagicMock() relay_mgr.subscribe = AsyncMock() monitor = NostrMonitor(crypto_mock, relay_mgr) now = int(time.time()) with patch("time.time", return_value=now): await monitor.subscribe("pairing") relay_mgr.subscribe.assert_called_once() filters = relay_mgr.subscribe.call_args[0][0] since = filters[0]["since"] assert now - 86400 <= since <= now assert monitor._last_subscribe_ts == now @pytest.mark.asyncio async def test_subscribe_second_call_uses_previous_ts(self): from yuxi.channels.adapters.nostr.monitor import NostrMonitor crypto_mock = MagicMock() relay_mgr = MagicMock() relay_mgr.subscribe = AsyncMock() monitor = NostrMonitor(crypto_mock, relay_mgr) monitor._last_subscribe_ts = 1000 now = 5000 with patch("time.time", return_value=now): await monitor.subscribe("pairing") filters = relay_mgr.subscribe.call_args[0][0] assert filters[0]["since"] == 1000 assert monitor._last_subscribe_ts == now @pytest.mark.asyncio async def test_subscribe_updates_timestamp_each_call(self): from yuxi.channels.adapters.nostr.monitor import NostrMonitor crypto_mock = MagicMock() relay_mgr = MagicMock() relay_mgr.subscribe = AsyncMock() monitor = NostrMonitor(crypto_mock, relay_mgr) with patch("time.time", return_value=1000): await monitor.subscribe("pairing") assert monitor._last_subscribe_ts == 1000 with patch("time.time", return_value=5000): await monitor.subscribe("pairing") assert monitor._last_subscribe_ts == 5000 def _make_mock_keys(): mk = MagicMock() mpk = MagicMock() mpk.to_bech32.return_value = "npub1test" mpk.to_hex.return_value = "a" * 64 msk = MagicMock() msk.to_bech32.return_value = "nsec1test" mk.public_key.return_value = mpk mk.secret_key.return_value = msk return mk