radicle-reticulum/tests/test_adapter.py

207 lines
6.9 KiB
Python

"""Tests for RNSTransportAdapter peer discovery logic (RNS networking mocked)."""
import time
from unittest.mock import MagicMock, patch, call
import pytest
import RNS
from radicle_reticulum.adapter import (
PeerInfo,
RNSTransportAdapter,
NODE_APP_DATA_MAGIC,
REPO_APP_DATA_MAGIC,
)
from radicle_reticulum.identity import RadicleIdentity
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_dest_mock(hash_bytes: bytes = b"\xaa" * 16) -> MagicMock:
dest = MagicMock()
dest.hash = hash_bytes
dest.hexhash = hash_bytes.hex()
return dest
def _make_adapter() -> RNSTransportAdapter:
"""Instantiate adapter with all RNS I/O patched out."""
identity = RadicleIdentity.generate()
dest_mock = _make_dest_mock()
with patch("radicle_reticulum.adapter.RNS.Reticulum"), \
patch("radicle_reticulum.adapter.RNS.Destination", return_value=dest_mock), \
patch("radicle_reticulum.adapter.RNS.log"):
adapter = RNSTransportAdapter(identity=identity)
return adapter
# ---------------------------------------------------------------------------
# PeerInfo
# ---------------------------------------------------------------------------
class TestPeerInfo:
def test_age_increases_over_time(self):
identity = RadicleIdentity.generate()
peer = PeerInfo(
identity=identity,
destination_hash=b"\x01" * 16,
last_seen=time.time() - 5.0,
)
assert peer.age >= 5.0
def test_age_near_zero_for_fresh_peer(self):
identity = RadicleIdentity.generate()
peer = PeerInfo(
identity=identity,
destination_hash=b"\x02" * 16,
last_seen=time.time(),
)
assert peer.age < 1.0
def test_announced_repos_defaults_empty(self):
identity = RadicleIdentity.generate()
peer = PeerInfo(identity=identity, destination_hash=b"\x03" * 16, last_seen=0)
assert peer.announced_repos == set()
# ---------------------------------------------------------------------------
# Adapter construction & peer list
# ---------------------------------------------------------------------------
class TestAdapterConstruction:
def test_identity_is_stored(self):
adapter = _make_adapter()
assert adapter.identity is not None
assert adapter.identity.did.startswith("did:key:")
def test_initial_peer_list_is_empty(self):
adapter = _make_adapter()
assert adapter.get_peers() == []
def test_get_peer_by_did_returns_none_when_absent(self):
adapter = _make_adapter()
assert adapter.get_peer_by_did("did:key:z6Mkunknown") is None
# ---------------------------------------------------------------------------
# _handle_announce
# ---------------------------------------------------------------------------
class TestHandleAnnounce:
def _make_rns_identity(self) -> RNS.Identity:
"""Create a real RNS.Identity (keypair only, no networking)."""
return RNS.Identity()
def test_ignores_own_announce(self):
adapter = _make_adapter()
own_hash = adapter.node_destination.hash # the mock's hash
discovered = []
adapter.set_on_peer_discovered(discovered.append)
rns_id = self._make_rns_identity()
with patch("radicle_reticulum.adapter.RNS.log"):
adapter._handle_announce(own_hash, rns_id, NODE_APP_DATA_MAGIC)
assert discovered == []
assert adapter.get_peers() == []
def test_ignores_non_radicle_announce(self):
adapter = _make_adapter()
rns_id = self._make_rns_identity()
foreign_hash = b"\xbb" * 16
discovered = []
adapter.set_on_peer_discovered(discovered.append)
with patch("radicle_reticulum.adapter.RNS.log"):
adapter._handle_announce(foreign_hash, rns_id, b"SOME_OTHER_APP")
assert discovered == []
def test_ignores_announce_with_no_app_data(self):
adapter = _make_adapter()
rns_id = self._make_rns_identity()
with patch("radicle_reticulum.adapter.RNS.log"):
adapter._handle_announce(b"\xcc" * 16, rns_id, None)
assert adapter.get_peers() == []
def test_discovers_valid_peer(self):
adapter = _make_adapter()
rns_id = self._make_rns_identity()
peer_hash = b"\xdd" * 16
discovered = []
adapter.set_on_peer_discovered(discovered.append)
with patch("radicle_reticulum.adapter.RNS.log"):
adapter._handle_announce(peer_hash, rns_id, NODE_APP_DATA_MAGIC)
assert len(discovered) == 1
peers = adapter.get_peers()
assert len(peers) == 1
assert peers[0].destination_hash == peer_hash
def test_second_announce_updates_last_seen_not_duplicates(self):
adapter = _make_adapter()
rns_id = self._make_rns_identity()
peer_hash = b"\xee" * 16
discovered = []
adapter.set_on_peer_discovered(discovered.append)
with patch("radicle_reticulum.adapter.RNS.log"):
adapter._handle_announce(peer_hash, rns_id, NODE_APP_DATA_MAGIC)
old_seen = adapter.get_peers()[0].last_seen
time.sleep(0.01)
adapter._handle_announce(peer_hash, rns_id, NODE_APP_DATA_MAGIC)
# Callback only fires once (first discovery)
assert len(discovered) == 1
# But last_seen was refreshed
assert adapter.get_peers()[0].last_seen >= old_seen
def test_multiple_distinct_peers_are_all_tracked(self):
adapter = _make_adapter()
with patch("radicle_reticulum.adapter.RNS.log"):
for i in range(3):
rns_id = self._make_rns_identity()
peer_hash = bytes([i]) * 16
adapter._handle_announce(peer_hash, rns_id, NODE_APP_DATA_MAGIC)
assert len(adapter.get_peers()) == 3
def test_get_peer_by_did_returns_correct_peer(self):
adapter = _make_adapter()
rns_id = self._make_rns_identity()
peer_hash = b"\xff" * 16
with patch("radicle_reticulum.adapter.RNS.log"):
adapter._handle_announce(peer_hash, rns_id, NODE_APP_DATA_MAGIC)
peers = adapter.get_peers()
did = peers[0].identity.did
found = adapter.get_peer_by_did(did)
assert found is not None
assert found.destination_hash == peer_hash
def test_extra_app_data_after_magic_still_accepted(self):
"""Announces with extra bytes after the magic prefix are valid."""
adapter = _make_adapter()
rns_id = self._make_rns_identity()
peer_hash = b"\x11" * 16
with patch("radicle_reticulum.adapter.RNS.log"):
adapter._handle_announce(
peer_hash, rns_id, NODE_APP_DATA_MAGIC + b"\x00\x01extra"
)
assert len(adapter.get_peers()) == 1