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() }