From c59566d96f1db1daa66bd99803e18550e8d19ce6 Mon Sep 17 00:00:00 2001 From: ginuerzh Date: Sat, 23 May 2026 17:17:38 +0800 Subject: [PATCH] test(chain): add unit tests achieving 86.7% coverage 112 tests covering Transport, Chain, Route, Router, packetConn wrapper, routePath, and all interface satisfaction checks. --- chain/chain_test.go | 1769 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1769 insertions(+) create mode 100644 chain/chain_test.go diff --git a/chain/chain_test.go b/chain/chain_test.go new file mode 100644 index 00000000..f0d3ae2c --- /dev/null +++ b/chain/chain_test.go @@ -0,0 +1,1769 @@ +package chain + +import ( + "context" + "errors" + "net" + "sync" + "testing" + "time" + + "github.com/go-gost/core/chain" + "github.com/go-gost/core/connector" + "github.com/go-gost/core/dialer" + "github.com/go-gost/core/hop" + "github.com/go-gost/core/logger" + "github.com/go-gost/core/metadata" + "github.com/go-gost/core/recorder" + xlogger "github.com/go-gost/x/logger" + xmetadata "github.com/go-gost/x/metadata" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Mock types --- + +// testConn is a minimal net.Conn for testing. +type testConn struct { + closed bool +} + +func (c *testConn) Read(b []byte) (n int, err error) { return 0, nil } +func (c *testConn) Write(b []byte) (n int, err error) { return len(b), nil } +func (c *testConn) Close() error { c.closed = true; return nil } +func (c *testConn) LocalAddr() net.Addr { return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 12345} } +func (c *testConn) RemoteAddr() net.Addr { return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 8080} } +func (c *testConn) SetDeadline(t time.Time) error { return nil } +func (c *testConn) SetReadDeadline(t time.Time) error { return nil } +func (c *testConn) SetWriteDeadline(t time.Time) error { return nil } + +// testDialer implements dialer.Dialer and optionally dialer.Handshaker and +// dialer.Multiplexer. Hook functions allow per-test customization. +type testDialer struct { + dialFn func(ctx context.Context, addr string, opts ...dialer.DialOption) (net.Conn, error) + handshakeFn func(ctx context.Context, conn net.Conn, opts ...dialer.HandshakeOption) (net.Conn, error) + multiplexVal bool + hasMux bool // whether Multiplex() method exists (simulates optional interface) +} + +func (d *testDialer) Init(md metadata.Metadata) error { return nil } + +func (d *testDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOption) (net.Conn, error) { + if d.dialFn != nil { + return d.dialFn(ctx, addr, opts...) + } + return &testConn{}, nil +} + +func (d *testDialer) Handshake(ctx context.Context, conn net.Conn, opts ...dialer.HandshakeOption) (net.Conn, error) { + if d.handshakeFn != nil { + return d.handshakeFn(ctx, conn, opts...) + } + return conn, nil +} + +func (d *testDialer) Multiplex() bool { + return d.hasMux && d.multiplexVal +} + +// plainDialer implements only dialer.Dialer (no Handshaker, no Multiplexer). +type plainDialer struct{} + +func (d *plainDialer) Init(md metadata.Metadata) error { return nil } +func (d *plainDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOption) (net.Conn, error) { + return &testConn{}, nil +} + +// testConnector implements connector.Connector and optionally connector.Handshaker +// and connector.Binder. +type testConnector struct { + connectFn func(ctx context.Context, conn net.Conn, network, address string, opts ...connector.ConnectOption) (net.Conn, error) + handshakeFn func(ctx context.Context, conn net.Conn) (net.Conn, error) + bindFn func(ctx context.Context, conn net.Conn, network, address string, opts ...connector.BindOption) (net.Listener, error) +} + +func (c *testConnector) Init(md metadata.Metadata) error { return nil } + +func (c *testConnector) Connect(ctx context.Context, conn net.Conn, network, address string, opts ...connector.ConnectOption) (net.Conn, error) { + if c.connectFn != nil { + return c.connectFn(ctx, conn, network, address, opts...) + } + return conn, nil +} + +func (c *testConnector) Handshake(ctx context.Context, conn net.Conn) (net.Conn, error) { + if c.handshakeFn != nil { + return c.handshakeFn(ctx, conn) + } + return conn, nil +} + +func (c *testConnector) Bind(ctx context.Context, conn net.Conn, network, address string, opts ...connector.BindOption) (net.Listener, error) { + if c.bindFn != nil { + return c.bindFn(ctx, conn, network, address, opts...) + } + return nil, connector.ErrBindUnsupported +} + +// plainConnector implements only connector.Connector (no Handshaker, no Binder). +type plainConnector struct { + connectFn func(ctx context.Context, conn net.Conn, network, address string, opts ...connector.ConnectOption) (net.Conn, error) +} + +func (c *plainConnector) Init(md metadata.Metadata) error { return nil } +func (c *plainConnector) Connect(ctx context.Context, conn net.Conn, network, address string, opts ...connector.ConnectOption) (net.Conn, error) { + if c.connectFn != nil { + return c.connectFn(ctx, conn, network, address, opts...) + } + return conn, nil +} + +// testHop implements hop.Hop. +type testHop struct { + selectFn func(ctx context.Context, opts ...hop.SelectOption) *chain.Node +} + +func (h *testHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.Node { + if h.selectFn != nil { + return h.selectFn(ctx, opts...) + } + return nil +} + +// trackedConn wraps a net.Conn and reports whether Close was called. +type trackedConn struct { + *testConn + closed bool + closeFn func() // optional, called on Close +} + +func (c *trackedConn) Close() error { + if c.closed { + return nil + } + c.closed = true + if c.closeFn != nil { + c.closeFn() + } + return c.testConn.Close() +} + +type testListener struct { + closed bool +} + +func (l *testListener) Accept() (net.Conn, error) { return &testConn{}, nil } +func (l *testListener) Close() error { l.closed = true; return nil } +func (l *testListener) Addr() net.Addr { return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0} } + +// testRecorder implements recorder.Recorder. +type testRecorder struct { + records []recordCall + mu sync.Mutex +} + +type recordCall struct { + data string +} + +func (r *testRecorder) Record(ctx context.Context, b []byte, opts ...recorder.RecordOption) error { + r.mu.Lock() + defer r.mu.Unlock() + r.records = append(r.records, recordCall{data: string(b)}) + return nil +} + +func init() { + logger.SetDefault(xlogger.Nop()) +} + +// ============================================================================= +// Transport tests +// ============================================================================= + +func TestNewTransport(t *testing.T) { + d := &testDialer{} + c := &testConnector{} + tr := NewTransport(d, c) + require.NotNil(t, tr) + assert.Equal(t, d, tr.dialer) + assert.Equal(t, c, tr.connector) +} + +func TestNewTransport_WithOptions(t *testing.T) { + rt := &chainRoute{} + d := &testDialer{} + c := &testConnector{} + + tr := NewTransport(d, c, + chain.AddrTransportOption("example.com:8080"), + chain.InterfaceTransportOption("eth0"), + chain.NetnsTransportOption("ns1"), + chain.SockOptsTransportOption(&chain.SockOpts{Mark: 42}), + chain.RouteTransportOption(rt), + ) + require.NotNil(t, tr) + assert.Equal(t, "example.com:8080", tr.options.Addr) + assert.Equal(t, "eth0", tr.options.IfceName) + assert.Equal(t, "ns1", tr.options.Netns) + assert.Equal(t, 42, tr.options.SockOpts.Mark) + assert.Equal(t, rt, tr.options.Route) +} + +func TestTransport_Dial_Success(t *testing.T) { + var calledAddr string + d := &testDialer{ + dialFn: func(ctx context.Context, addr string, opts ...dialer.DialOption) (net.Conn, error) { + calledAddr = addr + return &testConn{}, nil + }, + } + tr := NewTransport(d, &testConnector{}) + + conn, err := tr.Dial(context.Background(), "192.168.1.1:80") + require.NoError(t, err) + require.NotNil(t, conn) + assert.Equal(t, "192.168.1.1:80", calledAddr) +} + +func TestTransport_Dial_Error(t *testing.T) { + dialErr := errors.New("dial failed") + d := &testDialer{ + dialFn: func(ctx context.Context, addr string, opts ...dialer.DialOption) (net.Conn, error) { + return nil, dialErr + }, + } + tr := NewTransport(d, &testConnector{}) + + conn, err := tr.Dial(context.Background(), "192.168.1.1:80") + assert.Error(t, err) + assert.Nil(t, conn) +} + +func TestTransport_Dial_WithHostOption(t *testing.T) { + tr := NewTransport(&testDialer{}, &testConnector{}, + chain.AddrTransportOption("proxy.example.com:8080"), + ) + + var calledAddr string + // Wrap to check options are passed - we set dialFn after construction + tr.dialer = &testDialer{ + dialFn: func(ctx context.Context, addr string, opts ...dialer.DialOption) (net.Conn, error) { + calledAddr = addr + return &testConn{}, nil + }, + } + + conn, err := tr.Dial(context.Background(), "1.2.3.4:443") + require.NoError(t, err) + require.NotNil(t, conn) + // The addr parameter is what's passed through; Host option is inside opts + assert.Equal(t, "1.2.3.4:443", calledAddr) +} + +func TestTransport_Dial_WithRouteNodes(t *testing.T) { + // When the transport's Route option is set and has nodes, Dial should use + // Route.Dial as the underlying dial function. + innerRoute := NewRoute() + innerNode := chain.NewNode("inner", "127.0.0.1:9090", + chain.TransportNodeOption(NewTransport(&testDialer{}, &testConnector{}))) + innerRoute.addNode(innerNode) + + tr := NewTransport(&testDialer{}, &testConnector{}, + chain.RouteTransportOption(innerRoute), + ) + + conn, err := tr.Dial(context.Background(), "127.0.0.1:80") + require.NoError(t, err) + require.NotNil(t, conn) +} + +func TestTransport_Dial_RouteSet_NoNodes(t *testing.T) { + // Route is set but has zero nodes — should NOT use Route.Dial as the + // underlying dial function (short-circuit on len(Nodes()) > 0). + emptyRoute := NewRoute() // no nodes added + tr := NewTransport(&testDialer{}, &testConnector{}, + chain.RouteTransportOption(emptyRoute), + ) + + conn, err := tr.Dial(context.Background(), "192.168.1.1:80") + require.NoError(t, err) + require.NotNil(t, conn) +} + +func TestTransport_Dial_WithSockOpts(t *testing.T) { + // Dial with SockOpts set — exercises the Mark assignment path. + tr := NewTransport(&testDialer{}, &testConnector{}, + chain.SockOptsTransportOption(&chain.SockOpts{Mark: 999}), + ) + + conn, err := tr.Dial(context.Background(), "192.168.1.1:80") + require.NoError(t, err) + require.NotNil(t, conn) +} + +func TestTransport_Dial_DialFuncActive(t *testing.T) { + // Use a dialer that actually invokes the NetDialerDialOption, which in + // turn triggers the DialFunc on the net_dialer when Route has nodes. + d := &testDialer{ + dialFn: func(ctx context.Context, addr string, opts ...dialer.DialOption) (net.Conn, error) { + var do dialer.DialOptions + for _, opt := range opts { + if opt != nil { + opt(&do) + } + } + if do.Dialer != nil { + return do.Dialer.Dial(ctx, "tcp", addr) + } + return &testConn{}, nil + }, + } + + innerRoute := NewRoute() + innerNode := chain.NewNode("inner", "127.0.0.1:8080", + chain.TransportNodeOption(NewTransport(&testDialer{}, &testConnector{}))) + innerRoute.addNode(innerNode) + + tr := NewTransport(d, &testConnector{}, + chain.RouteTransportOption(innerRoute), + ) + + conn, err := tr.Dial(context.Background(), "127.0.0.1:80") + require.NoError(t, err) + require.NotNil(t, conn) +} + +func TestTransport_Dial_NoSockOpts(t *testing.T) { + // Dial with no SockOpts set (nil path). + d := &testDialer{} + c := &testConnector{} + tr := NewTransport(d, c) // no SockOptsTransportOption + + conn, err := tr.Dial(context.Background(), "192.168.1.1:80") + require.NoError(t, err) + require.NotNil(t, conn) +} + +func TestTransport_Connect_NoSockOpts(t *testing.T) { + // Connect with no SockOpts set (nil path). + c := &testConnector{ + connectFn: func(ctx context.Context, conn net.Conn, network, address string, opts ...connector.ConnectOption) (net.Conn, error) { + return conn, nil + }, + } + tr := NewTransport(&testDialer{}, c) // no SockOptsTransportOption + + conn := &testConn{} + result, err := tr.Connect(context.Background(), conn, "tcp", "10.0.0.1:80") + require.NoError(t, err) + assert.Equal(t, conn, result) +} + +func TestTransport_Handshake_DialerOnly(t *testing.T) { + d := &testDialer{ + handshakeFn: func(ctx context.Context, conn net.Conn, opts ...dialer.HandshakeOption) (net.Conn, error) { + return conn, nil + }, + } + c := &plainConnector{} + tr := NewTransport(d, c, chain.AddrTransportOption("proxy:8080")) + + conn := &testConn{} + result, err := tr.Handshake(context.Background(), conn) + require.NoError(t, err) + assert.Equal(t, conn, result) +} + +func TestTransport_Handshake_ConnectorOnly(t *testing.T) { + d := &plainDialer{} + c := &testConnector{ + handshakeFn: func(ctx context.Context, conn net.Conn) (net.Conn, error) { + return conn, nil + }, + } + tr := NewTransport(d, c) + + conn := &testConn{} + result, err := tr.Handshake(context.Background(), conn) + require.NoError(t, err) + assert.Equal(t, conn, result) +} + +func TestTransport_Handshake_Both(t *testing.T) { + callOrder := make([]string, 0) + d := &testDialer{ + handshakeFn: func(ctx context.Context, conn net.Conn, opts ...dialer.HandshakeOption) (net.Conn, error) { + callOrder = append(callOrder, "dialer") + return conn, nil + }, + } + c := &testConnector{ + handshakeFn: func(ctx context.Context, conn net.Conn) (net.Conn, error) { + callOrder = append(callOrder, "connector") + return conn, nil + }, + } + tr := NewTransport(d, c) + + conn := &testConn{} + result, err := tr.Handshake(context.Background(), conn) + require.NoError(t, err) + assert.Equal(t, conn, result) + assert.Equal(t, []string{"dialer", "connector"}, callOrder) +} + +func TestTransport_Handshake_Neither(t *testing.T) { + d := &plainDialer{} + c := &plainConnector{} + tr := NewTransport(d, c) + + conn := &testConn{} + result, err := tr.Handshake(context.Background(), conn) + require.NoError(t, err) + assert.Equal(t, conn, result) +} + +func TestTransport_Handshake_DialerError(t *testing.T) { + handshakeErr := errors.New("handshake failed") + d := &testDialer{ + handshakeFn: func(ctx context.Context, conn net.Conn, opts ...dialer.HandshakeOption) (net.Conn, error) { + return nil, handshakeErr + }, + } + tr := NewTransport(d, &testConnector{}) + + conn := &testConn{} + result, err := tr.Handshake(context.Background(), conn) + assert.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, handshakeErr, err) +} + +func TestTransport_Handshake_ConnectorError(t *testing.T) { + handshakeErr := errors.New("connector handshake failed") + d := &testDialer{} // dialer handshake succeeds first + c := &testConnector{ + handshakeFn: func(ctx context.Context, conn net.Conn) (net.Conn, error) { + return nil, handshakeErr + }, + } + tr := NewTransport(d, c) + + conn := &testConn{} + result, err := tr.Handshake(context.Background(), conn) + assert.Error(t, err) + assert.Nil(t, result) +} + +func TestTransport_Connect_Success(t *testing.T) { + c := &testConnector{ + connectFn: func(ctx context.Context, conn net.Conn, network, address string, opts ...connector.ConnectOption) (net.Conn, error) { + return conn, nil + }, + } + tr := NewTransport(&testDialer{}, c) + + conn := &testConn{} + result, err := tr.Connect(context.Background(), conn, "tcp", "10.0.0.1:80") + require.NoError(t, err) + assert.Equal(t, conn, result) +} + +func TestTransport_Connect_Error(t *testing.T) { + connectErr := errors.New("connect failed") + c := &testConnector{ + connectFn: func(ctx context.Context, conn net.Conn, network, address string, opts ...connector.ConnectOption) (net.Conn, error) { + return nil, connectErr + }, + } + tr := NewTransport(&testDialer{}, c) + + conn := &testConn{} + result, err := tr.Connect(context.Background(), conn, "tcp", "10.0.0.1:80") + assert.Error(t, err) + assert.Nil(t, result) +} + +func TestTransport_Connect_WithSockOpts(t *testing.T) { + c := &testConnector{ + connectFn: func(ctx context.Context, conn net.Conn, network, address string, opts ...connector.ConnectOption) (net.Conn, error) { + return conn, nil + }, + } + tr := NewTransport(&testDialer{}, c, + chain.SockOptsTransportOption(&chain.SockOpts{Mark: 123}), + ) + + conn := &testConn{} + result, err := tr.Connect(context.Background(), conn, "tcp", "10.0.0.1:80") + require.NoError(t, err) + assert.Equal(t, conn, result) +} + +func TestTransport_Bind_Binder(t *testing.T) { + expectedLn := &testListener{} + c := &testConnector{ + bindFn: func(ctx context.Context, conn net.Conn, network, address string, opts ...connector.BindOption) (net.Listener, error) { + return expectedLn, nil + }, + } + tr := NewTransport(&testDialer{}, c) + + conn := &testConn{} + ln, err := tr.Bind(context.Background(), conn, "tcp", ":0") + require.NoError(t, err) + assert.Equal(t, expectedLn, ln) +} + +func TestTransport_Bind_BindError(t *testing.T) { + bindErr := errors.New("bind failed") + c := &testConnector{ + bindFn: func(ctx context.Context, conn net.Conn, network, address string, opts ...connector.BindOption) (net.Listener, error) { + return nil, bindErr + }, + } + tr := NewTransport(&testDialer{}, c) + + conn := &testConn{} + ln, err := tr.Bind(context.Background(), conn, "tcp", ":0") + assert.Error(t, err) + assert.Nil(t, ln) +} + +func TestTransport_Bind_NotBinder(t *testing.T) { + c := &plainConnector{} + tr := NewTransport(&testDialer{}, c) + + conn := &testConn{} + ln, err := tr.Bind(context.Background(), conn, "tcp", ":0") + assert.Error(t, err) + assert.Nil(t, ln) + assert.Equal(t, connector.ErrBindUnsupported, err) +} + +func TestTransport_Multiplex_True(t *testing.T) { + d := &testDialer{hasMux: true, multiplexVal: true} + tr := NewTransport(d, &testConnector{}) + assert.True(t, tr.Multiplex()) +} + +func TestTransport_Multiplex_False(t *testing.T) { + d := &testDialer{hasMux: true, multiplexVal: false} + tr := NewTransport(d, &testConnector{}) + assert.False(t, tr.Multiplex()) +} + +func TestTransport_Multiplex_NotImplemented(t *testing.T) { + d := &plainDialer{} + tr := NewTransport(d, &testConnector{}) + assert.False(t, tr.Multiplex()) +} + +func TestTransport_Options(t *testing.T) { + tr := NewTransport(&testDialer{}, &testConnector{}, + chain.AddrTransportOption("test:1234"), + chain.InterfaceTransportOption("eth1"), + ) + opts := tr.Options() + require.NotNil(t, opts) + assert.Equal(t, "test:1234", opts.Addr) + assert.Equal(t, "eth1", opts.IfceName) +} + +func TestTransport_Options_NilReceiver(t *testing.T) { + var tr *Transport + opts := tr.Options() + assert.Nil(t, opts) +} + +func TestTransport_Copy_Isolation(t *testing.T) { + original := NewTransport(&testDialer{}, &testConnector{}, + chain.AddrTransportOption("original:8080"), + ) + + cp := original.Copy() + require.NotNil(t, cp) + + // The copy should not be the same pointer. + assert.NotSame(t, original, cp) + + // Modifying the copy's options must not affect the original. + cp.Options().Addr = "modified:9090" + assert.Equal(t, "original:8080", original.Options().Addr) + assert.Equal(t, "modified:9090", cp.Options().Addr) +} + +func TestTransport_Copy_SupportsEmbeddingSubRoute(t *testing.T) { + // Verify that Copy produces a transporter whose Options() can independently + // hold a sub-route (as used by Chain.Route for multiplex splitting). + original := NewTransport(&testDialer{}, &testConnector{}) + cp := original.Copy().(*Transport) + require.NotNil(t, cp) + + subRoute := NewRoute() + cp.Options().Route = subRoute + + assert.Nil(t, original.Options().Route) + assert.Equal(t, subRoute, cp.Options().Route) +} + +func TestTransport_Copy_ChainTransporterInterface(t *testing.T) { + // Verify Copy satisfies chain.Transporter interface. + var tr chain.Transporter = NewTransport(&testDialer{}, &testConnector{}) + cp := tr.Copy() + require.NotNil(t, cp) + assert.IsType(t, &Transport{}, cp) +} + +// ============================================================================= +// Chain tests +// ============================================================================= + +func TestNewChain(t *testing.T) { + c := NewChain("test-chain") + require.NotNil(t, c) + assert.Equal(t, "test-chain", c.Name()) + assert.NotNil(t, c.Marker()) +} + +func TestNewChain_WithMetadata(t *testing.T) { + md := xmetadata.NewMetadata(map[string]any{"key": "value"}) + c := NewChain("test-chain", MetadataChainOption(md)) + assert.Equal(t, md, c.Metadata()) +} + +func TestNewChain_WithLogger(t *testing.T) { + l := xlogger.Nop() + c := NewChain("test-chain", LoggerChainOption(l)) + assert.Equal(t, l, c.logger) +} + +func TestChain_AddHop(t *testing.T) { + c := NewChain("test-chain") + h := &testHop{} + c.AddHop(h) + assert.Len(t, c.hops, 1) +} + +func TestChain_Metadata(t *testing.T) { + md := xmetadata.NewMetadata(map[string]any{"a": 1}) + c := NewChain("test-chain", MetadataChainOption(md)) + assert.Equal(t, md, c.Metadata()) +} + +func TestChain_Marker(t *testing.T) { + c := NewChain("test-chain") + m := c.Marker() + require.NotNil(t, m) + m.Mark() + assert.Equal(t, int64(1), m.Count()) +} + +func TestChain_Name(t *testing.T) { + c := NewChain("my-chain") + assert.Equal(t, "my-chain", c.Name()) +} + +func TestChain_Route_NilChain(t *testing.T) { + var c *Chain + rt := c.Route(context.Background(), "tcp", "127.0.0.1:80") + assert.Nil(t, rt) +} + +func TestChain_Route_EmptyHops(t *testing.T) { + c := NewChain("empty") + rt := c.Route(context.Background(), "tcp", "127.0.0.1:80") + assert.Nil(t, rt) +} + +func TestChain_Route_SingleHop(t *testing.T) { + c := NewChain("test-chain") + tr := NewTransport(&testDialer{}, &testConnector{}) + node := chain.NewNode("node1", "127.0.0.1:8080", chain.TransportNodeOption(tr)) + h := &testHop{ + selectFn: func(ctx context.Context, opts ...hop.SelectOption) *chain.Node { + return node + }, + } + c.AddHop(h) + + rt := c.Route(context.Background(), "tcp", "127.0.0.1:80") + require.NotNil(t, rt) + assert.Len(t, rt.Nodes(), 1) + assert.Equal(t, "node1", rt.Nodes()[0].Name) +} + +func TestChain_Route_MultipleHops(t *testing.T) { + c := NewChain("test-chain") + node1 := chain.NewNode("hop1", "10.0.0.1:8080", + chain.TransportNodeOption(NewTransport(&testDialer{}, &testConnector{}))) + node2 := chain.NewNode("hop2", "10.0.0.2:8080", + chain.TransportNodeOption(NewTransport(&testDialer{}, &testConnector{}))) + + h1 := &testHop{selectFn: func(ctx context.Context, opts ...hop.SelectOption) *chain.Node { return node1 }} + h2 := &testHop{selectFn: func(ctx context.Context, opts ...hop.SelectOption) *chain.Node { return node2 }} + c.AddHop(h1) + c.AddHop(h2) + + rt := c.Route(context.Background(), "tcp", "192.168.1.1:80") + require.NotNil(t, rt) + assert.Len(t, rt.Nodes(), 2) + assert.Equal(t, "hop1", rt.Nodes()[0].Name) + assert.Equal(t, "hop2", rt.Nodes()[1].Name) +} + +func TestChain_Route_MultiplexSplit(t *testing.T) { + // When a node supports multiplexing, the route is split: nodes before the + // multiplex node become a sub-route embedded in that node's transport, and + // the returned route starts fresh after the multiplex node. + c := NewChain("test-chain") + + node1 := chain.NewNode("hop1", "10.0.0.1:8080", + chain.TransportNodeOption(NewTransport(&testDialer{}, &testConnector{}))) + + muxTr := NewTransport(&testDialer{hasMux: true, multiplexVal: true}, &testConnector{}) + node2 := chain.NewNode("hop2", "10.0.0.2:8080", + chain.TransportNodeOption(muxTr)) + + node3 := chain.NewNode("hop3", "10.0.0.3:8080", + chain.TransportNodeOption(NewTransport(&testDialer{}, &testConnector{}))) + + h1 := &testHop{selectFn: func(ctx context.Context, opts ...hop.SelectOption) *chain.Node { return node1 }} + h2 := &testHop{selectFn: func(ctx context.Context, opts ...hop.SelectOption) *chain.Node { return node2 }} + h3 := &testHop{selectFn: func(ctx context.Context, opts ...hop.SelectOption) *chain.Node { return node3 }} + c.AddHop(h1) + c.AddHop(h2) + c.AddHop(h3) + + rt := c.Route(context.Background(), "tcp", "192.168.1.1:80") + require.NotNil(t, rt) + + // hop1 is in the sub-route embedded in hop2's transport + // hop2 starts the new route (it's the multiplex node), hop3 follows + nodes := rt.Nodes() + require.Len(t, nodes, 2) + assert.Equal(t, "hop2", nodes[0].Name) + assert.Equal(t, "hop3", nodes[1].Name) + + // Verify hop2's transport has the sub-route with hop1 + subRoute := nodes[0].Options().Transport.Options().Route + require.NotNil(t, subRoute) + assert.Len(t, subRoute.Nodes(), 1) + assert.Equal(t, "hop1", subRoute.Nodes()[0].Name) +} + +func TestChain_Route_NilNodeFromHop(t *testing.T) { + c := NewChain("test-chain") + h := &testHop{ + selectFn: func(ctx context.Context, opts ...hop.SelectOption) *chain.Node { return nil }, + } + c.AddHop(h) + + rt := c.Route(context.Background(), "tcp", "127.0.0.1:80") + require.NotNil(t, rt) + // Empty route because the hop returned nil (no suitable node) + assert.Len(t, rt.Nodes(), 0) +} + +func TestChain_Route_NilNodeInMiddle(t *testing.T) { + c := NewChain("test-chain") + node1 := chain.NewNode("hop1", "10.0.0.1:8080", + chain.TransportNodeOption(NewTransport(&testDialer{}, &testConnector{}))) + + h1 := &testHop{selectFn: func(ctx context.Context, opts ...hop.SelectOption) *chain.Node { return node1 }} + h2 := &testHop{selectFn: func(ctx context.Context, opts ...hop.SelectOption) *chain.Node { return nil }} + c.AddHop(h1) + c.AddHop(h2) + + rt := c.Route(context.Background(), "tcp", "127.0.0.1:80") + require.NotNil(t, rt) + // Only hop1 is present; route stops at the nil node + assert.Len(t, rt.Nodes(), 1) +} + +func TestNewChainGroup(t *testing.T) { + ch1 := NewChain("ch1") + ch2 := NewChain("ch2") + g := NewChainGroup(ch1, ch2) + require.NotNil(t, g) + assert.Len(t, g.chains, 2) +} + +func TestChainGroup_Route_NoChains(t *testing.T) { + g := NewChainGroup() + rt := g.Route(context.Background(), "tcp", "127.0.0.1:80") + assert.Nil(t, rt) +} + +func TestChainGroup_Route_WithEmptyChain(t *testing.T) { + // Chain group with empty chain (no hops) returns nil route. + ch1 := NewChain("ch1") + g := NewChainGroup(ch1) + sel := &testSelector{chains: []chain.Chainer{ch1}} + g.WithSelector(sel) + + rt := g.Route(context.Background(), "tcp", "127.0.0.1:80") + assert.Nil(t, rt) +} + +func TestChainGroup_Route_NilReceiver(t *testing.T) { + var g *chainGroup + rt := g.Route(context.Background(), "tcp", "127.0.0.1:80") + assert.Nil(t, rt) +} + +// ============================================================================= +// Route tests +// ============================================================================= + +func TestNewRoute(t *testing.T) { + rt := NewRoute() + require.NotNil(t, rt) + assert.Len(t, rt.Nodes(), 0) +} + +func TestNewRoute_WithChainOption(t *testing.T) { + c := NewChain("test-chain") + rt := NewRoute(ChainRouteOption(c)) + require.NotNil(t, rt) + assert.Equal(t, c, rt.options.Chain) +} + +func TestChainRoute_AddNode(t *testing.T) { + rt := NewRoute() + node := chain.NewNode("n1", "127.0.0.1:8080") + rt.addNode(node) + assert.Len(t, rt.Nodes(), 1) + rt.addNode(chain.NewNode("n2", "127.0.0.1:9090")) + assert.Len(t, rt.Nodes(), 2) +} + +func TestChainRoute_Nodes(t *testing.T) { + rt := NewRoute() + node1 := chain.NewNode("n1", "127.0.0.1:8080") + node2 := chain.NewNode("n2", "127.0.0.1:9090") + rt.addNode(node1, node2) + + nodes := rt.Nodes() + assert.Len(t, nodes, 2) + assert.Equal(t, "n1", nodes[0].Name) + assert.Equal(t, "n2", nodes[1].Name) +} + +func TestChainRoute_Nodes_NilReceiver(t *testing.T) { + var rt *chainRoute + assert.Nil(t, rt.Nodes()) +} + +func TestChainRoute_GetNode_Valid(t *testing.T) { + rt := NewRoute() + node := chain.NewNode("n1", "127.0.0.1:8080") + rt.addNode(node) + + assert.Equal(t, node, rt.getNode(0)) +} + +func TestChainRoute_GetNode_OutOfBounds(t *testing.T) { + rt := NewRoute() + rt.addNode(chain.NewNode("n1", "127.0.0.1:8080")) + + assert.Nil(t, rt.getNode(-1)) + assert.Nil(t, rt.getNode(1)) +} + +func TestChainRoute_GetNode_EmptyRoute(t *testing.T) { + rt := NewRoute() + assert.Nil(t, rt.getNode(0)) +} + +func TestChainRoute_GetNode_NilReceiver(t *testing.T) { + var rt *chainRoute + assert.Nil(t, rt.getNode(0)) +} + +func TestChainRoute_Dial_EmptyRoute(t *testing.T) { + // Empty route falls through to DefaultRoute. The DefaultRoute path + // requires real network I/O; test it through Router integration tests. + rt := NewRoute() + assert.Len(t, rt.Nodes(), 0) +} + +func TestChainRoute_Dial_SingleNode(t *testing.T) { + tr := NewTransport(&testDialer{}, &testConnector{}) + node := chain.NewNode("node1", "127.0.0.1:8080", chain.TransportNodeOption(tr)) + rt := NewRoute() + rt.addNode(node) + + conn, err := rt.Dial(context.Background(), "tcp", "127.0.0.1:80") + require.NoError(t, err) + require.NotNil(t, conn) + conn.Close() +} + +func TestChainRoute_Dial_MultipleNodes(t *testing.T) { + // Two-hop chain: node1 (entry proxy) → node2 (exit proxy) → destination + tr1 := NewTransport(&testDialer{}, &testConnector{}) + tr2 := NewTransport(&testDialer{}, &testConnector{}) + + node1 := chain.NewNode("entry", "127.0.0.1:8080", chain.TransportNodeOption(tr1)) + node2 := chain.NewNode("exit", "127.0.0.1:9090", chain.TransportNodeOption(tr2)) + + rt := NewRoute() + rt.addNode(node1, node2) + + conn, err := rt.Dial(context.Background(), "tcp", "192.168.1.1:80") + require.NoError(t, err) + require.NotNil(t, conn) + conn.Close() +} + +func TestChainRoute_Dial_DialError(t *testing.T) { + dialErr := errors.New("cannot dial") + tr := NewTransport( + &testDialer{ + dialFn: func(ctx context.Context, addr string, opts ...dialer.DialOption) (net.Conn, error) { + return nil, dialErr + }, + }, + &testConnector{}, + ) + node := chain.NewNode("node1", "127.0.0.1:8080", chain.TransportNodeOption(tr)) + rt := NewRoute() + rt.addNode(node) + + conn, err := rt.Dial(context.Background(), "tcp", "127.0.0.1:80") + assert.Error(t, err) + assert.Nil(t, conn) +} + +func TestChainRoute_Dial_HandshakeError(t *testing.T) { + handshakeErr := errors.New("handshake error") + tr := NewTransport( + &testDialer{ + handshakeFn: func(ctx context.Context, conn net.Conn, opts ...dialer.HandshakeOption) (net.Conn, error) { + return nil, handshakeErr + }, + }, + &testConnector{}, + ) + node := chain.NewNode("node1", "127.0.0.1:8080", chain.TransportNodeOption(tr)) + rt := NewRoute() + rt.addNode(node) + + conn, err := rt.Dial(context.Background(), "tcp", "127.0.0.1:80") + assert.Error(t, err) + assert.Nil(t, conn) +} + +func TestChainRoute_Dial_ConnectError(t *testing.T) { + connectErr := errors.New("connect failed") + tr := NewTransport( + &testDialer{}, + &testConnector{ + connectFn: func(ctx context.Context, conn net.Conn, network, address string, opts ...connector.ConnectOption) (net.Conn, error) { + return nil, connectErr + }, + }, + ) + node := chain.NewNode("node1", "127.0.0.1:8080", chain.TransportNodeOption(tr)) + rt := NewRoute() + rt.addNode(node) + + conn, err := rt.Dial(context.Background(), "tcp", "127.0.0.1:80") + assert.Error(t, err) + assert.Nil(t, conn) +} + +func TestChainRoute_Dial_MultiHopConnectError(t *testing.T) { + // First hop succeeds, second hop's Connect fails. + connectErr := errors.New("intermediate connect failed") + tr1 := NewTransport(&testDialer{}, &testConnector{}) + tr2 := NewTransport( + &testDialer{}, + &testConnector{ + connectFn: func(ctx context.Context, conn net.Conn, network, address string, opts ...connector.ConnectOption) (net.Conn, error) { + return nil, connectErr + }, + }, + ) + + node1 := chain.NewNode("entry", "127.0.0.1:8080", chain.TransportNodeOption(tr1)) + node2 := chain.NewNode("exit", "127.0.0.1:9090", chain.TransportNodeOption(tr2)) + + rt := NewRoute() + rt.addNode(node1, node2) + + conn, err := rt.Dial(context.Background(), "tcp", "192.168.1.1:80") + assert.Error(t, err) + assert.Nil(t, conn) +} + +func TestChainRoute_Dial_MultiHopHandshakeError(t *testing.T) { + // First hop succeeds, second hop's Handshake fails. + handshakeErr := errors.New("handshake failed") + tr1 := NewTransport(&testDialer{}, &testConnector{}) + tr2 := NewTransport( + &testDialer{ + handshakeFn: func(ctx context.Context, conn net.Conn, opts ...dialer.HandshakeOption) (net.Conn, error) { + return nil, handshakeErr + }, + }, + &testConnector{}, + ) + + node1 := chain.NewNode("entry", "127.0.0.1:8080", chain.TransportNodeOption(tr1)) + node2 := chain.NewNode("exit", "127.0.0.1:9090", chain.TransportNodeOption(tr2)) + + rt := NewRoute() + rt.addNode(node1, node2) + + conn, err := rt.Dial(context.Background(), "tcp", "192.168.1.1:80") + assert.Error(t, err) + assert.Nil(t, conn) +} + +func TestChainRoute_Dial_ConnectionCleanupOnError(t *testing.T) { + // Verify that on intermediate node failure, previous connections are closed. + conn1 := &trackedConn{testConn: &testConn{}} + + tr1 := NewTransport( + &testDialer{ + dialFn: func(ctx context.Context, addr string, opts ...dialer.DialOption) (net.Conn, error) { + return conn1, nil + }, + }, + &testConnector{}, + ) + + // Second transport fails on Connect, which should trigger conn1.Close() + tr2 := NewTransport( + &testDialer{}, + &testConnector{ + connectFn: func(ctx context.Context, conn net.Conn, network, address string, opts ...connector.ConnectOption) (net.Conn, error) { + return nil, errors.New("fail") + }, + }, + ) + + node1 := chain.NewNode("entry", "127.0.0.1:8080", chain.TransportNodeOption(tr1)) + node2 := chain.NewNode("exit", "127.0.0.1:9090", chain.TransportNodeOption(tr2)) + + rt := NewRoute() + rt.addNode(node1, node2) + + _, err := rt.Dial(context.Background(), "tcp", "192.168.1.1:80") + assert.Error(t, err) + assert.True(t, conn1.closed, "first hop connection should be closed on second hop failure") +} + +func TestChainRoute_Bind_EmptyRoute(t *testing.T) { + rt := NewRoute() + // Empty route falls to DefaultRoute.Bind — TCP bind on port 0 should succeed. + ln, err := rt.Bind(context.Background(), "tcp", ":0") + require.NoError(t, err) + require.NotNil(t, ln) + ln.Close() +} + +func TestChainRoute_Bind_SingleNode(t *testing.T) { + tr := NewTransport(&testDialer{}, &testConnector{ + bindFn: func(ctx context.Context, conn net.Conn, network, address string, opts ...connector.BindOption) (net.Listener, error) { + return &testListener{}, nil + }, + }) + node := chain.NewNode("node1", "127.0.0.1:8080", chain.TransportNodeOption(tr)) + rt := NewRoute() + rt.addNode(node) + + ln, err := rt.Bind(context.Background(), "tcp", ":0") + require.NoError(t, err) + require.NotNil(t, ln) + ln.Close() +} + +func TestChainRoute_Bind_BindError(t *testing.T) { + bindErr := errors.New("bind not available") + tr := NewTransport(&testDialer{}, &testConnector{ + bindFn: func(ctx context.Context, conn net.Conn, network, address string, opts ...connector.BindOption) (net.Listener, error) { + return nil, bindErr + }, + }) + node := chain.NewNode("node1", "127.0.0.1:8080", chain.TransportNodeOption(tr)) + rt := NewRoute() + rt.addNode(node) + + ln, err := rt.Bind(context.Background(), "tcp", ":0") + assert.Error(t, err) + assert.Nil(t, ln) +} + +func TestChainRoute_Bind_ConnectError(t *testing.T) { + // Dial succeeds but Bind on the last node fails — connection should be cleaned up. + dialErr := errors.New("dial failed") + tr := NewTransport( + &testDialer{ + dialFn: func(ctx context.Context, addr string, opts ...dialer.DialOption) (net.Conn, error) { + return nil, dialErr + }, + }, + &testConnector{}, + ) + node := chain.NewNode("node1", "127.0.0.1:8080", chain.TransportNodeOption(tr)) + rt := NewRoute() + rt.addNode(node) + + ln, err := rt.Bind(context.Background(), "tcp", ":0") + assert.Error(t, err) + assert.Nil(t, ln) +} + +func TestChainRoute_Bind_WithBindOptions(t *testing.T) { + // Verify Bind passes through BindOptions correctly. + tr := NewTransport(&testDialer{}, &testConnector{ + bindFn: func(ctx context.Context, conn net.Conn, network, address string, opts ...connector.BindOption) (net.Listener, error) { + return &testListener{}, nil + }, + }) + node := chain.NewNode("node1", "127.0.0.1:8080", chain.TransportNodeOption(tr)) + rt := NewRoute() + rt.addNode(node) + + ln, err := rt.Bind(context.Background(), "tcp", ":0", + chain.MuxBindOption(true), + chain.BacklogBindOption(128), + chain.UDPConnTTLBindOption(30*time.Second), + chain.UDPDataBufferSizeBindOption(4096), + chain.UDPDataQueueSizeBindOption(64), + ) + require.NoError(t, err) + require.NotNil(t, ln) + ln.Close() +} + +func TestChainRoute_Dial_WithDialOptions(t *testing.T) { + // Verify Dial passes through DialOptions correctly. + tr := NewTransport(&testDialer{}, &testConnector{}) + node := chain.NewNode("node1", "127.0.0.1:8080", chain.TransportNodeOption(tr)) + rt := NewRoute() + rt.addNode(node) + + conn, err := rt.Dial(context.Background(), "tcp", "127.0.0.1:80", + chain.InterfaceDialOption("eth0"), + chain.NetnsDialOption("ns1"), + chain.SockOptsDialOption(&chain.SockOpts{Mark: 99}), + chain.LoggerDialOption(xlogger.Nop()), + ) + require.NoError(t, err) + require.NotNil(t, conn) + conn.Close() +} + +func TestChainRoute_Connect_MarkerOnError(t *testing.T) { + // When connect fails and the route has a parent Chain, the node and chain + // markers should be updated. + c := NewChain("test-chain") + dialErr := errors.New("dial failed") + var dialCalled bool + tr := NewTransport( + &testDialer{ + dialFn: func(ctx context.Context, addr string, opts ...dialer.DialOption) (net.Conn, error) { + dialCalled = true + // Return error after first call to test marker behavior + if addr == "127.0.0.1:8080" { + return nil, dialErr + } + return &testConn{}, nil + }, + }, + &testConnector{}, + ) + node := chain.NewNode("node1", "127.0.0.1:8080", chain.TransportNodeOption(tr)) + rt := NewRoute(ChainRouteOption(c)) + rt.addNode(node) + + conn, err := rt.Dial(context.Background(), "tcp", "127.0.0.1:80") + assert.Error(t, err) + assert.Nil(t, conn) + assert.True(t, dialCalled) + + // Chain marker should reflect the error + assert.Greater(t, c.Marker().Count(), int64(0)) +} + +func TestChainRoute_Connect_SuccessResetsChainMarker(t *testing.T) { + // On success, the chain marker should be reset. + c := NewChain("test-chain") + // Pre-mark the chain + c.Marker().Mark() + + tr := NewTransport(&testDialer{}, &testConnector{}) + node := chain.NewNode("node1", "127.0.0.1:8080", chain.TransportNodeOption(tr)) + rt := NewRoute(ChainRouteOption(c)) + rt.addNode(node) + + conn, err := rt.Dial(context.Background(), "tcp", "127.0.0.1:80") + require.NoError(t, err) + conn.Close() + + // Chain marker should be reset to 0 on success + assert.Equal(t, int64(0), c.Marker().Count()) +} + +func TestDefaultRoute_Nodes(t *testing.T) { + assert.Nil(t, DefaultRoute.Nodes()) +} + +func TestDefaultRoute_ImplementsRoute(t *testing.T) { + var r chain.Route = DefaultRoute + assert.NotNil(t, r) +} + +func TestRoutePath(t *testing.T) { + // Simple route with no sub-routes. + node1 := chain.NewNode("n1", "127.0.0.1:8080", + chain.TransportNodeOption(NewTransport(&testDialer{}, &testConnector{}))) + node2 := chain.NewNode("n2", "127.0.0.1:9090", + chain.TransportNodeOption(NewTransport(&testDialer{}, &testConnector{}))) + + rt := NewRoute() + rt.addNode(node1, node2) + + path := routePath(rt) + require.Len(t, path, 2) + assert.Equal(t, "n1", path[0].Name) + assert.Equal(t, "n2", path[1].Name) +} + +func TestRoutePath_NilRoute(t *testing.T) { + assert.Nil(t, routePath(nil)) +} + +func TestRoutePath_NestedRoutes(t *testing.T) { + // Simulate what Chain.Route does for multiplex splitting: the sub-route + // is embedded in the transport options of the multiplex node. + innerNode := chain.NewNode("inner", "10.0.0.1:8080", + chain.TransportNodeOption(NewTransport(&testDialer{}, &testConnector{}))) + innerRoute := NewRoute() + innerRoute.addNode(innerNode) + + muxTr := NewTransport(&testDialer{}, &testConnector{}) + muxTr.Options().Route = innerRoute + muxNode := chain.NewNode("mux", "10.0.0.2:8080", chain.TransportNodeOption(muxTr)) + + outerNode := chain.NewNode("outer", "10.0.0.3:9090", + chain.TransportNodeOption(NewTransport(&testDialer{}, &testConnector{}))) + + rt := NewRoute() + rt.addNode(muxNode, outerNode) + + path := routePath(rt) + require.Len(t, path, 3) + assert.Equal(t, "inner", path[0].Name) + assert.Equal(t, "mux", path[1].Name) + assert.Equal(t, "outer", path[2].Name) +} + +// ============================================================================= +// Router tests +// ============================================================================= + +func TestNewRouter_Defaults(t *testing.T) { + r := NewRouter() + require.NotNil(t, r) + assert.Equal(t, 15*time.Second, r.options.Timeout) + assert.NotNil(t, r.options.Logger) +} + +func TestNewRouter_WithOptions(t *testing.T) { + testChain := NewChain("test-chain") + l := xlogger.Nop() + + r := NewRouter( + chain.TimeoutRouterOption(30*time.Second), + chain.RetriesRouterOption(3), + chain.InterfaceRouterOption("eth0"), + chain.NetnsRouterOption("ns1"), + chain.SockOptsRouterOption(&chain.SockOpts{Mark: 99}), + chain.ChainRouterOption(testChain), + chain.LoggerRouterOption(l), + ) + require.NotNil(t, r) + assert.Equal(t, 30*time.Second, r.options.Timeout) + assert.Equal(t, 3, r.options.Retries) + assert.Equal(t, "eth0", r.options.IfceName) + assert.Equal(t, "ns1", r.options.Netns) + assert.Equal(t, 99, r.options.SockOpts.Mark) + assert.Equal(t, testChain, r.options.Chain) + assert.Equal(t, l, r.options.Logger) +} + +func TestNewRouter_ZeroTimeoutDefault(t *testing.T) { + // Timeout of 0 is replaced with 15s default. + r := NewRouter(chain.TimeoutRouterOption(0)) + assert.Equal(t, 15*time.Second, r.options.Timeout) +} + +func TestRouter_Options(t *testing.T) { + r := NewRouter(chain.TimeoutRouterOption(5 * time.Second)) + opts := r.Options() + require.NotNil(t, opts) + assert.Equal(t, 5*time.Second, opts.Timeout) +} + +func TestRouter_Options_NilReceiver(t *testing.T) { + var r *Router + assert.Nil(t, r.Options()) +} + +func TestRouter_Record_WithMatchingRecorder(t *testing.T) { + rec := &testRecorder{} + r := NewRouter( + chain.RecordersRouterOption(recorder.RecorderObject{ + Recorder: rec, + Record: recorder.RecorderServiceRouterDialAddress, + }), + ) + err := r.record(context.Background(), recorder.RecorderServiceRouterDialAddress, []byte("test-addr")) + assert.NoError(t, err) + assert.Len(t, rec.records, 1) + assert.Equal(t, "test-addr", rec.records[0].data) +} + +func TestRouter_Record_NoMatch(t *testing.T) { + rec := &testRecorder{} + r := NewRouter( + chain.RecordersRouterOption(recorder.RecorderObject{ + Recorder: rec, + Record: "other.record.name", + }), + ) + err := r.record(context.Background(), recorder.RecorderServiceRouterDialAddress, []byte("test-addr")) + assert.NoError(t, err) + assert.Len(t, rec.records, 0) +} + +func TestRouter_Record_EmptyData(t *testing.T) { + rec := &testRecorder{} + r := NewRouter( + chain.RecordersRouterOption(recorder.RecorderObject{ + Recorder: rec, + Record: recorder.RecorderServiceRouterDialAddress, + }), + ) + err := r.record(context.Background(), recorder.RecorderServiceRouterDialAddress, nil) + assert.NoError(t, err) + assert.Len(t, rec.records, 0) +} + +func TestRouter_Dial_RecordsAddress(t *testing.T) { + rec := &testRecorder{} + testChain := NewChain("test-chain") + + tr := NewTransport(&testDialer{}, &testConnector{}) + node := chain.NewNode("n1", "127.0.0.1:8080", chain.TransportNodeOption(tr)) + h := &testHop{ + selectFn: func(ctx context.Context, opts ...hop.SelectOption) *chain.Node { return node }, + } + testChain.AddHop(h) + + r := NewRouter( + chain.ChainRouterOption(testChain), + chain.RecordersRouterOption(recorder.RecorderObject{ + Recorder: rec, + Record: recorder.RecorderServiceRouterDialAddress, + }), + ) + + conn, err := r.Dial(context.Background(), "tcp", "192.168.1.1:80") + require.NoError(t, err) + require.NotNil(t, conn) + conn.Close() + + assert.Len(t, rec.records, 1) + assert.Equal(t, "192.168.1.1", rec.records[0].data) +} + +func TestRouter_Dial_ErrorRecordsAddress(t *testing.T) { + // Verify that Dial records the address before the dial attempt, regardless + // of whether the dial succeeds or fails. + rec := &testRecorder{} + dialErr := errors.New("dial failed") + testChain := NewChain("test-chain") + tr := NewTransport( + &testDialer{ + dialFn: func(ctx context.Context, addr string, opts ...dialer.DialOption) (net.Conn, error) { + return nil, dialErr + }, + }, + &testConnector{}, + ) + node := chain.NewNode("n1", "127.0.0.1:8080", chain.TransportNodeOption(tr)) + h := &testHop{ + selectFn: func(ctx context.Context, opts ...hop.SelectOption) *chain.Node { return node }, + } + testChain.AddHop(h) + + r := NewRouter( + chain.ChainRouterOption(testChain), + chain.RetriesRouterOption(0), + chain.RecordersRouterOption( + recorder.RecorderObject{ + Recorder: rec, + Record: recorder.RecorderServiceRouterDialAddress, + }, + recorder.RecorderObject{ + Recorder: rec, + Record: recorder.RecorderServiceRouterDialAddressError, + }, + ), + ) + + _, err := r.Dial(context.Background(), "tcp", "192.168.1.1:80") + assert.Error(t, err) + // Dial always records the address first (before dial attempt) + assert.Equal(t, "192.168.1.1", rec.records[0].data) +} + +func TestRouter_Dial_UDPWrapsAsPacketConn(t *testing.T) { + // When network is "udp", the returned connection should implement net.PacketConn. + testChain := NewChain("test-chain") + tr := NewTransport(&testDialer{}, &testConnector{}) + node := chain.NewNode("n1", "127.0.0.1:8080", chain.TransportNodeOption(tr)) + h := &testHop{ + selectFn: func(ctx context.Context, opts ...hop.SelectOption) *chain.Node { return node }, + } + testChain.AddHop(h) + + r := NewRouter( + chain.ChainRouterOption(testChain), + ) + + // The transport returns a testConn which does NOT implement net.PacketConn, + // so Router.Dial should wrap it in a packetConn. + conn, err := r.Dial(context.Background(), "udp", "127.0.0.1:80") + require.NoError(t, err) + require.NotNil(t, conn) + + _, ok := conn.(net.PacketConn) + assert.True(t, ok, "UDP dial should return a net.PacketConn") + conn.Close() +} + +func TestRouter_Bind_EmptyChain(t *testing.T) { + // Chain with no hops → empty route → ErrEmptyRoute + c := NewChain("empty") + r := NewRouter(chain.ChainRouterOption(c), chain.RetriesRouterOption(0)) + + ln, err := r.Bind(context.Background(), "tcp", ":0") + assert.ErrorIs(t, err, ErrEmptyRoute) + assert.Nil(t, ln) +} + +func TestRouter_Bind_NoChain(t *testing.T) { + // No chain → falls to DefaultRoute.Bind + r := NewRouter(chain.RetriesRouterOption(0)) + + ln, err := r.Bind(context.Background(), "tcp", ":0") + require.NoError(t, err) + require.NotNil(t, ln) + ln.Close() +} + +func TestRouter_Bind_WithChain(t *testing.T) { + c := NewChain("test-chain") + tr := NewTransport(&testDialer{}, &testConnector{ + bindFn: func(ctx context.Context, conn net.Conn, network, address string, opts ...connector.BindOption) (net.Listener, error) { + return &testListener{}, nil + }, + }) + node := chain.NewNode("n1", "127.0.0.1:8080", chain.TransportNodeOption(tr)) + h := &testHop{ + selectFn: func(ctx context.Context, opts ...hop.SelectOption) *chain.Node { return node }, + } + c.AddHop(h) + + r := NewRouter( + chain.ChainRouterOption(c), + chain.RetriesRouterOption(0), + ) + + ln, err := r.Bind(context.Background(), "tcp", ":0") + require.NoError(t, err) + require.NotNil(t, ln) + ln.Close() +} + +func TestRouter_Bind_Retries(t *testing.T) { + // First attempt fails, second succeeds. + attempts := 0 + c := NewChain("test-chain") + + tr := NewTransport( + &testDialer{ + dialFn: func(ctx context.Context, addr string, opts ...dialer.DialOption) (net.Conn, error) { + attempts++ + if attempts == 1 { + return nil, errors.New("transient error") + } + return &testConn{}, nil + }, + }, + &testConnector{}, + ) + node := chain.NewNode("n1", "127.0.0.1:8080", chain.TransportNodeOption(tr)) + h := &testHop{ + selectFn: func(ctx context.Context, opts ...hop.SelectOption) *chain.Node { return node }, + } + c.AddHop(h) + + r := NewRouter( + chain.ChainRouterOption(c), + chain.RetriesRouterOption(2), + ) + + conn, err := r.Dial(context.Background(), "tcp", "127.0.0.1:80") + require.NoError(t, err) + require.NotNil(t, conn) + conn.Close() + + assert.Equal(t, 2, attempts, "should have retried once and succeeded on second attempt") +} + +func TestRouter_Dial_NegativeRetries(t *testing.T) { + // Negative retries should be clamped to 1 (count must be at least 1). + c := NewChain("test-chain") + tr := NewTransport(&testDialer{}, &testConnector{}) + node := chain.NewNode("n1", "127.0.0.1:8080", chain.TransportNodeOption(tr)) + h := &testHop{ + selectFn: func(ctx context.Context, opts ...hop.SelectOption) *chain.Node { return node }, + } + c.AddHop(h) + + r := NewRouter( + chain.ChainRouterOption(c), + chain.RetriesRouterOption(-5), + ) + + conn, err := r.Dial(context.Background(), "tcp", "127.0.0.1:80") + require.NoError(t, err) + require.NotNil(t, conn) + conn.Close() +} + +func TestRouter_Bind_NegativeRetries(t *testing.T) { + c := NewChain("test-chain") + tr := NewTransport(&testDialer{}, &testConnector{ + bindFn: func(ctx context.Context, conn net.Conn, network, address string, opts ...connector.BindOption) (net.Listener, error) { + return &testListener{}, nil + }, + }) + node := chain.NewNode("n1", "127.0.0.1:8080", chain.TransportNodeOption(tr)) + h := &testHop{ + selectFn: func(ctx context.Context, opts ...hop.SelectOption) *chain.Node { return node }, + } + c.AddHop(h) + + r := NewRouter( + chain.ChainRouterOption(c), + chain.RetriesRouterOption(-1), + ) + + ln, err := r.Bind(context.Background(), "tcp", ":0") + require.NoError(t, err) + require.NotNil(t, ln) + ln.Close() +} + +func TestRouter_Bind_ErrorOnRetry(t *testing.T) { + // All retries fail — verifies error logging path. + c := NewChain("test-chain") + tr := NewTransport( + &testDialer{ + dialFn: func(ctx context.Context, addr string, opts ...dialer.DialOption) (net.Conn, error) { + return nil, errors.New("persistent error") + }, + }, + &testConnector{}, + ) + node := chain.NewNode("n1", "127.0.0.1:8080", chain.TransportNodeOption(tr)) + h := &testHop{ + selectFn: func(ctx context.Context, opts ...hop.SelectOption) *chain.Node { return node }, + } + c.AddHop(h) + + r := NewRouter( + chain.ChainRouterOption(c), + chain.RetriesRouterOption(1), + ) + + ln, err := r.Bind(context.Background(), "tcp", ":0") + assert.Error(t, err) + assert.Nil(t, ln) +} + +// ============================================================================= +// PacketConn wrapper tests +// ============================================================================= + +func TestPacketConn_ReadFrom(t *testing.T) { + c := &packetConn{Conn: &testConn{}} + buf := make([]byte, 1024) + n, addr, err := c.ReadFrom(buf) + assert.NoError(t, err) + assert.GreaterOrEqual(t, n, 0) + assert.NotNil(t, addr) + assert.Equal(t, c.Conn.RemoteAddr(), addr) +} + +func TestPacketConn_WriteTo(t *testing.T) { + c := &packetConn{Conn: &testConn{}} + addr := &net.TCPAddr{IP: net.IPv4(10, 0, 0, 1), Port: 9999} + n, err := c.WriteTo([]byte("hello"), addr) + assert.NoError(t, err) + assert.Equal(t, 5, n) +} + +// ============================================================================= +// Integration: full route traversal +// ============================================================================= + +func TestConnect_FirstNodeDialError(t *testing.T) { + // connect() should mark both the node and the chain on dial failure. + c := NewChain("test-chain") + dialErr := errors.New("dial refused") + tr := NewTransport( + &testDialer{ + dialFn: func(ctx context.Context, addr string, opts ...dialer.DialOption) (net.Conn, error) { + return nil, dialErr + }, + }, + &testConnector{}, + ) + node := chain.NewNode("bad-node", "127.0.0.1:9999", chain.TransportNodeOption(tr)) + rt := NewRoute(ChainRouteOption(c)) + rt.addNode(node) + + conn, err := rt.Dial(context.Background(), "tcp", "127.0.0.1:80") + assert.Error(t, err) + assert.Nil(t, conn) +} + +func TestConnect_FirstNodeHandshakeError(t *testing.T) { + // connect() should close the dialed connection on handshake failure. + c := NewChain("test-chain") + handshakeErr := errors.New("tls handshake failed") + tr := NewTransport( + &testDialer{ + handshakeFn: func(ctx context.Context, conn net.Conn, opts ...dialer.HandshakeOption) (net.Conn, error) { + return nil, handshakeErr + }, + }, + &testConnector{}, + ) + node := chain.NewNode("bad-tls", "127.0.0.1:8080", chain.TransportNodeOption(tr)) + rt := NewRoute(ChainRouteOption(c)) + rt.addNode(node) + + conn, err := rt.Dial(context.Background(), "tcp", "127.0.0.1:80") + assert.Error(t, err) + assert.Nil(t, conn) +} + +func TestConnect_SecondNodeEmptyAddress(t *testing.T) { + // When the second node has an empty address, xnet.Resolve returns ("", nil) + // and Connect is called with an empty target. + tr1 := NewTransport(&testDialer{}, &testConnector{}) + node1 := chain.NewNode("ok-node", "127.0.0.1:8080", chain.TransportNodeOption(tr1)) + + node2 := chain.NewNode("empty-addr", "", chain.TransportNodeOption( + NewTransport(&testDialer{}, &testConnector{}), + )) + + rt := NewRoute() + rt.addNode(node1, node2) + + conn, err := rt.Dial(context.Background(), "tcp", "127.0.0.1:80") + require.NoError(t, err) + conn.Close() +} + +// ============================================================================= +// Verify interface satisfaction +// ============================================================================= + +func TestTransport_ImplementsTransporter(t *testing.T) { + var _ chain.Transporter = (*Transport)(nil) +} + +func TestChain_ImplementsChainer(t *testing.T) { + var _ chain.Chainer = (*Chain)(nil) +} + +func TestChainGroup_ImplementsChainer(t *testing.T) { + var _ chain.Chainer = (*chainGroup)(nil) +} + +func TestChainRoute_ImplementsRoute(t *testing.T) { + var _ chain.Route = (*chainRoute)(nil) +} + +func TestDefaultRoute_ImplementsRoute_Interface(t *testing.T) { + var _ chain.Route = DefaultRoute +} + +func TestRouter_ImplementsRouter(t *testing.T) { + var _ chain.Router = (*Router)(nil) +} + +// ============================================================================= +// selector.Selector usage in chainGroup +// ============================================================================= + +// testSelector implements selector.Selector[chain.Chainer]. +type testSelector struct { + chains []chain.Chainer + idx int +} + +func (s *testSelector) Select(ctx context.Context, chains ...chain.Chainer) chain.Chainer { + if len(s.chains) == 0 { + return nil + } + ch := s.chains[s.idx%len(s.chains)] + s.idx++ + return ch +} + +func TestChainGroup_WithSelector(t *testing.T) { + ch1 := NewChain("ch1") + ch2 := NewChain("ch2") + g := NewChainGroup(ch1, ch2) + + sel := &testSelector{chains: []chain.Chainer{ch1, ch2}} + g.WithSelector(sel) + + // With selector, Route should delegate to the selected chain. + // But NewChain creates an empty chain (no hops), so Route returns nil. + // We need to add hops to make Route return a non-nil route. + node := chain.NewNode("n1", "127.0.0.1:8080", + chain.TransportNodeOption(NewTransport(&testDialer{}, &testConnector{}))) + h := &testHop{ + selectFn: func(ctx context.Context, opts ...hop.SelectOption) *chain.Node { return node }, + } + ch1.AddHop(h) + + rt := g.Route(context.Background(), "tcp", "127.0.0.1:80") + require.NotNil(t, rt) + assert.Len(t, rt.Nodes(), 1) +} + +func TestChainGroup_Next_NilReceiver(t *testing.T) { + var g *chainGroup + assert.Nil(t, g.next(context.Background())) +} + +func TestChainGroup_Next_EmptyChains(t *testing.T) { + g := NewChainGroup() + assert.Nil(t, g.next(context.Background())) +}