From 28997b8077a5a4e5d2580911bfb84b79407fd6b3 Mon Sep 17 00:00:00 2001 From: Dave Miner Date: Tue, 13 Jan 2026 00:51:54 -0500 Subject: [PATCH] chore: chain sync tests --- lib/xander/chain_sync/intersection.ex | 2 +- test/xander/chain_sync/intersection_test.exs | 317 +++++++++++++ test/xander/chain_sync/ledger_test.exs | 81 ++++ test/xander/chain_sync/response_test.exs | 339 +++++++++++++ test/xander/chain_sync_test.exs | 473 +++++++++++++++++++ 5 files changed, 1211 insertions(+), 1 deletion(-) create mode 100644 test/xander/chain_sync/intersection_test.exs create mode 100644 test/xander/chain_sync/ledger_test.exs create mode 100644 test/xander/chain_sync/response_test.exs create mode 100644 test/xander/chain_sync_test.exs diff --git a/lib/xander/chain_sync/intersection.ex b/lib/xander/chain_sync/intersection.ex index 424496d..1b13cf6 100644 --- a/lib/xander/chain_sync/intersection.ex +++ b/lib/xander/chain_sync/intersection.ex @@ -60,7 +60,7 @@ defmodule Xander.ChainSync.Intersection do end {:error, reason} -> - Logger.warning("Find intersection response failed: #{inspect(reason)}") + Logger.error("Find intersection response failed: #{inspect(reason)}") {:error, reason} end end diff --git a/test/xander/chain_sync/intersection_test.exs b/test/xander/chain_sync/intersection_test.exs new file mode 100644 index 0000000..7094c4c --- /dev/null +++ b/test/xander/chain_sync/intersection_test.exs @@ -0,0 +1,317 @@ +defmodule Xander.ChainSync.IntersectionTest do + use ExUnit.Case, async: true + + import ExUnit.CaptureLog + + alias Xander.ChainSync.Intersection + alias Xander.ChainSync.Ledger + alias Xander.ChainSync.Ledger.IntersectionTarget + alias Xander.Transport + + defmodule FakeClient do + def send(_socket, _data), do: :ok + + def recv(socket, _length, _timeout) do + case socket do + {:test_response, response} -> {:ok, response} + {:test_error, reason} -> {:error, reason} + end + end + end + + defp test_transport do + %Transport{ + type: :socket, + client: FakeClient, + setopts_module: :inet, + path: {:local, "/tmp/test.socket"}, + port: 0, + connect_opts: [] + } + end + + defp build_roll_backward_response(tip_slot, tip_block_bytes) do + # Empty point represents origin + point = [] + + # Build tip block + tip = [ + [tip_slot, %CBOR.Tag{tag: :bytes, value: tip_block_bytes}], + 100 + ] + + cbor_payload = CBOR.encode([3, point, tip]) + + # Wrap in multiplexer format: timestamp (4 bytes) + mode/protocol (2 bytes) + size (2 bytes) + payload + timestamp = <<0, 0, 0, 0>> + mode_and_protocol = <<0, 2>> + size = <> + timestamp <> mode_and_protocol <> size <> cbor_payload + end + + defp build_intersect_found_response(point_slot, point_block_bytes, tip_slot, tip_block_bytes) do + point = [ + point_slot, + %CBOR.Tag{tag: :bytes, value: point_block_bytes} + ] + + tip = [ + [tip_slot, %CBOR.Tag{tag: :bytes, value: tip_block_bytes}], + 200 + ] + + cbor_payload = CBOR.encode([5, point, tip]) + + timestamp = <<0, 0, 0, 0>> + mode_and_protocol = <<0, 2>> + size = <> + timestamp <> mode_and_protocol <> size <> cbor_payload + end + + defp build_origin_intersect_found_response(tip_slot, tip_block_bytes) do + # Empty point represents origin + point = [] + + tip = [ + [tip_slot, %CBOR.Tag{tag: :bytes, value: tip_block_bytes}], + 200 + ] + + cbor_payload = CBOR.encode([5, point, tip]) + + timestamp = <<0, 0, 0, 0>> + mode_and_protocol = <<0, 2>> + size = <> + timestamp <> mode_and_protocol <> size <> cbor_payload + end + + defp build_invalid_response do + cbor_payload = CBOR.encode([99, "invalid", "data"]) + + timestamp = <<0, 0, 0, 0>> + mode_and_protocol = <<0, 2>> + size = <> + timestamp <> mode_and_protocol <> size <> cbor_payload + end + + describe "find_target/3" do + test "returns conway boundary target when sync_from is :conway" do + tip_slot = 150_000_000 + tip_block_bytes = :crypto.strong_rand_bytes(32) + response = build_roll_backward_response(tip_slot, tip_block_bytes) + + transport = test_transport() + socket = {:test_response, response} + + assert {:ok, target} = Intersection.find_target(transport, socket, :conway) + assert target == Ledger.conway_boundary_target() + assert %IntersectionTarget{slot: 133_660_799} = target + end + + test "returns genesis target when sync_from is :origin" do + tip_slot = 150_000_000 + tip_block_bytes = :crypto.strong_rand_bytes(32) + response = build_roll_backward_response(tip_slot, tip_block_bytes) + + transport = test_transport() + socket = {:test_response, response} + + assert {:ok, target} = Intersection.find_target(transport, socket, :origin) + assert target == Ledger.genesis_target() + assert %IntersectionTarget{slot: nil, block_hash: nil, block_bytes: nil} = target + end + + test "returns custom point target when sync_from is a tuple {slot, hash}" do + tip_slot = 150_000_000 + tip_block_bytes = :crypto.strong_rand_bytes(32) + response = build_roll_backward_response(tip_slot, tip_block_bytes) + + custom_slot = 100_000 + custom_hash = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + + transport = test_transport() + socket = {:test_response, response} + + assert {:ok, target} = + Intersection.find_target(transport, socket, {custom_slot, custom_hash}) + + assert target == Ledger.custom_point_target(custom_slot, custom_hash) + assert %IntersectionTarget{slot: ^custom_slot, block_hash: ^custom_hash} = target + end + + test "returns target from tip when sync_from is any other value" do + tip_slot = 150_000_000 + tip_block_bytes = :crypto.strong_rand_bytes(32) + tip_hash = Base.encode16(tip_block_bytes, case: :lower) + response = build_roll_backward_response(tip_slot, tip_block_bytes) + + transport = test_transport() + socket = {:test_response, response} + + # Using :latest as an example of "any other value" + assert {:ok, target} = Intersection.find_target(transport, socket, :latest) + + assert %IntersectionTarget{slot: ^tip_slot, block_hash: ^tip_hash} = target + end + + test "returns target from tip when sync_from is nil" do + tip_slot = 200_000_000 + tip_block_bytes = :crypto.strong_rand_bytes(32) + tip_hash = Base.encode16(tip_block_bytes, case: :lower) + response = build_roll_backward_response(tip_slot, tip_block_bytes) + + transport = test_transport() + socket = {:test_response, response} + + assert {:ok, target} = Intersection.find_target(transport, socket, nil) + + assert %IntersectionTarget{slot: ^tip_slot, block_hash: ^tip_hash} = target + end + + test "returns error when transport recv fails" do + transport = test_transport() + socket = {:test_error, :timeout} + + log = + capture_log(fn -> + assert {:error, :error_finding_initial_intersection} = + Intersection.find_target(transport, socket, :conway) + end) + + assert log =~ "Error finding initial intersection" + assert log =~ "timeout" + end + + test "returns error when response parsing fails" do + response = build_invalid_response() + + transport = test_transport() + socket = {:test_response, response} + + log = + capture_log(fn -> + assert {:error, :error_finding_initial_intersection} = + Intersection.find_target(transport, socket, :conway) + end) + + assert log =~ "Error finding initial intersection" + end + end + + describe "find_intersection/4" do + test "returns IntersectFound when intersection matches requested slot and bytes" do + target_slot = 133_660_799 + + target_block_bytes = + Base.decode16!("e757d57eb8dc9500a61c60a39fadb63d9be6973ba96ae337fd24453d4d15c343", + case: :lower + ) + + tip_slot = 150_000_000 + tip_block_bytes = :crypto.strong_rand_bytes(32) + + response = + build_intersect_found_response(target_slot, target_block_bytes, tip_slot, tip_block_bytes) + + transport = test_transport() + socket = {:test_response, response} + + assert {:ok, intersect} = + Intersection.find_intersection(transport, socket, target_slot, target_block_bytes) + + assert intersect.point.slot_number == target_slot + assert intersect.point.bytes == target_block_bytes + assert intersect.tip.slot_number == tip_slot + end + + test "returns IntersectFound when syncing from origin (empty point)" do + tip_slot = 150_000_000 + tip_block_bytes = :crypto.strong_rand_bytes(32) + + response = build_origin_intersect_found_response(tip_slot, tip_block_bytes) + + transport = test_transport() + socket = {:test_response, response} + + # When syncing from origin, slot is nil and bytes is nil + assert {:ok, intersect} = + Intersection.find_intersection(transport, socket, nil, nil) + + assert intersect.tip.slot_number == tip_slot + end + + test "returns error when intersection is not found" do + response = build_invalid_response() + + transport = test_transport() + socket = {:test_response, response} + + target_slot = 100_000 + target_bytes = :crypto.strong_rand_bytes(32) + + assert {:error, :intersection_not_found} = + Intersection.find_intersection(transport, socket, target_slot, target_bytes) + end + + test "returns error when transport recv fails" do + transport = test_transport() + socket = {:test_error, :closed} + + target_slot = 100_000 + target_bytes = :crypto.strong_rand_bytes(32) + + log = + capture_log(fn -> + assert {:error, :closed} = + Intersection.find_intersection(transport, socket, target_slot, target_bytes) + end) + + assert log =~ "Find intersection response failed" + assert log =~ "closed" + end + + test "returns error when transport times out" do + transport = test_transport() + socket = {:test_error, :timeout} + + target_slot = 100_000 + target_bytes = :crypto.strong_rand_bytes(32) + + log = + capture_log(fn -> + assert {:error, :timeout} = + Intersection.find_intersection(transport, socket, target_slot, target_bytes) + end) + + assert log =~ "Find intersection response failed" + end + + test "returns intersection when point slot matches but different origin format" do + # Test the case where IntersectFound matches but not the specific pattern match + # This tests the second clause: {:ok, %IntersectFound{} = intersect} + tip_slot = 150_000_000 + tip_block_bytes = :crypto.strong_rand_bytes(32) + + # Build response with different slot/bytes than requested + response_slot = 99_999 + response_bytes = :crypto.strong_rand_bytes(32) + + response = + build_intersect_found_response(response_slot, response_bytes, tip_slot, tip_block_bytes) + + transport = test_transport() + socket = {:test_response, response} + + # Request with different slot/bytes - should still return the intersect found + # because it matches the IntersectFound pattern but not the specific slot/bytes + requested_slot = 100_000 + requested_bytes = :crypto.strong_rand_bytes(32) + + assert {:ok, intersect} = + Intersection.find_intersection(transport, socket, requested_slot, requested_bytes) + + # The response point should be what was in the response, not what was requested + assert intersect.point.slot_number == response_slot + end + end +end diff --git a/test/xander/chain_sync/ledger_test.exs b/test/xander/chain_sync/ledger_test.exs new file mode 100644 index 0000000..2147ee4 --- /dev/null +++ b/test/xander/chain_sync/ledger_test.exs @@ -0,0 +1,81 @@ +defmodule Xander.ChainSync.LedgerTest do + use ExUnit.Case, async: true + + alias Xander.ChainSync.Ledger + alias Xander.ChainSync.Ledger.IntersectionTarget + + describe "conway_boundary_target/0" do + test "returns the last babbage block as intersection target" do + target = Ledger.conway_boundary_target() + + assert %IntersectionTarget{} = target + assert target.slot == 133_660_799 + + assert target.block_hash == + "e757d57eb8dc9500a61c60a39fadb63d9be6973ba96ae337fd24453d4d15c343" + end + + test "block_bytes is the decoded hex of block_hash" do + target = Ledger.conway_boundary_target() + + expected_bytes = Base.decode16!(target.block_hash, case: :lower) + assert target.block_bytes == expected_bytes + end + + test "block_bytes is 32 bytes (256 bits)" do + target = Ledger.conway_boundary_target() + + assert byte_size(target.block_bytes) == 32 + end + end + + describe "custom_point_target/2" do + test "creates intersection target with given slot and hash" do + slot = 100_000 + hash = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + + target = Ledger.custom_point_target(slot, hash) + + assert %IntersectionTarget{} = target + assert target.slot == slot + assert target.block_hash == hash + end + + test "decodes block_hash to block_bytes" do + slot = 50_000 + hash = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" + + target = Ledger.custom_point_target(slot, hash) + + expected_bytes = Base.decode16!(hash, case: :lower) + assert target.block_bytes == expected_bytes + end + + test "requires lowercase hex hash" do + slot = 75_000 + uppercase_hash = "ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890" + + # Function uses case: :lower, so uppercase will raise + assert_raise ArgumentError, fn -> + Ledger.custom_point_target(slot, uppercase_hash) + end + end + end + + describe "genesis_target/0" do + test "returns intersection target with all nil values" do + target = Ledger.genesis_target() + + assert %IntersectionTarget{} = target + assert target.slot == nil + assert target.block_hash == nil + assert target.block_bytes == nil + end + + test "represents the origin/genesis point" do + target = Ledger.genesis_target() + + assert target == %IntersectionTarget{slot: nil, block_hash: nil, block_bytes: nil} + end + end +end diff --git a/test/xander/chain_sync/response_test.exs b/test/xander/chain_sync/response_test.exs new file mode 100644 index 0000000..2edb44d --- /dev/null +++ b/test/xander/chain_sync/response_test.exs @@ -0,0 +1,339 @@ +defmodule Xander.ChainSync.ResponseTest do + use ExUnit.Case, async: true + + import ExUnit.CaptureLog + + alias Xander.ChainSync.Response + + alias Xander.ChainSync.Response.{ + AwaitReply, + Block, + Header, + IntersectFound, + RollBackward, + RollForward + } + + @encoded_cbor_data_tag 24 + + describe "decode/1" do + test "decodes AwaitReply response" do + cbor_data = CBOR.encode([1]) + + assert {:ok, %AwaitReply{}} = Response.decode(cbor_data) + end + + test "decodes RollForward response with header and tip" do + # Conway era header + block_number = 12345 + block_body_size = 6789 + + header_content = [ + 0, + [[[block_number, 1, 2, 3, 4, 5, block_body_size, 7, 8], []], []] + ] + + header_bytes = CBOR.encode(header_content) + + header = %CBOR.Tag{ + tag: @encoded_cbor_data_tag, + value: %CBOR.Tag{ + tag: :bytes, + value: header_bytes + } + } + + # Build tip block + slot_number = 54321 + block_bytes = :crypto.strong_rand_bytes(32) + + tip = [ + [slot_number, %CBOR.Tag{tag: :bytes, value: block_bytes}], + 100 + ] + + cbor_data = CBOR.encode([2, header, tip]) + + assert {:ok, %RollForward{header: parsed_header, tip: parsed_tip}} = + Response.decode(cbor_data) + + assert %Header{ + block_number: ^block_number, + block_body_size: ^block_body_size, + bytes: ^header_bytes + } = parsed_header + + assert %Block{ + slot_number: ^slot_number, + bytes: ^block_bytes + } = parsed_tip + + assert parsed_tip.hash == Base.encode16(block_bytes, case: :lower) + end + + test "decodes RollBackward response with point and tip" do + # Build point + point_slot_number = 11111 + point_block_bytes = :crypto.strong_rand_bytes(32) + + point = [ + point_slot_number, + %CBOR.Tag{tag: :bytes, value: point_block_bytes} + ] + + # Build tip block + tip_slot_number = 22222 + tip_block_bytes = :crypto.strong_rand_bytes(32) + + tip = [ + [tip_slot_number, %CBOR.Tag{tag: :bytes, value: tip_block_bytes}], + 200 + ] + + cbor_data = CBOR.encode([3, point, tip]) + + assert {:ok, %RollBackward{point: parsed_point, tip: parsed_tip}} = + Response.decode(cbor_data) + + assert %Block{ + slot_number: ^point_slot_number, + bytes: ^point_block_bytes + } = parsed_point + + assert parsed_point.hash == Base.encode16(point_block_bytes, case: :lower) + + assert %Block{ + slot_number: ^tip_slot_number, + bytes: ^tip_block_bytes + } = parsed_tip + end + + test "decodes RollBackward with empty point (origin)" do + # Empty point represents origin + point = [] + + # Build tip block + tip_slot_number = 33333 + tip_block_bytes = :crypto.strong_rand_bytes(32) + + tip = [ + [tip_slot_number, %CBOR.Tag{tag: :bytes, value: tip_block_bytes}], + 300 + ] + + cbor_data = CBOR.encode([3, point, tip]) + + assert {:ok, %RollBackward{point: parsed_point, tip: parsed_tip}} = + Response.decode(cbor_data) + + # Empty point returns empty map + assert parsed_point == %{} + + assert %Block{slot_number: ^tip_slot_number} = parsed_tip + end + + test "decodes IntersectFound response" do + # Build point + point_slot_number = 44444 + point_block_bytes = :crypto.strong_rand_bytes(32) + + point = [ + point_slot_number, + %CBOR.Tag{tag: :bytes, value: point_block_bytes} + ] + + # Build tip block + tip_slot_number = 55555 + tip_block_bytes = :crypto.strong_rand_bytes(32) + + tip = [ + [tip_slot_number, %CBOR.Tag{tag: :bytes, value: tip_block_bytes}], + 400 + ] + + cbor_data = CBOR.encode([5, point, tip]) + + assert {:ok, %IntersectFound{point: parsed_point, tip: parsed_tip}} = + Response.decode(cbor_data) + + assert %Block{ + slot_number: ^point_slot_number, + bytes: ^point_block_bytes + } = parsed_point + + assert %Block{ + slot_number: ^tip_slot_number, + bytes: ^tip_block_bytes + } = parsed_tip + end + + test "returns error for unknown response type" do + cbor_data = CBOR.encode([99, "some", "data"]) + + assert {:error, :unknown_response} = Response.decode(cbor_data) + end + + test "returns error for incomplete CBOR data" do + # Incomplete array (says 3 items but only has 2) + incomplete_cbor = <<0x83, 0x01, 0x02>> + + assert {:error, :incomplete_cbor_data} = Response.decode(incomplete_cbor) + end + end + + describe "parse_response/1" do + test "parses a valid multiplexed response" do + # Create the CBOR payload (AwaitReply) + cbor_payload = CBOR.encode([1]) + + # Wrap in multiplexer format: timestamp (4 bytes) + mode/protocol (2 bytes) + size (2 bytes) + payload + timestamp = <<0, 0, 0, 0>> + mode_and_protocol = <<0, 2>> + size = <> + multiplexed_response = timestamp <> mode_and_protocol <> size <> cbor_payload + + assert {:ok, %AwaitReply{}} = Response.parse_response(multiplexed_response) + end + + test "parses a multiplexed RollForward response" do + block_number = 99999 + block_body_size = 88888 + + header_content = [ + 0, + [[[block_number, 1, 2, 3, 4, 5, block_body_size, 7, 8], []], []] + ] + + header_bytes = CBOR.encode(header_content) + + header = %CBOR.Tag{ + tag: @encoded_cbor_data_tag, + value: %CBOR.Tag{ + tag: :bytes, + value: header_bytes + } + } + + # Build tip + slot_number = 77777 + block_bytes = :crypto.strong_rand_bytes(32) + + tip = [ + [slot_number, %CBOR.Tag{tag: :bytes, value: block_bytes}], + 500 + ] + + cbor_payload = CBOR.encode([2, header, tip]) + + # Wrap in multiplexer format + timestamp = <<0, 0, 0, 0>> + mode_and_protocol = <<0, 2>> + size = <> + multiplexed_response = timestamp <> mode_and_protocol <> size <> cbor_payload + + assert {:ok, %RollForward{header: parsed_header, tip: parsed_tip}} = + Response.parse_response(multiplexed_response) + + assert parsed_header.block_number == block_number + assert parsed_header.block_body_size == block_body_size + assert parsed_tip.slot_number == slot_number + end + + test "returns error for invalid multiplexed format" do + # Message too short (< 8 bytes) + short_message = <<0, 0, 0, 0, 0>> + + assert {:error, "Failed to parse response"} = Response.parse_response(short_message) + end + + test "returns error for nil input" do + assert {:error, "Failed to parse response"} = Response.parse_response(nil) + end + end + + describe "parse_header error handling" do + test "returns error and logs message for unsupported era header format" do + # Create a header that doesn't match the conway era pattern + unsupported_header_content = [1, 2, 3] + header_bytes = CBOR.encode(unsupported_header_content) + + header = %CBOR.Tag{ + tag: @encoded_cbor_data_tag, + value: %CBOR.Tag{ + tag: :bytes, + value: header_bytes + } + } + + # Build tip + block_bytes = :crypto.strong_rand_bytes(32) + + tip = [ + [12345, %CBOR.Tag{tag: :bytes, value: block_bytes}], + 100 + ] + + cbor_data = CBOR.encode([2, header, tip]) + + log = + capture_log(fn -> + assert {:error, :unsupported_era} = Response.decode(cbor_data) + end) + + assert log =~ "Could not parse block" + assert log =~ "Only conway era formats are currently supported" + end + + test "returns error for invalid CBOR in header bytes" do + invalid_inner_cbor = <<0x83, 0x01, 0x02>> + + header = %CBOR.Tag{ + tag: @encoded_cbor_data_tag, + value: %CBOR.Tag{ + tag: :bytes, + value: invalid_inner_cbor + } + } + + # Build tip + block_bytes = :crypto.strong_rand_bytes(32) + + tip = [ + [12345, %CBOR.Tag{tag: :bytes, value: block_bytes}], + 100 + ] + + cbor_data = CBOR.encode([2, header, tip]) + assert {:error, _reason} = Response.decode(cbor_data) + end + end + + describe "block hash encoding" do + test "hash is lowercase hex encoded" do + # Create a known block bytes value + block_bytes = <<0xAB, 0xCD, 0xEF, 0x12, 0x34>> + + point_slot_number = 1000 + + point = [ + point_slot_number, + %CBOR.Tag{tag: :bytes, value: block_bytes} + ] + + tip_block_bytes = :crypto.strong_rand_bytes(32) + + tip = [ + [2000, %CBOR.Tag{tag: :bytes, value: tip_block_bytes}], + 100 + ] + + cbor_data = CBOR.encode([5, point, tip]) + + assert {:ok, %IntersectFound{point: parsed_point}} = Response.decode(cbor_data) + + # Verify hash is lowercase hex + assert parsed_point.hash == "abcdef1234" + assert parsed_point.hash == String.downcase(parsed_point.hash) + end + end +end diff --git a/test/xander/chain_sync_test.exs b/test/xander/chain_sync_test.exs new file mode 100644 index 0000000..cc3a3ba --- /dev/null +++ b/test/xander/chain_sync_test.exs @@ -0,0 +1,473 @@ +defmodule Xander.ChainSyncTest do + use ExUnit.Case, async: true + + import ExUnit.CaptureLog + + alias Xander.ChainSync + alias Xander.Transport + + @encoded_cbor_data_tag 24 + + defmodule TestClient do + def connect(_address, _port, _opts) do + {:ok, make_ref()} + end + + def send(_socket, _data), do: :ok + + def recv(socket, _length, _timeout) do + case socket do + {:test_response, response} -> {:ok, response} + {:test_error, reason} -> {:error, reason} + {:test_sequence, responses} when is_list(responses) -> get_next_response(responses) + _ -> {:ok, <<>>} + end + end + + def close(_socket), do: :ok + + defp get_next_response([response | _rest]) do + case response do + {:ok, data} -> {:ok, data} + {:error, reason} -> {:error, reason} + end + end + + defp get_next_response([]), do: {:error, :no_more_responses} + end + + defmodule TestSetoptsModule do + def setopts(_socket, _opts), do: :ok + end + + defmodule TestHandler do + use Xander.ChainSync + + def start_link(opts) do + Xander.ChainSync.start_link(__MODULE__, opts) + end + + @impl Xander.ChainSync + def handle_block(block, state) do + send(state[:test_pid], {:block_received, block}) + {:ok, :next_block, state} + end + + @impl Xander.ChainSync + def handle_rollback(point, state) do + send(state[:test_pid], {:rollback_received, point}) + {:ok, :next_block, state} + end + end + + defmodule CloseAfterBlockHandler do + use Xander.ChainSync + + def start_link(opts) do + Xander.ChainSync.start_link(__MODULE__, opts) + end + + @impl Xander.ChainSync + def handle_block(block, state) do + send(state[:test_pid], {:block_received, block}) + {:close, state} + end + end + + defmodule DefaultRollbackHandler do + use Xander.ChainSync + + def start_link(opts) do + Xander.ChainSync.start_link(__MODULE__, opts) + end + + @impl Xander.ChainSync + def handle_block(_block, state) do + {:ok, :next_block, state} + end + + # Does NOT override handle_rollback - uses the default from __using__ macro + end + + defp test_transport do + %Transport{ + type: :socket, + client: TestClient, + setopts_module: TestSetoptsModule, + path: {:local, "/tmp/test.socket"}, + port: 0, + connect_opts: [] + } + end + + defp build_handshake_accept_response(version \\ 32784, network_magic \\ 764_824_073) do + cbor_payload = CBOR.encode([1, version, [network_magic, %{"query" => true}]]) + wrap_in_multiplexer(cbor_payload, 0) + end + + defp build_await_reply_response do + cbor_payload = CBOR.encode([1]) + wrap_in_multiplexer(cbor_payload, 5) + end + + defp build_roll_forward_response(block_number, block_body_size, tip_slot \\ 150_000_000) do + header_content = [ + 0, + [[[block_number, 1, 2, 3, 4, 5, block_body_size, 7, 8], []], []] + ] + + header_bytes = CBOR.encode(header_content) + + header = %CBOR.Tag{ + tag: @encoded_cbor_data_tag, + value: %CBOR.Tag{ + tag: :bytes, + value: header_bytes + } + } + + tip_block_bytes = :crypto.strong_rand_bytes(32) + + tip = [ + [tip_slot, %CBOR.Tag{tag: :bytes, value: tip_block_bytes}], + 100 + ] + + cbor_payload = CBOR.encode([2, header, tip]) + wrap_in_multiplexer(cbor_payload, 5) + end + + defp build_roll_backward_response(slot_number, block_bytes \\ nil, tip_slot \\ 150_000_000) do + block_bytes = block_bytes || :crypto.strong_rand_bytes(32) + + point = [ + slot_number, + %CBOR.Tag{tag: :bytes, value: block_bytes} + ] + + tip_block_bytes = :crypto.strong_rand_bytes(32) + + tip = [ + [tip_slot, %CBOR.Tag{tag: :bytes, value: tip_block_bytes}], + 100 + ] + + cbor_payload = CBOR.encode([3, point, tip]) + wrap_in_multiplexer(cbor_payload, 5) + end + + defp build_roll_backward_origin_response(tip_slot \\ 150_000_000) do + # Empty point represents origin + point = [] + tip_block_bytes = :crypto.strong_rand_bytes(32) + + tip = [ + [tip_slot, %CBOR.Tag{tag: :bytes, value: tip_block_bytes}], + 100 + ] + + cbor_payload = CBOR.encode([3, point, tip]) + wrap_in_multiplexer(cbor_payload, 5) + end + + defp build_intersect_found_response( + point_slot, + point_block_bytes, + tip_slot \\ 150_000_000 + ) do + point = [ + point_slot, + %CBOR.Tag{tag: :bytes, value: point_block_bytes} + ] + + tip_block_bytes = :crypto.strong_rand_bytes(32) + + tip = [ + [tip_slot, %CBOR.Tag{tag: :bytes, value: tip_block_bytes}], + 200 + ] + + cbor_payload = CBOR.encode([5, point, tip]) + wrap_in_multiplexer(cbor_payload, 5) + end + + defp wrap_in_multiplexer(cbor_payload, protocol_id) do + timestamp = <<0, 0, 0, 0>> + mode_and_protocol = <<0::1, protocol_id::15>> + size = <> + timestamp <> mode_and_protocol <> size <> cbor_payload + end + + describe "__using__/1 macro" do + test "provides default handle_rollback implementation" do + # The default implementation just returns {:ok, :next_block, state} + # Using DefaultRollbackHandler which doesn't override handle_rollback + state = %{some: :state} + point = %{slot_number: 12345, block_hash: "abc123"} + + assert {:ok, :next_block, ^state} = DefaultRollbackHandler.handle_rollback(point, state) + end + + test "generates child_spec/1" do + opts = [network: :mainnet, path: "/tmp/test.socket", type: :socket] + spec = TestHandler.child_spec(opts) + + assert %{ + id: TestHandler, + start: {TestHandler, :start_link, [^opts]}, + type: :worker, + restart: :temporary, + shutdown: 5_000 + } = spec + end + end + + describe "callback_mode/0" do + test "returns :state_functions" do + assert ChainSync.callback_mode() == :state_functions + end + end + + describe "init/1" do + test "initializes to disconnected state with connect action" do + data = %ChainSync{ + client_module: TestHandler, + transport: test_transport(), + network: :mainnet + } + + assert {:ok, :disconnected, ^data, actions} = ChainSync.init(data) + assert [{:next_event, :internal, :connect}] = actions + end + end + + describe "disconnected/3" do + test "transitions to connected state on successful connection" do + transport = test_transport() + + data = %ChainSync{ + client_module: TestHandler, + transport: transport, + network: :mainnet, + socket: nil + } + + result = ChainSync.disconnected(:internal, :connect, data) + + assert {:next_state, :connected, updated_data, actions} = result + assert updated_data.socket != nil + assert [{:next_event, :internal, :establish}] = actions + end + + test "stays in disconnected state on connection error" do + defmodule FailingClient do + def connect(_address, _port, _opts) do + {:error, :econnrefused} + end + end + + transport = %Transport{ + type: :socket, + client: FailingClient, + setopts_module: TestSetoptsModule, + path: {:local, "/tmp/test.socket"}, + port: 0, + connect_opts: [] + } + + data = %ChainSync{ + client_module: TestHandler, + transport: transport, + network: :mainnet + } + + log = + capture_log(fn -> + result = ChainSync.disconnected(:internal, :connect, data) + assert {:next_state, :disconnected, ^data} = result + end) + + assert log =~ "Error reaching socket" + end + + test "returns error when called with call event" do + data = %ChainSync{ + client_module: TestHandler, + transport: test_transport(), + network: :mainnet + } + + from = {self(), make_ref()} + result = ChainSync.disconnected({:call, from}, :some_command, data) + + assert {:keep_state, ^data, actions} = result + assert [{:reply, ^from, {:error, :disconnected}}] = actions + end + end + + describe "start_link/2" do + test "ignores start when sync_from is :origin and network is not :yaci_devkit" do + log = + capture_log(fn -> + result = + ChainSync.start_link(TestHandler, + network: :mainnet, + path: "/tmp/test.socket", + port: 0, + type: :socket, + sync_from: :origin + ) + + assert result == :ignore + end) + + assert log =~ + "Syncing from origin is only supported on the yaci_devkit network" + end + + @tag capture_log: true + test "allows sync_from :origin when network is :yaci_devkit" do + # This will fail to actually connect but won't be ignored + result = + ChainSync.start_link(TestHandler, + network: :yaci_devkit, + path: "/tmp/nonexistent.socket", + port: 0, + type: :socket, + sync_from: :origin + ) + + # It will either start or fail to connect, but not be ignored + assert result != :ignore + # Clean up if it started + case result do + {:ok, pid} -> GenServer.stop(pid) + _ -> :ok + end + end + end + + describe "catching_up/3 socket message handling" do + @tag capture_log: true + test "handles tcp_closed during state transition" do + data = %ChainSync{ + client_module: TestHandler, + transport: test_transport(), + socket: make_ref(), + state: [] + } + + result = ChainSync.catching_up(:info, {:tcp_closed, data.socket}, data) + assert {:next_state, :disconnected, ^data} = result + end + + @tag capture_log: true + test "handles ssl_closed during state transition" do + data = %ChainSync{ + client_module: TestHandler, + transport: test_transport(), + socket: make_ref(), + state: [] + } + + result = ChainSync.catching_up(:info, {:ssl_closed, data.socket}, data) + assert {:next_state, :disconnected, ^data} = result + end + end + + describe "response building helpers" do + test "build_handshake_accept_response creates valid response" do + response = build_handshake_accept_response() + assert is_binary(response) + assert byte_size(response) > 8 + + # Parse the multiplexer header + <<_timestamp::32, _mode::1, protocol_id::15, size::big-16, payload::binary>> = response + assert protocol_id == 0 + assert byte_size(payload) == size + end + + test "build_await_reply_response creates valid response" do + response = build_await_reply_response() + assert is_binary(response) + + <<_timestamp::32, _mode::1, protocol_id::15, size::big-16, payload::binary>> = response + assert protocol_id == 5 + assert byte_size(payload) == size + + {:ok, [1], ""} = CBOR.decode(payload) + end + + test "build_roll_forward_response creates valid response" do + response = build_roll_forward_response(12345, 6789) + assert is_binary(response) + + <<_timestamp::32, _mode::1, protocol_id::15, size::big-16, payload::binary>> = response + assert protocol_id == 5 + assert byte_size(payload) == size + + {:ok, [2, _header, _tip], ""} = CBOR.decode(payload) + end + + test "build_roll_backward_response creates valid response" do + response = build_roll_backward_response(100_000) + assert is_binary(response) + + <<_timestamp::32, _mode::1, protocol_id::15, size::big-16, payload::binary>> = response + assert protocol_id == 5 + assert byte_size(payload) == size + + {:ok, [3, _point, _tip], ""} = CBOR.decode(payload) + end + + test "build_roll_backward_origin_response creates empty point" do + response = build_roll_backward_origin_response() + assert is_binary(response) + + <<_timestamp::32, _mode::1, _protocol_id::15, _size::big-16, payload::binary>> = response + + {:ok, [3, point, _tip], ""} = CBOR.decode(payload) + assert point == [] + end + + test "build_intersect_found_response creates valid response" do + block_bytes = :crypto.strong_rand_bytes(32) + response = build_intersect_found_response(100_000, block_bytes) + assert is_binary(response) + + <<_timestamp::32, _mode::1, protocol_id::15, size::big-16, payload::binary>> = response + assert protocol_id == 5 + assert byte_size(payload) == size + + {:ok, [5, _point, _tip], ""} = CBOR.decode(payload) + end + end + + describe "ChainSync behaviour callbacks" do + test "handle_block callback signature is correct" do + block = %{block_number: 100, size: 500} + state = %{test_pid: self()} + + assert {:ok, :next_block, _new_state} = TestHandler.handle_block(block, state) + assert_receive {:block_received, ^block} + end + + test "handle_rollback callback can be overridden" do + point = %{slot_number: 12345, block_hash: "abc123"} + state = %{test_pid: self()} + + assert {:ok, :next_block, _new_state} = TestHandler.handle_rollback(point, state) + assert_receive {:rollback_received, ^point} + end + end + + describe "CloseAfterBlockHandler" do + test "returns close tuple from handle_block" do + block = %{block_number: 100, size: 500} + state = %{test_pid: self()} + + assert {:close, ^state} = CloseAfterBlockHandler.handle_block(block, state) + assert_receive {:block_received, ^block} + end + end +end