"""Tests for sync data structures and SyncManager logic (no RNS networking).""" import struct import time import tempfile from pathlib import Path from unittest.mock import MagicMock, patch import pytest import LXMF from radicle_reticulum.sync import ( RefsAnnouncement, SyncManager, SyncMode, CONTENT_TYPE_BUNDLE, CONTENT_TYPE_BUNDLE_CHUNK, CONTENT_TYPE_REFS_ANNOUNCE, CHUNK_HEADER_SIZE, ) from radicle_reticulum.identity import RadicleIdentity from radicle_reticulum.git_bundle import GitBundle, BundleMetadata, BundleType import hashlib # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_manager(tmp_path: Path) -> SyncManager: """Instantiate SyncManager with LXMF/RNS components patched out.""" identity = RadicleIdentity.generate() mock_router = MagicMock() mock_dest = MagicMock() mock_dest.hash = b"\x00" * 32 mock_dest.hash_hex = "00" * 32 with patch("radicle_reticulum.sync.RNS.Reticulum"), \ patch("radicle_reticulum.sync.LXMF.LXMRouter", return_value=mock_router), \ patch("radicle_reticulum.sync.RNS.log"): mock_router.register_delivery_identity.return_value = mock_dest manager = SyncManager(identity=identity, storage_path=tmp_path) manager._lxmf_router = mock_router manager._lxmf_destination = mock_dest return manager def _make_bundle(data: bytes = b"git bundle") -> GitBundle: checksum = hashlib.sha256(data).digest() metadata = BundleMetadata( bundle_type=BundleType.FULL, repository_id="rad:z3test", source_node="did:key:z6Mktest", timestamp=1000, refs_included=["refs/heads/main"], prerequisites=[], size_bytes=len(data), checksum=checksum, ) return GitBundle(metadata=metadata, data=data) class TestRefsAnnouncement: def test_encode_decode_roundtrip(self): ann = RefsAnnouncement( repository_id="rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5", node_id="did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", refs={ "refs/heads/main": "abc123def456789012345678901234567890abcd", "refs/rad/id": "0000000000000000000000000000000000000001", }, timestamp=1234567890123, ) encoded = ann.encode() decoded = RefsAnnouncement.decode(encoded) assert decoded.repository_id == ann.repository_id assert decoded.node_id == ann.node_id assert decoded.refs == ann.refs assert decoded.timestamp == ann.timestamp def test_empty_refs(self): ann = RefsAnnouncement( repository_id="rad:test", node_id="did:key:z6Mk...", refs={}, timestamp=0, ) decoded = RefsAnnouncement.decode(ann.encode()) assert decoded.refs == {} def test_many_refs(self): refs = {f"refs/heads/branch-{i}": "a" * 40 for i in range(50)} ann = RefsAnnouncement( repository_id="rad:z3test", node_id="did:key:z6Mktest", refs=refs, timestamp=9999, ) decoded = RefsAnnouncement.decode(ann.encode()) assert decoded.refs == refs def test_special_characters_in_ref_names(self): refs = {"refs/heads/feature/my-branch_v2.0": "b" * 40} ann = RefsAnnouncement( repository_id="rad:z3test", node_id="did:key:z6Mktest", refs=refs, timestamp=1, ) decoded = RefsAnnouncement.decode(ann.encode()) assert decoded.refs == refs def test_encode_produces_bytes(self): ann = RefsAnnouncement( repository_id="rad:test", node_id="did:key:z6Mk", refs={"refs/heads/main": "a" * 40}, timestamp=1000, ) assert isinstance(ann.encode(), bytes) assert len(ann.encode()) > 0 # --------------------------------------------------------------------------- # SyncManager state management # --------------------------------------------------------------------------- class TestSyncManagerPeers: def test_initial_peer_list_empty(self, tmp_path): manager = _make_manager(tmp_path) assert manager.get_known_peers() == [] def test_register_peer_adds_to_list(self, tmp_path): manager = _make_manager(tmp_path) with patch("radicle_reticulum.sync.RNS.log"): manager.register_peer(b"\x01" * 16) assert b"\x01" * 16 in manager.get_known_peers() def test_register_multiple_peers(self, tmp_path): manager = _make_manager(tmp_path) hashes = [bytes([i]) * 16 for i in range(3)] with patch("radicle_reticulum.sync.RNS.log"): for h in hashes: manager.register_peer(h) assert set(manager.get_known_peers()) == set(hashes) def test_peer_learned_from_incoming_message(self, tmp_path): manager = _make_manager(tmp_path) source_hash = b"\xab" * 16 bundle = _make_bundle() msg = MagicMock() msg.source_hash = source_hash msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_BUNDLE} msg.content = bundle.encode() with patch("radicle_reticulum.sync.RNS.log"): manager._on_lxmf_delivery(msg) assert source_hash in manager.get_known_peers() def test_message_without_source_hash_still_processed(self, tmp_path): manager = _make_manager(tmp_path) bundle = _make_bundle() msg = MagicMock() msg.source_hash = None msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_BUNDLE} msg.content = bundle.encode() received = [] manager.set_on_bundle_received(received.append) with patch("radicle_reticulum.sync.RNS.log"): manager._on_lxmf_delivery(msg) assert len(received) == 1 # --------------------------------------------------------------------------- # SyncManager delivery callbacks # --------------------------------------------------------------------------- class TestSyncManagerDelivery: def test_bundle_callback_fires_on_bundle_message(self, tmp_path): manager = _make_manager(tmp_path) bundle = _make_bundle() received = [] manager.set_on_bundle_received(received.append) msg = MagicMock() msg.source_hash = b"\x01" * 16 msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_BUNDLE} msg.content = bundle.encode() with patch("radicle_reticulum.sync.RNS.log"): manager._on_lxmf_delivery(msg) assert len(received) == 1 assert received[0].metadata.repository_id == "rad:z3test" def test_refs_callback_fires_on_refs_announce_message(self, tmp_path): manager = _make_manager(tmp_path) ann = RefsAnnouncement( repository_id="rad:z3test", node_id="did:key:z6Mktest", refs={"refs/heads/main": "a" * 40}, timestamp=999, ) received = [] manager.set_on_refs_announced(received.append) msg = MagicMock() msg.source_hash = b"\x02" * 16 msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_REFS_ANNOUNCE} msg.content = ann.encode() with patch("radicle_reticulum.sync.RNS.log"): manager._on_lxmf_delivery(msg) assert len(received) == 1 assert received[0].repository_id == "rad:z3test" def test_unknown_content_type_silently_ignored(self, tmp_path): manager = _make_manager(tmp_path) msg = MagicMock() msg.source_hash = None msg.fields = {LXMF.FIELD_CUSTOM_TYPE: 0xFF} msg.content = b"garbage" with patch("radicle_reticulum.sync.RNS.log"): manager._on_lxmf_delivery(msg) # should not raise # --------------------------------------------------------------------------- # Chunk reassembly # --------------------------------------------------------------------------- class TestChunkReassembly: def _make_chunks(self, data: bytes, chunk_size: int = 10): """Split data into chunk messages as _send_chunked_bundle would.""" bundle_id = hashlib.sha256(data).digest()[:16] total = (len(data) + chunk_size - 1) // chunk_size msgs = [] for i in range(total): chunk = data[i * chunk_size: (i + 1) * chunk_size] header = struct.pack("!16sHH", bundle_id, i, total) msgs.append(header + chunk) return msgs def test_reassemble_two_chunks(self, tmp_path): manager = _make_manager(tmp_path) bundle = _make_bundle(b"first half second half ") bundle_bytes = bundle.encode() received = [] manager.set_on_bundle_received(received.append) chunks = self._make_chunks(bundle_bytes, chunk_size=len(bundle_bytes) // 2 + 1) assert len(chunks) == 2 for chunk_data in chunks: msg = MagicMock() msg.source_hash = b"\x01" * 16 msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_BUNDLE_CHUNK} msg.content = chunk_data with patch("radicle_reticulum.sync.RNS.log"): manager._on_lxmf_delivery(msg) assert len(received) == 1 assert received[0].data == bundle.data def test_out_of_order_chunks_reassemble_correctly(self, tmp_path): manager = _make_manager(tmp_path) bundle = _make_bundle(b"abcdefghijklmnopqrstuvwxyz0123456789") bundle_bytes = bundle.encode() received = [] manager.set_on_bundle_received(received.append) chunks = self._make_chunks(bundle_bytes, chunk_size=8) assert len(chunks) >= 3 for chunk_data in reversed(chunks): # deliver in reverse order msg = MagicMock() msg.source_hash = None msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_BUNDLE_CHUNK} msg.content = chunk_data with patch("radicle_reticulum.sync.RNS.log"): manager._on_lxmf_delivery(msg) assert len(received) == 1 assert received[0].data == bundle.data def test_malformed_chunk_too_short_is_ignored(self, tmp_path): manager = _make_manager(tmp_path) msg = MagicMock() msg.source_hash = None msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_BUNDLE_CHUNK} msg.content = b"\x00" * (CHUNK_HEADER_SIZE - 1) # too short with patch("radicle_reticulum.sync.RNS.log"): manager._on_lxmf_delivery(msg) # should not raise assert manager._chunk_buffers == {} def test_partial_chunks_not_delivered_until_complete(self, tmp_path): manager = _make_manager(tmp_path) bundle = _make_bundle(b"partial test data here") bundle_bytes = bundle.encode() received = [] manager.set_on_bundle_received(received.append) chunks = self._make_chunks(bundle_bytes, chunk_size=5) assert len(chunks) >= 2 # Send only first chunk msg = MagicMock() msg.source_hash = None msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_BUNDLE_CHUNK} msg.content = chunks[0] with patch("radicle_reticulum.sync.RNS.log"): manager._on_lxmf_delivery(msg) assert received == [] # not yet complete # --------------------------------------------------------------------------- # SyncManager repository management # --------------------------------------------------------------------------- class TestSyncManagerRepositories: def test_register_repository(self, tmp_path): manager = _make_manager(tmp_path) repo_path = tmp_path / "repo" repo_path.mkdir() with patch("radicle_reticulum.sync.RNS.log"): state = manager.register_repository("rad:z3test", repo_path) assert state.repository_id == "rad:z3test" assert state.local_path == repo_path def test_register_same_repo_twice_returns_same_state(self, tmp_path): manager = _make_manager(tmp_path) repo_path = tmp_path / "repo" repo_path.mkdir() with patch("radicle_reticulum.sync.RNS.log"): s1 = manager.register_repository("rad:z3test", repo_path) s2 = manager.register_repository("rad:z3test", repo_path) assert s1 is s2 def test_get_sync_status_returns_none_for_unknown(self, tmp_path): manager = _make_manager(tmp_path) assert manager.get_sync_status("rad:unknown") is None def test_get_sync_status_after_register(self, tmp_path): manager = _make_manager(tmp_path) repo_path = tmp_path / "repo" repo_path.mkdir() with patch("radicle_reticulum.sync.RNS.log"): manager.register_repository("rad:z3test", repo_path) status = manager.get_sync_status("rad:z3test") assert status is not None assert status["repository_id"] == "rad:z3test" assert status["known_peers"] == 0 # --------------------------------------------------------------------------- # Phase 4: speculative push # --------------------------------------------------------------------------- def _make_manager_auto_push(tmp_path: Path) -> SyncManager: identity = RadicleIdentity.generate() mock_router = MagicMock() mock_dest = MagicMock() mock_dest.hash = b"\x00" * 32 mock_dest.hash_hex = "00" * 32 with patch("radicle_reticulum.sync.RNS.Reticulum"), \ patch("radicle_reticulum.sync.LXMF.LXMRouter", return_value=mock_router), \ patch("radicle_reticulum.sync.RNS.log"): mock_router.register_delivery_identity.return_value = mock_dest manager = SyncManager(identity=identity, storage_path=tmp_path, auto_push=True) manager._lxmf_router = mock_router manager._lxmf_destination = mock_dest return manager class TestShouldPushToPeer: def test_no_push_when_peer_up_to_date(self, tmp_path): manager = _make_manager(tmp_path) our_refs = {"refs/heads/main": "a" * 40} peer_refs = {"refs/heads/main": "a" * 40} assert manager._should_push_to_peer(our_refs, peer_refs) is False def test_push_when_peer_behind(self, tmp_path): manager = _make_manager(tmp_path) our_refs = {"refs/heads/main": "b" * 40} peer_refs = {"refs/heads/main": "a" * 40} assert manager._should_push_to_peer(our_refs, peer_refs) is True def test_push_when_peer_missing_ref(self, tmp_path): manager = _make_manager(tmp_path) our_refs = {"refs/heads/main": "a" * 40, "refs/heads/dev": "b" * 40} peer_refs = {"refs/heads/main": "a" * 40} assert manager._should_push_to_peer(our_refs, peer_refs) is True def test_no_push_when_our_refs_empty(self, tmp_path): manager = _make_manager(tmp_path) assert manager._should_push_to_peer({}, {"refs/heads/main": "a" * 40}) is False def test_no_push_when_both_empty(self, tmp_path): manager = _make_manager(tmp_path) assert manager._should_push_to_peer({}, {}) is False class TestSpeculativePush: def _make_announcement(self, repo_id="rad:z3test", refs=None): return RefsAnnouncement( repository_id=repo_id, node_id="did:key:z6Mkpeer", refs=refs or {"refs/heads/main": "a" * 40}, timestamp=1000, ) def test_auto_push_false_does_not_call_maybe_push(self, tmp_path): manager = _make_manager(tmp_path) # auto_push=False by default ann = self._make_announcement() msg = MagicMock() msg.source_hash = b"\x01" * 16 msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_REFS_ANNOUNCE} msg.content = ann.encode() with patch("radicle_reticulum.sync.RNS.log"), \ patch.object(manager, "_maybe_push_to_peer") as mock_push: manager._on_lxmf_delivery(msg) mock_push.assert_not_called() def test_auto_push_true_calls_maybe_push_on_refs_announce(self, tmp_path): manager = _make_manager_auto_push(tmp_path) ann = self._make_announcement() msg = MagicMock() msg.source_hash = b"\x02" * 16 msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_REFS_ANNOUNCE} msg.content = ann.encode() with patch("radicle_reticulum.sync.RNS.log"), \ patch.object(manager, "_maybe_push_to_peer") as mock_push: manager._on_lxmf_delivery(msg) mock_push.assert_called_once_with(b"\x02" * 16, mock_push.call_args[0][1]) def test_auto_push_skipped_when_source_hash_none(self, tmp_path): manager = _make_manager_auto_push(tmp_path) ann = self._make_announcement() msg = MagicMock() msg.source_hash = None msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_REFS_ANNOUNCE} msg.content = ann.encode() with patch("radicle_reticulum.sync.RNS.log"), \ patch.object(manager, "_maybe_push_to_peer") as mock_push: manager._on_lxmf_delivery(msg) mock_push.assert_not_called() def test_maybe_push_skips_unknown_repository(self, tmp_path): manager = _make_manager_auto_push(tmp_path) ann = self._make_announcement(repo_id="rad:unknown") with patch("radicle_reticulum.sync.RNS.log"), \ patch("radicle_reticulum.sync.GitBundleGenerator") as mock_gen: manager._maybe_push_to_peer(b"\x03" * 16, ann) mock_gen.assert_not_called() def test_maybe_push_skips_when_peer_up_to_date(self, tmp_path): manager = _make_manager_auto_push(tmp_path) repo_path = tmp_path / "repo" repo_path.mkdir() with patch("radicle_reticulum.sync.RNS.log"): manager.register_repository("rad:z3test", repo_path) our_refs = {"refs/heads/main": "a" * 40} peer_refs = {"refs/heads/main": "a" * 40} ann = self._make_announcement(refs=peer_refs) with patch("radicle_reticulum.sync.RNS.log"), \ patch("radicle_reticulum.sync.GitBundleGenerator") as mock_gen_cls: mock_gen = MagicMock() mock_gen_cls.return_value = mock_gen mock_gen.get_refs.return_value = our_refs manager._maybe_push_to_peer(b"\x04" * 16, ann) mock_gen.create_incremental_bundle.assert_not_called() def test_maybe_push_sends_bundle_when_ahead(self, tmp_path): manager = _make_manager_auto_push(tmp_path) repo_path = tmp_path / "repo" repo_path.mkdir() with patch("radicle_reticulum.sync.RNS.log"): manager.register_repository("rad:z3test", repo_path) our_refs = {"refs/heads/main": "b" * 40} peer_refs = {"refs/heads/main": "a" * 40} ann = self._make_announcement(refs=peer_refs) bundle = _make_bundle(b"incremental data") with patch("radicle_reticulum.sync.RNS.log"), \ patch("radicle_reticulum.sync.GitBundleGenerator") as mock_gen_cls, \ patch.object(manager, "_send_lxmf_message", return_value=True) as mock_send: mock_gen = MagicMock() mock_gen_cls.return_value = mock_gen mock_gen.get_refs.return_value = our_refs mock_gen.create_incremental_bundle.return_value = bundle manager._maybe_push_to_peer(b"\x05" * 16, ann) mock_send.assert_called_once() call_args = mock_send.call_args[0] assert call_args[0] == b"\x05" * 16 assert call_args[1] == CONTENT_TYPE_BUNDLE