"""Tests for i2p_data.data_helper — I2P wire format serialization utilities.""" import io from datetime import datetime, timezone import pytest from i2p_data.data_helper import ( from_base32, from_base64, from_date, read_long, read_properties, read_string, to_base32, to_base64, to_date, write_long, write_properties, write_string, ) # --------------------------------------------------------------------------- # read_long / write_long # --------------------------------------------------------------------------- class TestReadWriteLong: """Roundtrip and edge-case tests for read_long / write_long.""" @pytest.mark.parametrize("num_bytes,value", [ (1, 0), (1, 127), (1, 255), (2, 0), (2, 256), (2, 0xFFFF), (4, 0), (4, 1), (4, 0xFFFFFFFF), (8, 0), (8, 1), (8, 0xFFFFFFFFFFFFFFFF), ]) def test_roundtrip(self, num_bytes: int, value: int) -> None: buf = io.BytesIO() write_long(buf, num_bytes, value) buf.seek(0) assert read_long(buf, num_bytes) == value def test_read_known_bytes(self) -> None: """Read a known big-endian 2-byte value: 0x0102 == 258.""" buf = io.BytesIO(b"\x01\x02") assert read_long(buf, 2) == 258 def test_write_known_bytes(self) -> None: """Write 258 as 2-byte big-endian and verify raw bytes.""" buf = io.BytesIO() write_long(buf, 2, 258) assert buf.getvalue() == b"\x01\x02" def test_read_short_stream(self) -> None: """Reading more bytes than available raises ValueError.""" buf = io.BytesIO(b"\x01") with pytest.raises(ValueError, match="Expected 2 bytes, got 1"): read_long(buf, 2) def test_read_empty_stream(self) -> None: buf = io.BytesIO(b"") with pytest.raises(ValueError, match="Expected 1 bytes, got 0"): read_long(buf, 1) def test_sequential_writes_and_reads(self) -> None: """Multiple values written sequentially are read back correctly.""" buf = io.BytesIO() write_long(buf, 1, 0xAB) write_long(buf, 4, 0xDEADBEEF) write_long(buf, 2, 0x1234) buf.seek(0) assert read_long(buf, 1) == 0xAB assert read_long(buf, 4) == 0xDEADBEEF assert read_long(buf, 2) == 0x1234 # --------------------------------------------------------------------------- # read_string / write_string # --------------------------------------------------------------------------- class TestReadWriteString: """Roundtrip and edge-case tests for read_string / write_string.""" def test_empty_string(self) -> None: buf = io.BytesIO() write_string(buf, "") buf.seek(0) assert read_string(buf) == "" def test_ascii_roundtrip(self) -> None: buf = io.BytesIO() write_string(buf, "hello") buf.seek(0) assert read_string(buf) == "hello" def test_utf8_multibyte(self) -> None: """Multi-byte UTF-8 characters (length prefix counts bytes, not chars).""" text = "\u00e9\u00e8\u00ea" # e-acute, e-grave, e-circumflex (2 bytes each) buf = io.BytesIO() write_string(buf, text) raw = buf.getvalue() # Length prefix should be 6 (3 chars x 2 bytes each) assert raw[0] == 6 buf.seek(0) assert read_string(buf) == text def test_max_length_string(self) -> None: """String of exactly 255 bytes is accepted.""" text = "a" * 255 buf = io.BytesIO() write_string(buf, text) buf.seek(0) assert read_string(buf) == text def test_too_long_string(self) -> None: """String longer than 255 bytes raises ValueError.""" text = "a" * 256 with pytest.raises(ValueError, match="String too long"): buf = io.BytesIO() write_string(buf, text) def test_wire_format(self) -> None: """Verify raw wire format: 1-byte length + UTF-8 payload.""" buf = io.BytesIO() write_string(buf, "AB") assert buf.getvalue() == b"\x02AB" def test_read_truncated_string(self) -> None: """Truncated payload raises ValueError.""" # Length says 5 but only 2 bytes of payload buf = io.BytesIO(b"\x05AB") with pytest.raises(ValueError, match="Expected 5 bytes for string, got 2"): read_string(buf) # --------------------------------------------------------------------------- # read_properties / write_properties # --------------------------------------------------------------------------- class TestReadWriteProperties: """Roundtrip and edge-case tests for read_properties / write_properties.""" def test_empty_dict(self) -> None: buf = io.BytesIO() write_properties(buf, {}) buf.seek(0) assert read_properties(buf) == {} def test_empty_dict_wire_format(self) -> None: """Empty properties writes 2-byte zero length.""" buf = io.BytesIO() write_properties(buf, {}) assert buf.getvalue() == b"\x00\x00" def test_single_property(self) -> None: buf = io.BytesIO() write_properties(buf, {"key": "value"}) buf.seek(0) assert read_properties(buf) == {"key": "value"} def test_multiple_properties(self) -> None: props = {"alpha": "1", "beta": "2", "gamma": "3"} buf = io.BytesIO() write_properties(buf, props) buf.seek(0) assert read_properties(buf) == props def test_sorted_key_order(self) -> None: """Keys are written in sorted order.""" props = {"z": "last", "a": "first", "m": "middle"} buf = io.BytesIO() write_properties(buf, props) raw = buf.getvalue() # Skip 2-byte length prefix, decode payload payload = raw[2:].decode("utf-8") lines = [l for l in payload.split("\n") if l.strip()] keys = [l.split("=")[0] for l in lines] assert keys == ["a", "m", "z"] def test_value_with_equals(self) -> None: """Values containing '=' are preserved (split on first '=' only).""" props = {"url": "http://example.com?a=1&b=2"} buf = io.BytesIO() write_properties(buf, props) buf.seek(0) assert read_properties(buf) == props def test_wire_format_semicolons(self) -> None: """Each property line ends with ';\\n' in the wire format.""" buf = io.BytesIO() write_properties(buf, {"k": "v"}) raw = buf.getvalue() payload = raw[2:].decode("utf-8") assert payload == "k=v;\n" def test_read_truncated_properties(self) -> None: """Truncated payload raises ValueError.""" # Length says 100 but we only have 3 bytes buf = io.BytesIO(b"\x00\x64abc") with pytest.raises(ValueError, match="Expected 100 bytes for properties"): read_properties(buf) # --------------------------------------------------------------------------- # to_date / from_date # --------------------------------------------------------------------------- class TestDateConversion: """Tests for to_date / from_date timestamp conversion.""" def test_epoch_zero(self) -> None: dt = to_date(0) assert dt == datetime(1970, 1, 1, tzinfo=timezone.utc) assert from_date(dt) == 0 def test_known_timestamp(self) -> None: """2025-01-01T00:00:00Z == 1735689600000 ms.""" ms = 1735689600000 dt = to_date(ms) assert dt.year == 2025 assert dt.month == 1 assert dt.day == 1 assert from_date(dt) == ms def test_roundtrip(self) -> None: now = datetime(2026, 3, 17, 12, 0, 0, tzinfo=timezone.utc) ms = from_date(now) assert to_date(ms) == now def test_sub_second_precision(self) -> None: """Millisecond precision is preserved.""" ms = 1735689600123 dt = to_date(ms) recovered = from_date(dt) assert recovered == ms # --------------------------------------------------------------------------- # to_base64 / from_base64 # --------------------------------------------------------------------------- class TestBase64: """Tests for I2P-style Base64 encoding/decoding.""" def test_empty(self) -> None: assert to_base64(b"") == "" assert from_base64("") == b"" def test_roundtrip_short(self) -> None: data = b"\x00\x01\x02\x03" assert from_base64(to_base64(data)) == data def test_roundtrip_long(self) -> None: data = bytes(range(256)) assert from_base64(to_base64(data)) == data def test_padding_in_output(self) -> None: """I2P Base64 preserves '=' padding.""" # 1 byte encodes to 2 Base64 chars + padding encoded = to_base64(b"\x00") assert encoded == "AA==" def test_known_vector(self) -> None: """'Hello' -> 'SGVsbG8=' (I2P Base64 with padding).""" assert to_base64(b"Hello") == "SGVsbG8=" assert from_base64("SGVsbG8") == b"Hello" assert from_base64("SGVsbG8=") == b"Hello" def test_binary_data(self) -> None: data = b"\xff\xfe\xfd\xfc" assert from_base64(to_base64(data)) == data # --------------------------------------------------------------------------- # to_base32 / from_base32 # --------------------------------------------------------------------------- class TestBase32: """Tests for Base32 encoding/decoding.""" def test_empty(self) -> None: assert to_base32(b"") == "" assert from_base32("") == b"" def test_roundtrip(self) -> None: data = b"test data for base32" assert from_base32(to_base32(data)) == data def test_lowercase_output(self) -> None: """I2P Base32 output is lowercase.""" encoded = to_base32(b"\xff\x00") assert encoded == encoded.lower() def test_no_padding(self) -> None: encoded = to_base32(b"\x00\x01\x02") assert "=" not in encoded def test_known_vector(self) -> None: """'Hi' -> base32 'jbuq' (lowercase, no padding).""" encoded = to_base32(b"Hi") assert encoded == "jbuq" assert from_base32("jbuq") == b"Hi" def test_accepts_mixed_case_input(self) -> None: """from_base32 should accept both upper and lower case.""" data = b"CaSe" encoded = to_base32(data) assert from_base32(encoded.upper()) == data assert from_base32(encoded.lower()) == data