Files
x/hop/hop_test.go
T
ginuerzh 011759d888 docs(hosts,hop): add doc comments, fix missing httpLoader.Close, accumulate reload errors
- hosts/hosts.go: add package and symbol doc comments, fix missing
  httpLoader.Close() in Close()
- hosts/plugin: add doc comments for HTTP and gRPC plugin types
- hop/hop.go: add doc comments, accumulate reload/parse errors via errors.Join
  instead of silently discarding them
- hop/plugin/http.go: add doc comments and missing Close() method
- Add unit tests for hosts (580 lines) and hop (991 lines)
2026-05-24 16:00:53 +08:00

1324 lines
36 KiB
Go

package hop
import (
"context"
"errors"
"io"
"net"
"strings"
"testing"
"time"
"github.com/go-gost/core/bypass"
"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/routing"
"github.com/go-gost/x/registry"
xlogger "github.com/go-gost/x/logger"
)
// --- Mock types ---
type testBypass struct {
whitelist bool
contains bool
}
func (b *testBypass) IsWhitelist() bool { return b.whitelist }
func (b *testBypass) Init(md metadata.Metadata) error { return nil }
func (b *testBypass) Contains(ctx context.Context, network, addr string, opts ...bypass.Option) bool {
return b.contains
}
// testNodeSelector implements selector.Selector[*chain.Node].
type testNodeSelector struct {
selectedIdx int
callCount int
}
func (s *testNodeSelector) Select(ctx context.Context, nodes ...*chain.Node) *chain.Node {
if len(nodes) == 0 {
return nil
}
idx := s.selectedIdx % len(nodes)
s.callCount++
return nodes[idx]
}
type testMatcher struct {
match bool
}
func (m *testMatcher) Match(req *routing.Request) bool { return m.match }
type requestCapturingMatcher struct {
capture **routing.Request
match bool
}
func (m *requestCapturingMatcher) Match(req *routing.Request) bool {
if m.capture != nil {
*m.capture = req
}
return m.match
}
type testLoader struct {
loadFn func(ctx context.Context) (io.Reader, error)
closeFn func() error
}
func (l *testLoader) Load(ctx context.Context) (io.Reader, error) {
if l.loadFn != nil {
return l.loadFn(ctx)
}
return nil, nil
}
func (l *testLoader) Close() error {
if l.closeFn != nil {
return l.closeFn()
}
return nil
}
type stubConnector struct{}
func (c *stubConnector) Init(md metadata.Metadata) error { return nil }
func (c *stubConnector) Connect(ctx context.Context, conn net.Conn, network, address string, opts ...connector.ConnectOption) (net.Conn, error) {
return conn, nil
}
func newStubConnector(opts ...connector.Option) connector.Connector {
return &stubConnector{}
}
func newStubDialer(opts ...dialer.Option) dialer.Dialer {
return &stubDialer{}
}
type stubDialer struct{}
func (d *stubDialer) Init(md metadata.Metadata) error { return nil }
func (d *stubDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOption) (net.Conn, error) {
return &stubConn{}, nil
}
type stubConn struct{}
func (c *stubConn) Read(b []byte) (n int, err error) { return 0, io.EOF }
func (c *stubConn) Write(b []byte) (n int, err error) { return len(b), nil }
func (c *stubConn) Close() error { return nil }
func (c *stubConn) LocalAddr() net.Addr { return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0} }
func (c *stubConn) RemoteAddr() net.Addr { return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0} }
func (c *stubConn) SetDeadline(t time.Time) error { return nil }
func (c *stubConn) SetReadDeadline(t time.Time) error { return nil }
func (c *stubConn) SetWriteDeadline(t time.Time) error { return nil }
func init() {
logger.SetDefault(xlogger.Nop())
registry.ConnectorRegistry().Register("http", newStubConnector)
registry.ConnectorRegistry().Register("socks5", newStubConnector)
registry.DialerRegistry().Register("tcp", newStubDialer)
}
// Helper to create a hop and return *chainHop for Close access.
func newTestHop(opts ...Option) *chainHop {
return NewHop(opts...).(*chainHop)
}
// =============================================================================
// Option tests
// =============================================================================
func TestNameOption(t *testing.T) {
var o options
NameOption("my-hop")(&o)
if o.name != "my-hop" {
t.Errorf("expected name 'my-hop', got %q", o.name)
}
}
func TestNodeOption(t *testing.T) {
var o options
n1 := chain.NewNode("n1", "127.0.0.1:8080")
n2 := chain.NewNode("n2", "127.0.0.1:9090")
NodeOption(n1, n2)(&o)
if len(o.nodes) != 2 {
t.Fatalf("expected 2 nodes, got %d", len(o.nodes))
}
if o.nodes[0].Name != "n1" || o.nodes[1].Name != "n2" {
t.Error("unexpected node content")
}
}
func TestBypassOption(t *testing.T) {
var o options
bp := &testBypass{}
BypassOption(bp)(&o)
if o.bypass != bp {
t.Error("bypass not set correctly")
}
}
func TestSelectorOption(t *testing.T) {
var o options
sel := &testNodeSelector{}
SelectorOption(sel)(&o)
if o.selector != sel {
t.Error("selector not set correctly")
}
}
func TestReloadPeriodOption(t *testing.T) {
var o options
ReloadPeriodOption(5 * time.Second)(&o)
if o.period != 5*time.Second {
t.Errorf("expected period 5s, got %v", o.period)
}
}
func TestFileLoaderOption(t *testing.T) {
var o options
ld := &testLoader{}
FileLoaderOption(ld)(&o)
if o.fileLoader != ld {
t.Error("fileLoader not set correctly")
}
}
func TestRedisLoaderOption(t *testing.T) {
var o options
ld := &testLoader{}
RedisLoaderOption(ld)(&o)
if o.redisLoader != ld {
t.Error("redisLoader not set correctly")
}
}
func TestHTTPLoaderOption(t *testing.T) {
var o options
ld := &testLoader{}
HTTPLoaderOption(ld)(&o)
if o.httpLoader != ld {
t.Error("httpLoader not set correctly")
}
}
func TestLoggerOption(t *testing.T) {
var o options
l := xlogger.Nop()
LoggerOption(l)(&o)
if o.logger != l {
t.Error("logger not set correctly")
}
}
// =============================================================================
// NewHop tests
// =============================================================================
func TestNewHop_Empty(t *testing.T) {
h := newTestHop()
defer h.Close()
nodes := h.Nodes()
if len(nodes) != 0 {
t.Errorf("expected 0 nodes, got %d", len(nodes))
}
}
func TestNewHop_WithNodes(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
n2 := chain.NewNode("n2", "127.0.0.1:9090")
h := newTestHop(NodeOption(n1, n2))
defer h.Close()
nodes := h.Nodes()
if len(nodes) != 2 {
t.Fatalf("expected 2 nodes, got %d", len(nodes))
}
}
func TestNewHop_NilLoggerDefaultsToNop(t *testing.T) {
h := newTestHop()
defer h.Close()
if h.logger == nil {
t.Error("expected non-nil logger (should default to Nop)")
}
}
func TestNewHop_WithLogger(t *testing.T) {
l := xlogger.Nop()
h := newTestHop(LoggerOption(l))
defer h.Close()
if h.logger != l {
t.Error("logger not set on chainHop")
}
}
func TestNewHop_NilOptionIgnored(t *testing.T) {
h := newTestHop(nil)
defer h.Close()
if h == nil {
t.Fatal("NewHop returned nil")
}
}
// =============================================================================
// Nodes tests
// =============================================================================
func TestNodes_NilReceiver(t *testing.T) {
var ch *chainHop
nodes := ch.Nodes()
if nodes != nil {
t.Error("expected nil from nil receiver")
}
}
func TestNodes_Empty(t *testing.T) {
ch := &chainHop{}
nodes := ch.Nodes()
if len(nodes) != 0 {
t.Errorf("expected 0 nodes, got %d", len(nodes))
}
}
func TestNodes_WithData(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
ch := &chainHop{nodes: []*chain.Node{n1}}
nodes := ch.Nodes()
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
if nodes[0].Name != "n1" {
t.Errorf("expected 'n1', got %q", nodes[0].Name)
}
}
// =============================================================================
// Select tests
// =============================================================================
func TestSelect_EmptyNodes(t *testing.T) {
h := newTestHop()
defer h.Close()
node := h.Select(context.Background())
if node != nil {
t.Error("expected nil from empty hop")
}
}
func TestSelect_SingleNode(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
h := newTestHop(NodeOption(n1))
defer h.Close()
node := h.Select(context.Background())
if node == nil {
t.Fatal("expected node, got nil")
}
if node.Name != "n1" {
t.Errorf("expected 'n1', got %q", node.Name)
}
}
func TestSelect_HopBypass_BlocksAll(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
h := newTestHop(
NodeOption(n1),
BypassOption(&testBypass{contains: true}),
)
defer h.Close()
node := h.Select(context.Background())
if node != nil {
t.Error("expected nil when hop-level bypass matches")
}
}
func TestSelect_HopBypass_Allows(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
h := newTestHop(
NodeOption(n1),
BypassOption(&testBypass{contains: false}),
)
defer h.Close()
node := h.Select(context.Background())
if node == nil {
t.Error("expected node when bypass does not match")
}
}
func TestSelect_NodeBypass_SkipsBlockedNode(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080",
chain.BypassNodeOption(&testBypass{contains: true}),
)
n2 := chain.NewNode("n2", "127.0.0.1:9090")
h := newTestHop(NodeOption(n1, n2))
defer h.Close()
node := h.Select(context.Background())
if node == nil {
t.Fatal("expected node, got nil")
}
if node.Name != "n2" {
t.Errorf("expected 'n2' (n1 blocked), got %q", node.Name)
}
}
func TestSelect_NodeBypass_AllNodesBlocked(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080",
chain.BypassNodeOption(&testBypass{contains: true}),
)
h := newTestHop(NodeOption(n1))
defer h.Close()
node := h.Select(context.Background())
if node != nil {
t.Error("expected nil when all nodes blocked")
}
}
func TestSelect_MatcherMatches(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080",
chain.MatcherNodeOption(&testMatcher{match: true}),
)
h := newTestHop(NodeOption(n1))
defer h.Close()
node := h.Select(context.Background())
if node == nil {
t.Fatal("expected node, got nil")
}
if node.Name != "n1" {
t.Errorf("expected 'n1', got %q", node.Name)
}
}
func TestSelect_MatcherDoesNotMatch(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080",
chain.MatcherNodeOption(&testMatcher{match: false}),
)
n2 := chain.NewNode("n2", "127.0.0.1:9090")
h := newTestHop(NodeOption(n1, n2))
defer h.Close()
node := h.Select(context.Background())
if node == nil {
t.Fatal("expected node, got nil")
}
if node.Name != "n2" {
t.Errorf("expected 'n2' (n1 didn't match), got %q", node.Name)
}
}
func TestSelect_Priority_HighestWins(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080", chain.PriorityNodeOption(5))
n2 := chain.NewNode("n2", "127.0.0.1:9090", chain.PriorityNodeOption(10))
n3 := chain.NewNode("n3", "127.0.0.1:7070")
h := newTestHop(NodeOption(n1, n2, n3))
defer h.Close()
node := h.Select(context.Background())
if node == nil {
t.Fatal("expected node, got nil")
}
if node.Name != "n2" {
t.Errorf("expected 'n2' (highest priority), got %q", node.Name)
}
}
func TestSelect_Priority_ZeroPriorityFallsToFirst(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:9090")
n2 := chain.NewNode("n2", "127.0.0.1:8080")
h := newTestHop(NodeOption(n1, n2))
defer h.Close()
node := h.Select(context.Background())
if node == nil {
t.Fatal("expected node, got nil")
}
if node.Name != "n1" {
t.Errorf("expected 'n1' (first node), got %q", node.Name)
}
}
func TestSelect_Priority_NegativeIgnored(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080", chain.PriorityNodeOption(-5))
n2 := chain.NewNode("n2", "127.0.0.1:9090")
h := newTestHop(NodeOption(n1, n2))
defer h.Close()
node := h.Select(context.Background())
if node == nil {
t.Fatal("expected node, got nil")
}
// n2 sorted before n1 (-5 <= 0), and both have priority <= 0
if node.Name != "n2" {
t.Errorf("expected 'n2', got %q", node.Name)
}
}
func TestSelect_WithSelector(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
n2 := chain.NewNode("n2", "127.0.0.1:9090")
sel := &testNodeSelector{selectedIdx: 1}
h := newTestHop(NodeOption(n1, n2), SelectorOption(sel))
defer h.Close()
node := h.Select(context.Background())
if node == nil {
t.Fatal("expected node, got nil")
}
if node.Name != "n2" {
t.Errorf("expected 'n2' (selected by selector), got %q", node.Name)
}
}
func TestSelect_NilNodesSkipped(t *testing.T) {
h := newTestHop()
defer h.Close()
h.nodes = []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080"), nil, chain.NewNode("n3", "127.0.0.1:7070")}
node := h.Select(context.Background())
if node == nil {
t.Fatal("expected node, got nil")
}
if node.Name == "" {
t.Error("expected non-empty node name")
}
}
// =============================================================================
// isEligible tests
// =============================================================================
func TestIsEligible_NilNode(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
if ch.isEligible(nil, &hop.SelectOptions{}) {
t.Error("expected false for nil node")
}
}
func TestIsEligible_NoFilter(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
if !ch.isEligible(n, &hop.SelectOptions{}) {
t.Error("expected true when no filter")
}
}
func TestIsEligible_HostMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"}
if !ch.isEligible(n, &hop.SelectOptions{Host: "example.com"}) {
t.Error("expected true for matching host")
}
}
func TestIsEligible_HostMismatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"}
if ch.isEligible(n, &hop.SelectOptions{Host: "other.com"}) {
t.Error("expected false for mismatching host")
}
}
func TestIsEligible_ProtocolMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Protocol: "http"}
if !ch.isEligible(n, &hop.SelectOptions{Protocol: "http"}) {
t.Error("expected true for matching protocol")
}
}
func TestIsEligible_ProtocolMismatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Protocol: "http"}
if ch.isEligible(n, &hop.SelectOptions{Protocol: "socks5"}) {
t.Error("expected false for mismatching protocol")
}
}
func TestIsEligible_PathMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Path: "/api"}
if !ch.isEligible(n, &hop.SelectOptions{Path: "/api/v1/users"}) {
t.Error("expected true for matching path prefix")
}
}
func TestIsEligible_PathMismatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Path: "/api"}
if ch.isEligible(n, &hop.SelectOptions{Path: "/other/route"}) {
t.Error("expected false for mismatching path")
}
}
func TestIsEligible_AllFiltersMustPass(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{
Host: "example.com",
Protocol: "http",
}
if ch.isEligible(n, &hop.SelectOptions{Host: "example.com", Protocol: "socks5"}) {
t.Error("expected false: host matches but protocol doesn't")
}
}
// =============================================================================
// checkHost tests
// =============================================================================
func TestCheckHost_EmptyFilter(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
if !ch.checkHost("anything.com", n) {
t.Error("expected true when no host filter")
}
}
func TestCheckHost_ExactMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"}
if !ch.checkHost("example.com", n) {
t.Error("expected true for exact host match")
}
}
func TestCheckHost_WildcardMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Host: ".example.com"}
if !ch.checkHost("sub.example.com", n) {
t.Error("expected true for wildcard host match")
}
}
func TestCheckHost_WildcardNoMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Host: ".example.com"}
if ch.checkHost("other.net", n) {
t.Error("expected false for non-matching wildcard")
}
}
func TestCheckHost_EmptyInputHost(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"}
if ch.checkHost("", n) {
t.Error("expected false when input host is empty and filter is set")
}
}
func TestCheckHost_StripPort(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"}
if !ch.checkHost("example.com:443", n) {
t.Error("expected true after stripping port")
}
}
func TestCheckHost_IPWithPort(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Host: "192.168.1.1"}
if !ch.checkHost("192.168.1.1:3128", n) {
t.Error("expected true for IP with port")
}
}
func TestCheckHost_IPv6WithPort(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Host: "::1"}
if !ch.checkHost("[::1]:8080", n) {
t.Error("expected true for bracketed IPv6 with port")
}
}
func TestCheckHost_NoMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"}
if ch.checkHost("other.com", n) {
t.Error("expected false for non-matching host")
}
}
// =============================================================================
// checkProtocol tests
// =============================================================================
func TestCheckProtocol_EmptyFilter(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
if !ch.checkProtocol("http", n) {
t.Error("expected true when no protocol filter")
}
}
func TestCheckProtocol_Match(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Protocol: "http"}
if !ch.checkProtocol("http", n) {
t.Error("expected true for matching protocol")
}
}
func TestCheckProtocol_NoMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Protocol: "socks5"}
if ch.checkProtocol("http", n) {
t.Error("expected false for non-matching protocol")
}
}
// =============================================================================
// checkPath tests
// =============================================================================
func TestCheckPath_EmptyFilter(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
if !ch.checkPath("/any/path", n) {
t.Error("expected true when no path filter")
}
}
func TestCheckPath_PrefixMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Path: "/api"}
if !ch.checkPath("/api/v1/users", n) {
t.Error("expected true for prefix match")
}
}
func TestCheckPath_ExactMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Path: "/api"}
if !ch.checkPath("/api", n) {
t.Error("expected true for exact match (prefix of self)")
}
}
func TestCheckPath_NoMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Path: "/api"}
if ch.checkPath("/other", n) {
t.Error("expected false for non-prefix match")
}
}
func TestCheckPath_ShorterInput(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Path: "/api"}
if ch.checkPath("/ap", n) {
t.Error("expected false when input is shorter than filter prefix")
}
}
// =============================================================================
// Select with filter integration tests
// =============================================================================
func TestSelect_FilterHost_Match(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
n1.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"}
h := newTestHop(NodeOption(n1))
defer h.Close()
node := h.Select(context.Background(), hop.HostSelectOption("example.com"))
if node == nil {
t.Fatal("expected node, got nil")
}
}
func TestSelect_FilterHost_NoMatch(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
n1.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"}
n2 := chain.NewNode("n2", "127.0.0.1:9090")
h := newTestHop(NodeOption(n1, n2))
defer h.Close()
node := h.Select(context.Background(), hop.HostSelectOption("other.com"))
if node == nil {
t.Fatal("expected node, got nil")
}
if node.Name != "n2" {
t.Errorf("expected 'n2' (n1 host mismatch), got %q", node.Name)
}
}
func TestSelect_FilterProtocol_Match(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
n1.Options().Filter = &chain.NodeFilterSettings{Protocol: "http"}
h := newTestHop(NodeOption(n1))
defer h.Close()
node := h.Select(context.Background(), hop.ProtocolSelectOption("http"))
if node == nil {
t.Fatal("expected node, got nil")
}
}
func TestSelect_FilterProtocol_NoMatch(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
n1.Options().Filter = &chain.NodeFilterSettings{Protocol: "http"}
n2 := chain.NewNode("n2", "127.0.0.1:9090")
h := newTestHop(NodeOption(n1, n2))
defer h.Close()
node := h.Select(context.Background(), hop.ProtocolSelectOption("socks5"))
if node == nil {
t.Fatal("expected node, got nil")
}
if node.Name != "n2" {
t.Errorf("expected 'n2' (n1 protocol mismatch), got %q", node.Name)
}
}
// =============================================================================
// Select with select options
// =============================================================================
func TestSelect_WithNetworkOption(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
h := newTestHop(NodeOption(n1))
defer h.Close()
node := h.Select(context.Background(), hop.NetworkSelectOption("tcp"))
if node == nil {
t.Fatal("expected node, got nil")
}
}
func TestSelect_WithAddrOption(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
h := newTestHop(NodeOption(n1))
defer h.Close()
node := h.Select(context.Background(), hop.AddrSelectOption("example.com:80"))
if node == nil {
t.Fatal("expected node, got nil")
}
}
func TestSelect_WithClientIPOption(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080",
chain.MatcherNodeOption(&testMatcher{match: true}),
)
h := newTestHop(NodeOption(n1))
defer h.Close()
ip := net.ParseIP("10.0.0.1")
node := h.Select(context.Background(), hop.ClientIPSelectOption(ip))
if node == nil {
t.Fatal("expected node, got nil")
}
}
func TestSelect_WithMethodOption(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080",
chain.MatcherNodeOption(&testMatcher{match: true}),
)
h := newTestHop(NodeOption(n1))
defer h.Close()
node := h.Select(context.Background(), hop.MethodSelectOption("CONNECT"))
if node == nil {
t.Fatal("expected node, got nil")
}
}
func TestSelect_WithHeaderOption(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080",
chain.MatcherNodeOption(&testMatcher{match: true}),
)
h := newTestHop(NodeOption(n1))
defer h.Close()
header := map[string][]string{"X-Forwarded-For": {"1.2.3.4"}}
node := h.Select(context.Background(), hop.HeaderSelectOption(header))
if node == nil {
t.Fatal("expected node, got nil")
}
}
func TestSelect_WithMultipleOptions(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
h := newTestHop(NodeOption(n1))
defer h.Close()
node := h.Select(context.Background(),
hop.NetworkSelectOption("tcp"),
hop.HostSelectOption("example.com"),
hop.ProtocolSelectOption("http"),
hop.PathSelectOption("/api"),
)
if node == nil {
t.Fatal("expected node, got nil")
}
}
func TestSelect_MatcherWithFullRequest(t *testing.T) {
var capturedReq *routing.Request
n1 := chain.NewNode("n1", "127.0.0.1:8080",
chain.MatcherNodeOption(&requestCapturingMatcher{capture: &capturedReq, match: true}),
)
h := newTestHop(NodeOption(n1))
defer h.Close()
ip := net.ParseIP("10.0.0.1")
node := h.Select(context.Background(),
hop.ClientIPSelectOption(ip),
hop.HostSelectOption("example.com"),
hop.ProtocolSelectOption("http"),
hop.MethodSelectOption("GET"),
hop.PathSelectOption("/api/v1"),
)
if node == nil {
t.Fatal("expected node, got nil")
}
}
// =============================================================================
// reload tests
// =============================================================================
func TestReload_NoLoaders(t *testing.T) {
ch := &chainHop{
options: options{nodes: []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080")}},
logger: xlogger.Nop(),
}
err := ch.reload(context.Background())
if err != nil {
t.Errorf("unexpected error: %v", err)
}
nodes := ch.Nodes()
if len(nodes) != 1 {
t.Errorf("expected 1 node, got %d", len(nodes))
}
}
func TestReload_WithFileLoader(t *testing.T) {
loader := &testLoader{
loadFn: func(ctx context.Context) (io.Reader, error) {
return strings.NewReader(`[{"name": "loaded", "addr": "10.0.0.1:8080"}]`), nil
},
}
ch := &chainHop{
logger: xlogger.Nop(),
options: options{nodes: []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080")}, fileLoader: loader, name: "test-hop"},
}
err := ch.reload(context.Background())
if err != nil {
t.Errorf("unexpected error: %v", err)
}
nodes := ch.Nodes()
if len(nodes) < 2 {
t.Errorf("expected at least 2 nodes, got %d", len(nodes))
}
}
func TestReload_LoaderError(t *testing.T) {
loadErr := errors.New("load failed")
loader := &testLoader{
loadFn: func(ctx context.Context) (io.Reader, error) {
return nil, loadErr
},
}
ch := &chainHop{
logger: xlogger.Nop(),
options: options{nodes: []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080")}, fileLoader: loader},
}
err := ch.reload(context.Background())
if err == nil {
t.Error("expected error from failed loader")
}
}
func TestReload_RedisLoader(t *testing.T) {
loader := &testLoader{
loadFn: func(ctx context.Context) (io.Reader, error) {
return strings.NewReader(`[{"name": "redis-node", "addr": "10.0.0.2:8080"}]`), nil
},
}
ch := &chainHop{
logger: xlogger.Nop(),
options: options{nodes: []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080")}, redisLoader: loader, name: "test-hop"},
}
err := ch.reload(context.Background())
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestReload_HTTPLoader(t *testing.T) {
loader := &testLoader{
loadFn: func(ctx context.Context) (io.Reader, error) {
return strings.NewReader(`[{"name": "http-node", "addr": "10.0.0.3:8080"}]`), nil
},
}
ch := &chainHop{
logger: xlogger.Nop(),
options: options{nodes: []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080")}, httpLoader: loader, name: "test-hop"},
}
err := ch.reload(context.Background())
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestReload_MultipleLoaders(t *testing.T) {
fileLoader := &testLoader{
loadFn: func(ctx context.Context) (io.Reader, error) {
return strings.NewReader(`[{"name": "file-n", "addr": "10.0.0.1:8080"}]`), nil
},
}
redisLoader := &testLoader{
loadFn: func(ctx context.Context) (io.Reader, error) {
return strings.NewReader(`[{"name": "redis-n", "addr": "10.0.0.2:8080"}]`), nil
},
}
ch := &chainHop{
logger: xlogger.Nop(),
options: options{
nodes: []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080")},
fileLoader: fileLoader,
redisLoader: redisLoader,
name: "test-hop",
},
}
err := ch.reload(context.Background())
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestReload_LoaderErrorWithParseError(t *testing.T) {
loader := &testLoader{
loadFn: func(ctx context.Context) (io.Reader, error) {
return strings.NewReader(`invalid json`), nil
},
}
ch := &chainHop{
logger: xlogger.Nop(),
options: options{nodes: []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080")}, fileLoader: loader},
}
err := ch.reload(context.Background())
if err == nil {
t.Error("expected parse error")
}
}
// =============================================================================
// parseNode tests
// =============================================================================
func TestParseNode_NilReader(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
nodes, err := ch.parseNode(nil)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if len(nodes) != 0 {
t.Errorf("expected 0 nodes, got %d", len(nodes))
}
}
func TestParseNode_ValidJSON(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop(), options: options{name: "test-hop"}}
r := strings.NewReader(`[{"name": "n1", "addr": "10.0.0.1:8080"}]`)
nodes, err := ch.parseNode(r)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
if nodes[0].Name != "n1" {
t.Errorf("expected 'n1', got %q", nodes[0].Name)
}
}
func TestParseNode_EmptyArray(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
r := strings.NewReader(`[]`)
nodes, err := ch.parseNode(r)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(nodes) != 0 {
t.Errorf("expected 0 nodes, got %d", len(nodes))
}
}
func TestParseNode_InvalidJSON(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
r := strings.NewReader(`not json`)
nodes, err := ch.parseNode(r)
if err == nil {
t.Error("expected error for invalid JSON")
}
if len(nodes) != 0 {
t.Errorf("expected 0 nodes, got %d", len(nodes))
}
}
func TestParseNode_NilEntrySkipped(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop(), options: options{name: "test-hop"}}
r := strings.NewReader(`[null, {"name": "ok", "addr": "10.0.0.1:8080"}]`)
nodes, err := ch.parseNode(r)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
if nodes[0].Name != "ok" {
t.Errorf("expected 'ok', got %q", nodes[0].Name)
}
}
func TestParseNode_SkipInvalidEntry(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop(), options: options{name: "test-hop"}}
r := strings.NewReader(`[{"name": "bad"}]`)
nodes, err := ch.parseNode(r)
// May return an error via errors.Join even if some nodes parsed
_ = err
_ = nodes
}
// =============================================================================
// Close tests
// =============================================================================
func TestClose_NoLoaders(t *testing.T) {
h := newTestHop()
err := h.Close()
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestClose_WithLoaders(t *testing.T) {
fileClosed := false
redisClosed := false
httpClosed := false
h := newTestHop(
FileLoaderOption(&testLoader{closeFn: func() error { fileClosed = true; return nil }}),
RedisLoaderOption(&testLoader{closeFn: func() error { redisClosed = true; return nil }}),
HTTPLoaderOption(&testLoader{closeFn: func() error { httpClosed = true; return nil }}),
)
err := h.Close()
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !fileClosed {
t.Error("fileLoader not closed")
}
if !redisClosed {
t.Error("redisLoader not closed")
}
if !httpClosed {
t.Error("httpLoader not closed")
}
}
func TestClose_CancelFuncCalled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
ch := &chainHop{
cancelFunc: cancel,
logger: xlogger.Nop(),
}
ch.Close()
select {
case <-ctx.Done():
// expected
default:
t.Error("context should be cancelled after Close")
}
}
// =============================================================================
// periodReload tests
// =============================================================================
func TestPeriodReload_NoPeriod(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := &chainHop{
options: options{period: 0},
logger: xlogger.Nop(),
nodes: []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080")},
}
err := ch.periodReload(ctx)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestPeriodReload_CancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
ch := &chainHop{
options: options{period: time.Second},
logger: xlogger.Nop(),
nodes: []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080")},
}
err := ch.periodReload(ctx)
if !errors.Is(err, context.Canceled) {
t.Errorf("expected context.Canceled, got %v", err)
}
}
func TestPeriodReload_MinimumPeriod(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
ch := &chainHop{
options: options{period: time.Millisecond},
logger: xlogger.Nop(),
nodes: []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080")},
}
time.AfterFunc(50*time.Millisecond, cancel)
err := ch.periodReload(ctx)
if !errors.Is(err, context.Canceled) {
t.Errorf("expected context.Canceled, got %v", err)
}
}
// =============================================================================
// Priority edge cases
// =============================================================================
func TestSelect_AllSamePriority(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
n2 := chain.NewNode("n2", "127.0.0.1:9090")
n3 := chain.NewNode("n3", "127.0.0.1:7070")
h := newTestHop(NodeOption(n1, n2, n3))
defer h.Close()
node := h.Select(context.Background())
if node == nil {
t.Fatal("expected node, got nil")
}
if node.Name != "n1" {
t.Errorf("expected 'n1' (first node), got %q", node.Name)
}
}
func TestSelect_OnlyNegativePriorities(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080", chain.PriorityNodeOption(-5))
n2 := chain.NewNode("n2", "127.0.0.1:9090", chain.PriorityNodeOption(-10))
h := newTestHop(NodeOption(n1, n2))
defer h.Close()
node := h.Select(context.Background())
if node == nil {
t.Fatal("expected node, got nil")
}
if node.Name != "n1" {
t.Errorf("expected 'n1' (higher priority), got %q", node.Name)
}
}
// =============================================================================
// Select with backup node (empty host filter means backup)
// =============================================================================
func TestSelect_BackupNode_HostFilterEmpty(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
n1.Options().Filter = &chain.NodeFilterSettings{Host: ""}
n2 := chain.NewNode("n2", "127.0.0.1:9090")
n2.Options().Filter = &chain.NodeFilterSettings{Host: "specific.com"}
h := newTestHop(NodeOption(n1, n2))
defer h.Close()
node := h.Select(context.Background(), hop.HostSelectOption("other.com"))
if node == nil {
t.Fatal("expected node, got nil")
}
if node.Name != "n1" {
t.Errorf("expected 'n1' (backup), got %q", node.Name)
}
}
func TestSelect_SpecificHostNodeWins(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
n1.Options().Filter = &chain.NodeFilterSettings{Host: ""}
n2 := chain.NewNode("n2", "127.0.0.1:9090")
n2.Options().Filter = &chain.NodeFilterSettings{Host: "specific.com"}
h := newTestHop(NodeOption(n1, n2))
defer h.Close()
node := h.Select(context.Background(), hop.HostSelectOption("specific.com"))
if node == nil {
t.Fatal("expected node, got nil")
}
if node.Name != "n1" {
t.Errorf("expected 'n1' (first node when both eligible), got %q", node.Name)
}
}
// =============================================================================
// Interface satisfaction
// =============================================================================
func TestChainHop_ImplementsHop(t *testing.T) {
var _ hop.Hop = (*chainHop)(nil)
}
func TestChainHop_ImplementsNodeList(t *testing.T) {
var _ hop.NodeList = (*chainHop)(nil)
}
func TestNewHop_ReturnsHopInterface(t *testing.T) {
h := NewHop()
if h == nil {
t.Fatal("expected non-nil Hop")
}
defer h.(*chainHop).Close()
}
// =============================================================================
// NewHop starts periodReload goroutine
// =============================================================================
func TestNewHop_StartsPeriodReload(t *testing.T) {
h := NewHop()
ch := h.(*chainHop)
if ch.cancelFunc == nil {
t.Error("expected non-nil cancelFunc")
}
h.(*chainHop).Close()
}