go-blockchain/chain/p2psync_test.go
Claude d588f8f8d0
feat(chain): add P2P sync state machine
P2PSync() runs the REQUEST_CHAIN / REQUEST_GET_OBJECTS loop against
a P2PConnection interface. Reuses processBlockBlobs() for shared
validation logic.

Co-Authored-By: Charon <charon@lethean.io>
2026-02-21 21:00:57 +00:00

140 lines
3.4 KiB
Go

// Copyright (c) 2017-2026 Lethean (https://lt.hn)
//
// Licensed under the European Union Public Licence (EUPL) version 1.2.
// SPDX-License-Identifier: EUPL-1.2
package chain
import (
"context"
"fmt"
"testing"
store "forge.lthn.ai/core/go-store"
"forge.lthn.ai/core/go-blockchain/config"
"github.com/stretchr/testify/require"
)
// mockP2PConn implements P2PConnection for testing.
type mockP2PConn struct {
peerHeight uint64
// blocks maps hash -> (blockBlob, txBlobs)
blocks map[string]struct {
blockBlob []byte
txBlobs [][]byte
}
chainHashes [][]byte
// requestChainErr, if set, is returned by RequestChain.
requestChainErr error
// requestObjectsErr, if set, is returned by RequestObjects.
requestObjectsErr error
}
func (m *mockP2PConn) PeerHeight() uint64 { return m.peerHeight }
func (m *mockP2PConn) RequestChain(blockIDs [][]byte) (startHeight uint64, hashes [][]byte, err error) {
if m.requestChainErr != nil {
return 0, nil, m.requestChainErr
}
return 0, m.chainHashes, nil
}
func (m *mockP2PConn) RequestObjects(blockHashes [][]byte) ([]BlockBlobEntry, error) {
if m.requestObjectsErr != nil {
return nil, m.requestObjectsErr
}
var entries []BlockBlobEntry
for _, h := range blockHashes {
key := string(h)
if blk, ok := m.blocks[key]; ok {
entries = append(entries, BlockBlobEntry{
Block: blk.blockBlob,
Txs: blk.txBlobs,
})
}
}
return entries, nil
}
func TestP2PSync_EmptyChain(t *testing.T) {
// Test that P2PSync with a mock that has no blocks is a no-op.
s, err := store.New(":memory:")
require.NoError(t, err)
defer s.Close()
c := New(s)
mock := &mockP2PConn{peerHeight: 0}
opts := SyncOptions{Forks: config.TestnetForks}
err = c.P2PSync(context.Background(), mock, opts)
require.NoError(t, err)
}
func TestP2PSync_ContextCancellation(t *testing.T) {
s, err := store.New(":memory:")
require.NoError(t, err)
defer s.Close()
c := New(s)
mock := &mockP2PConn{peerHeight: 100} // claims 100 blocks but returns none
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
opts := SyncOptions{Forks: config.TestnetForks}
err = c.P2PSync(ctx, mock, opts)
require.ErrorIs(t, err, context.Canceled)
}
func TestP2PSync_NoBlockIDs(t *testing.T) {
// Peer claims a height but returns no block IDs from RequestChain.
s, err := store.New(":memory:")
require.NoError(t, err)
defer s.Close()
c := New(s)
mock := &mockP2PConn{
peerHeight: 10,
chainHashes: nil, // empty response
}
opts := SyncOptions{Forks: config.TestnetForks}
err = c.P2PSync(context.Background(), mock, opts)
require.NoError(t, err)
}
func TestP2PSync_RequestChainError(t *testing.T) {
s, err := store.New(":memory:")
require.NoError(t, err)
defer s.Close()
c := New(s)
mock := &mockP2PConn{
peerHeight: 10,
requestChainErr: fmt.Errorf("connection reset"),
}
opts := SyncOptions{Forks: config.TestnetForks}
err = c.P2PSync(context.Background(), mock, opts)
require.Error(t, err)
require.Contains(t, err.Error(), "request chain")
}
func TestP2PSync_RequestObjectsError(t *testing.T) {
s, err := store.New(":memory:")
require.NoError(t, err)
defer s.Close()
c := New(s)
mock := &mockP2PConn{
peerHeight: 10,
chainHashes: [][]byte{{0x01}},
requestObjectsErr: fmt.Errorf("timeout"),
}
opts := SyncOptions{Forks: config.TestnetForks}
err = c.P2PSync(context.Background(), mock, opts)
require.Error(t, err)
require.Contains(t, err.Error(), "request objects")
}