"""Tests for GossipRelay.""" import json import subprocess import time from pathlib import Path from unittest.mock import MagicMock, patch, call import pytest from radicle_reticulum.gossip import GossipRelay, _read_refs, _radicle_storage_path from radicle_reticulum.identity import RadicleIdentity # ── Helpers ────────────────────────────────────────────────────────────────── def _make_relay(tmp_path, rids=None, **kwargs): identity = RadicleIdentity.generate() with patch("radicle_reticulum.gossip.RNS.Reticulum"), \ patch("radicle_reticulum.gossip.RNS.Destination") as mock_dest_cls, \ patch("radicle_reticulum.gossip.RNS.Transport"): mock_dest = MagicMock() mock_dest.hash = b"\x00" * 16 mock_dest.hexhash = "0" * 32 mock_dest_cls.return_value = mock_dest relay = GossipRelay( identity=identity, rids=rids or ["rad:z3abc123"], storage=tmp_path / "storage", **kwargs, ) relay.destination = mock_dest return relay SAMPLE_REFS = { "refs/heads/main": "abc123def456" * 3, "refs/rad/sigrefs": "def456abc123" * 3, } # ── _read_refs ──────────────────────────────────────────────────────────────── class TestReadRefs: def test_missing_storage_returns_empty(self, tmp_path): result = _read_refs(tmp_path / "storage", "rad:z3nonexistent") assert result == {} def test_reads_refs_from_bare_repo(self, tmp_path): rid = "rad:z3testrepo" repo_path = tmp_path / "z3testrepo" repo_path.mkdir() show_ref_output = ( "abc123def456abc123def456abc123def456abc1 refs/heads/main\n" "def456abc123def456abc123def456abc123def4 refs/rad/sigrefs\n" ) with patch("radicle_reticulum.gossip.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0, stdout=show_ref_output) result = _read_refs(tmp_path, rid) assert result["refs/heads/main"] == "abc123def456abc123def456abc123def456abc1" assert result["refs/rad/sigrefs"] == "def456abc123def456abc123def456abc123def4" def test_strips_rad_prefix(self, tmp_path): # rid with "rad:" prefix should map to directory without it repo_path = tmp_path / "z3abc" repo_path.mkdir() result = _read_refs(tmp_path, "rad:z3abc") assert result == {} # empty repo, no refs — just confirming no crash # ── GossipRelay construction ────────────────────────────────────────────────── class TestGossipRelayInit: def test_defaults(self, tmp_path): relay = _make_relay(tmp_path) assert relay.rids == ["rad:z3abc123"] assert relay.poll_interval == 30 assert relay.bridge_port == 8777 assert relay.announce_retry_delays == (5, 15, 30) def test_custom_params(self, tmp_path): relay = _make_relay( tmp_path, rids=["rad:z3a", "rad:z3b"], bridge_port=9000, poll_interval=60, ) assert len(relay.rids) == 2 assert relay.bridge_port == 9000 assert relay.poll_interval == 60 # ── Broadcast ──────────────────────────────────────────────────────────────── class TestBroadcast: def test_broadcast_sends_to_known_peers(self, tmp_path): relay = _make_relay(tmp_path, radicle_nid="z6Mktest") peer_hash = b"\x01" * 16 relay._known_peers[peer_hash] = time.time() with patch.object(relay, "_send_packet", return_value=True) as mock_send: relay._broadcast("rad:z3abc123", SAMPLE_REFS) mock_send.assert_called_once() args = mock_send.call_args[0] assert args[0] == peer_hash msg = json.loads(args[1].decode()) assert msg["rid"] == "rad:z3abc123" assert msg["nid"] == "z6Mktest" assert msg["refs"] == SAMPLE_REFS def test_broadcast_no_peers_sends_nothing(self, tmp_path): relay = _make_relay(tmp_path) with patch.object(relay, "_send_packet") as mock_send: relay._broadcast("rad:z3abc123", SAMPLE_REFS) mock_send.assert_not_called() def test_broadcast_multiple_peers(self, tmp_path): relay = _make_relay(tmp_path) for i in range(3): relay._known_peers[bytes([i]) * 16] = time.time() with patch.object(relay, "_send_packet", return_value=True) as mock_send: relay._broadcast("rad:z3abc123", SAMPLE_REFS) assert mock_send.call_count == 3 # ── Poll loop ───────────────────────────────────────────────────────────────── class TestPollLoop: def test_broadcasts_on_ref_change(self, tmp_path): relay = _make_relay(tmp_path) relay._known_refs["rad:z3abc123"] = {"refs/heads/main": "old_sha"} new_refs = {"refs/heads/main": "new_sha"} with patch("radicle_reticulum.gossip._read_refs", return_value=new_refs), \ patch.object(relay, "_broadcast") as mock_broadcast: # Simulate one poll iteration for rid in relay.rids: refs = new_refs old = relay._known_refs.get(rid) if refs and refs != old: if old is not None: relay._broadcast(rid, refs) relay._known_refs[rid] = refs mock_broadcast.assert_called_once_with("rad:z3abc123", new_refs) def test_no_broadcast_on_first_poll(self, tmp_path): relay = _make_relay(tmp_path) # No existing refs — first poll should not broadcast with patch("radicle_reticulum.gossip._read_refs", return_value=SAMPLE_REFS), \ patch.object(relay, "_broadcast") as mock_broadcast: for rid in relay.rids: refs = SAMPLE_REFS old = relay._known_refs.get(rid) if refs and refs != old: if old is not None: relay._broadcast(rid, refs) relay._known_refs[rid] = refs mock_broadcast.assert_not_called() assert relay._known_refs["rad:z3abc123"] == SAMPLE_REFS def test_no_broadcast_when_refs_unchanged(self, tmp_path): relay = _make_relay(tmp_path) relay._known_refs["rad:z3abc123"] = SAMPLE_REFS with patch("radicle_reticulum.gossip._read_refs", return_value=SAMPLE_REFS), \ patch.object(relay, "_broadcast") as mock_broadcast: for rid in relay.rids: refs = SAMPLE_REFS old = relay._known_refs.get(rid) if refs and refs != old: if old is not None: relay._broadcast(rid, refs) mock_broadcast.assert_not_called() # ── Incoming packets ────────────────────────────────────────────────────────── class TestOnPacket: def _make_packet(self, rid, nid, refs): return json.dumps({"type": "refs", "rid": rid, "nid": nid, "refs": refs}).encode() def test_triggers_sync_on_new_refs(self, tmp_path): relay = _make_relay(tmp_path) relay._known_refs["rad:z3abc123"] = {"refs/heads/main": "old"} mock_packet = MagicMock() with patch.object(relay, "_trigger_sync") as mock_sync: relay._on_packet( self._make_packet("rad:z3abc123", "z6Mkpeer", {"refs/heads/main": "new"}), mock_packet, ) mock_sync.assert_called_once_with("rad:z3abc123", "z6Mkpeer") def test_no_sync_when_refs_match(self, tmp_path): relay = _make_relay(tmp_path) relay._known_refs["rad:z3abc123"] = SAMPLE_REFS mock_packet = MagicMock() with patch.object(relay, "_trigger_sync") as mock_sync: relay._on_packet( self._make_packet("rad:z3abc123", "z6Mkpeer", SAMPLE_REFS), mock_packet, ) mock_sync.assert_not_called() def test_ignores_invalid_json(self, tmp_path): relay = _make_relay(tmp_path) mock_packet = MagicMock() # Should not raise relay._on_packet(b"not json at all", mock_packet) def test_ignores_wrong_type(self, tmp_path): relay = _make_relay(tmp_path) mock_packet = MagicMock() with patch.object(relay, "_trigger_sync") as mock_sync: relay._on_packet( json.dumps({"type": "ping", "rid": "rad:z3abc123"}).encode(), mock_packet, ) mock_sync.assert_not_called() def test_triggers_sync_for_unknown_repo(self, tmp_path): relay = _make_relay(tmp_path) # No prior known refs — any incoming refs are "new" mock_packet = MagicMock() with patch.object(relay, "_trigger_sync") as mock_sync: relay._on_packet( self._make_packet("rad:z3unknown", "z6Mkpeer", SAMPLE_REFS), mock_packet, ) mock_sync.assert_called_once() def test_sync_callback_invoked(self, tmp_path): relay = _make_relay(tmp_path) received = [] relay.set_on_sync_triggered(lambda rid, nid: received.append((rid, nid))) with patch("radicle_reticulum.gossip.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0) relay._trigger_sync("rad:z3abc123", "z6Mkpeer") assert received == [("rad:z3abc123", "z6Mkpeer")] # ── Trigger sync ────────────────────────────────────────────────────────────── class TestTriggerSync: def test_calls_rad_node_connect_when_nid_given(self, tmp_path): relay = _make_relay(tmp_path, bridge_port=8777) with patch("radicle_reticulum.gossip.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0) relay._trigger_sync("rad:z3abc123", "z6Mkpeer123") calls = [c[0][0] for c in mock_run.call_args_list] connect_calls = [c for c in calls if "connect" in c] assert connect_calls assert "z6Mkpeer123@127.0.0.1:8777" in connect_calls[0] def test_skips_connect_when_no_nid(self, tmp_path): relay = _make_relay(tmp_path) with patch("radicle_reticulum.gossip.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0) relay._trigger_sync("rad:z3abc123", "") calls = [c[0][0] for c in mock_run.call_args_list] connect_calls = [c for c in calls if "connect" in c] assert not connect_calls def test_calls_rad_sync_fetch(self, tmp_path): relay = _make_relay(tmp_path) with patch("radicle_reticulum.gossip.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0) relay._trigger_sync("rad:z3abc123", "") all_calls = [c[0][0] for c in mock_run.call_args_list] sync_calls = [c for c in all_calls if "sync" in c] assert sync_calls assert "--fetch" in sync_calls[0] assert "--rid" in sync_calls[0] # ── Peer discovery ──────────────────────────────────────────────────────────── class TestPeerDiscovery: def _make_app_data(self, nid=None): import struct from radicle_reticulum.gossip import GOSSIP_MAGIC data = GOSSIP_MAGIC if nid: b = nid.encode() data += struct.pack("!H", len(b)) + b return data def test_new_peer_added(self, tmp_path): relay = _make_relay(tmp_path) peer_hash = b"\x02" * 16 relay._known_refs["rad:z3abc123"] = SAMPLE_REFS with patch.object(relay, "_send_packet"): relay._on_announce(peer_hash, MagicMock(), self._make_app_data("z6Mkpeer")) assert peer_hash in relay._known_peers def test_own_announce_ignored(self, tmp_path): relay = _make_relay(tmp_path) own_hash = relay.destination.hash with patch.object(relay, "_send_packet") as mock_send: relay._on_announce(own_hash, MagicMock(), self._make_app_data()) mock_send.assert_not_called() assert own_hash not in relay._known_peers def test_non_gossip_announce_ignored(self, tmp_path): relay = _make_relay(tmp_path) peer_hash = b"\x03" * 16 with patch.object(relay, "_send_packet") as mock_send: relay._on_announce(peer_hash, MagicMock(), b"OTHER_APP_DATA") mock_send.assert_not_called() assert peer_hash not in relay._known_peers def test_sends_current_refs_to_new_peer(self, tmp_path): relay = _make_relay(tmp_path) relay._known_refs["rad:z3abc123"] = SAMPLE_REFS peer_hash = b"\x04" * 16 with patch.object(relay, "_send_packet") as mock_send: relay._on_announce(peer_hash, MagicMock(), self._make_app_data()) mock_send.assert_called_once() assert mock_send.call_args[0][0] == peer_hash def test_duplicate_announce_not_re_sent(self, tmp_path): relay = _make_relay(tmp_path) relay._known_refs["rad:z3abc123"] = SAMPLE_REFS peer_hash = b"\x05" * 16 relay._known_peers[peer_hash] = time.time() # already known with patch.object(relay, "_send_packet") as mock_send: relay._on_announce(peer_hash, MagicMock(), self._make_app_data()) mock_send.assert_not_called() # ── push_refs_now ───────────────────────────────────────────────────────────── class TestPushRefsNow: def test_push_refs_now_broadcasts_immediately(self, tmp_path): relay = _make_relay(tmp_path) peer_hash = b"\x06" * 16 relay._known_peers[peer_hash] = time.time() with patch("radicle_reticulum.gossip._read_refs", return_value=SAMPLE_REFS), \ patch.object(relay, "_send_packet", return_value=True) as mock_send: relay.push_refs_now("rad:z3abc123") mock_send.assert_called_once() def test_push_refs_now_updates_known_refs(self, tmp_path): relay = _make_relay(tmp_path) with patch("radicle_reticulum.gossip._read_refs", return_value=SAMPLE_REFS), \ patch.object(relay, "_send_packet", return_value=True): relay.push_refs_now("rad:z3abc123") assert relay._known_refs["rad:z3abc123"] == SAMPLE_REFS # ── Watchdog / poll event ────────────────────────────────────────────────────── class TestWatchdog: def test_poll_event_wakes_poll_loop_early(self, tmp_path): relay = _make_relay(tmp_path) relay._known_refs["rad:z3abc123"] = {"refs/heads/main": "old"} new_refs = {"refs/heads/main": "new"} broadcasts = [] with patch("radicle_reticulum.gossip._read_refs", return_value=new_refs), \ patch.object(relay, "_broadcast", side_effect=lambda r, refs: broadcasts.append(r)): # Signal the event (simulates watchdog firing) relay._poll_event.set() # Run one poll iteration relay._poll_loop_once() assert broadcasts == ["rad:z3abc123"] def test_stop_sets_poll_event(self, tmp_path): relay = _make_relay(tmp_path) relay._running = True with patch("radicle_reticulum.gossip.RNS.log"): relay.stop() assert relay._poll_event.is_set() def test_start_watcher_graceful_without_watchdog(self, tmp_path): relay = _make_relay(tmp_path) with patch.dict("sys.modules", {"watchdog": None, "watchdog.observers": None, "watchdog.events": None}), \ patch("radicle_reticulum.gossip.RNS.log"): relay._start_watcher() assert relay._observer is None def test_auto_discover_adds_new_repos(self, tmp_path): relay = _make_relay(tmp_path, rids=[]) storage = tmp_path / "storage" storage.mkdir() (storage / "z3newrepo").mkdir() relay.storage = storage relay._discover_rids() assert "rad:z3newrepo" in relay.rids def test_auto_discover_does_not_add_duplicates(self, tmp_path): relay = _make_relay(tmp_path, rids=["rad:z3existing"]) storage = tmp_path / "storage" storage.mkdir() (storage / "z3existing").mkdir() relay.storage = storage relay._discover_rids() assert relay.rids.count("rad:z3existing") == 1