Files
x/hop/hop_test.go
T
ginuerzh cd97e5e746 fix(hop): skip priority short-circuit when multiple nodes share the same priority
When two or more nodes have an identical matcher rule they receive
identical default priority (rule string length). The priority shortcut
assumed the top-priority node was the single authoritative choice and
returned it directly, bypassing the selector (FailFilter, BackupFilter,
strategy) — so weight, round-robin, and hash were silently ignored.

Add a third condition: the top priority must be strictly greater than
the second-highest. When multiple nodes share the same highest priority
the selector applies normally for load balancing.

Add doc comments on NodeMatcherConfig.Priority explaining the three
semantic ranges (0=auto, negative=always-selector, positive=shortcut).
2026-06-26 22:53:17 +08:00

1386 lines
38 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_MatcherReceivesNetwork(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()
h.Select(context.Background(), hop.NetworkSelectOption("tcp"))
if capturedReq == nil {
t.Fatal("request was not passed to matcher")
}
if capturedReq.Network != "tcp" {
t.Errorf("matcher saw Network=%q, want %q", capturedReq.Network, "tcp")
}
}
func TestSelect_MatcherMatchNetwork(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",
chain.MatcherNodeOption(&testMatcher{match: true}),
)
h := newTestHop(NodeOption(n1, n2))
defer h.Close()
node := h.Select(context.Background(), hop.NetworkSelectOption("udp"))
if node == nil {
t.Fatal("expected selected node, got nil")
}
if node.Name != "n2" {
t.Errorf("expected 'n2' (match=true), 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_EqualPriorityDoesNotShortcut(t *testing.T) {
// Two nodes with identical matchers get the same default priority.
// The priority shortcut must NOT trigger when multiple nodes share
// the highest priority — the selector should still apply.
n1 := chain.NewNode("n1", "127.0.0.1:8080",
chain.MatcherNodeOption(&testMatcher{match: true}),
chain.PriorityNodeOption(50),
)
n2 := chain.NewNode("n2", "127.0.0.1:9090",
chain.MatcherNodeOption(&testMatcher{match: true}),
chain.PriorityNodeOption(50),
)
sel := &testNodeSelector{selectedIdx: 1} // picks n2
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, not shortcut), 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()
}