"""Tests for QR bundle encoding/decoding.""" import hashlib import pytest from radicle_reticulum.git_bundle import GitBundle, BundleMetadata, BundleType from radicle_reticulum.qr import ( encode_bundle_to_qr, decode_bundle_from_qr_data, BundleTooLargeForQR, QR_MAX_BYTES, QR_MAGIC, ) def make_small_bundle(data: bytes = b"tiny git bundle data") -> GitBundle: """Create a minimal GitBundle for testing.""" metadata = BundleMetadata( bundle_type=BundleType.INCREMENTAL, repository_id="rad:z3test", source_node="did:key:z6Mktest", timestamp=1000, refs_included=["refs/heads/main"], prerequisites=["abc123"], size_bytes=len(data), checksum=hashlib.sha256(data).digest(), ) return GitBundle(metadata=metadata, data=data) class TestQRPayloadRoundtrip: """Test the binary payload encode/decode without QR rendering.""" def _encode_payload(self, bundle: GitBundle) -> bytes: """Extract the raw bytes that would be put into a QR code.""" import struct bundle_bytes = bundle.encode() checksum = hashlib.sha256(bundle_bytes).digest() length_prefix = len(bundle_bytes).to_bytes(4, "big") return QR_MAGIC + length_prefix + checksum + bundle_bytes def test_decode_roundtrip(self): bundle = make_small_bundle() payload = self._encode_payload(bundle) decoded = decode_bundle_from_qr_data(payload) assert decoded.metadata.repository_id == bundle.metadata.repository_id assert decoded.data == bundle.data def test_decode_rejects_wrong_magic(self): bundle = make_small_bundle() payload = b"WRONGMAGIC" + self._encode_payload(bundle)[len(QR_MAGIC):] with pytest.raises(ValueError, match="Not a Radicle QR payload"): decode_bundle_from_qr_data(payload) def test_decode_detects_corruption(self): bundle = make_small_bundle() payload = bytearray(self._encode_payload(bundle)) payload[-1] ^= 0xFF # flip last bit of bundle data with pytest.raises(ValueError, match="checksum mismatch"): decode_bundle_from_qr_data(bytes(payload)) def test_decode_rejects_truncated_payload(self): bundle = make_small_bundle() payload = self._encode_payload(bundle) # Truncate the bundle data portion truncated = payload[:-10] with pytest.raises(ValueError, match="Truncated"): decode_bundle_from_qr_data(truncated) class TestBundleTooLarge: def test_oversized_bundle_raises(self): large_data = b"x" * (QR_MAX_BYTES + 1) bundle = make_small_bundle(data=large_data) with pytest.raises(BundleTooLargeForQR, match="QR capacity"): encode_bundle_to_qr(bundle) def test_exact_limit_would_include_overhead(self): # QR_MAX_BYTES is the limit for the serialised bundle, including metadata. # A bundle with data just under the limit should still fail due to metadata overhead. # This test verifies the check catches realistic oversized bundles. oversized_data = b"a" * QR_MAX_BYTES bundle = make_small_bundle(data=oversized_data) with pytest.raises(BundleTooLargeForQR): encode_bundle_to_qr(bundle) class TestQREncodeOutput: """Test QR rendering (requires qrcode package).""" def test_encode_returns_ascii_art(self): bundle = make_small_bundle() result = encode_bundle_to_qr(bundle) assert isinstance(result, str) assert len(result) > 0 # ASCII art QR codes use block characters assert any(c in result for c in ("█", "░", " ", "\n")) def test_encode_small_bundle_succeeds(self): bundle = make_small_bundle(b"small") result = encode_bundle_to_qr(bundle) assert result # non-empty def test_error_correction_levels(self): bundle = make_small_bundle() for level in ("L", "M", "Q", "H"): result = encode_bundle_to_qr(bundle, error_correction=level) assert isinstance(result, str)