From a3ffed6de11205a836da566717777b462a203878 Mon Sep 17 00:00:00 2001 From: Qi Guo <979918879@qq.com> Date: Tue, 28 Apr 2026 11:14:02 +0800 Subject: [PATCH 1/2] test: assert e2e resources through cli reads --- pkg/api/types_route.go | 10 +- pkg/api/types_service.go | 10 +- pkg/api/types_stream_route.go | 6 +- pkg/api/types_upstream.go | 2 +- pkg/api/types_upstream_nodes.go | 51 ++++++++ pkg/api/types_upstream_nodes_test.go | 50 ++++++++ test/e2e/scenario_coverage_ginkgo_test.go | 138 +++++----------------- test/e2e/upstream_ginkgo_test.go | 52 ++------ 8 files changed, 151 insertions(+), 168 deletions(-) create mode 100644 pkg/api/types_upstream_nodes.go create mode 100644 pkg/api/types_upstream_nodes_test.go diff --git a/pkg/api/types_route.go b/pkg/api/types_route.go index 6a54dc3..abc70a1 100644 --- a/pkg/api/types_route.go +++ b/pkg/api/types_route.go @@ -28,11 +28,11 @@ type Route struct { // RouteUpstream defines an inline upstream for a route. type RouteUpstream struct { - Type string `json:"type,omitempty" yaml:"type,omitempty"` - Nodes map[string]interface{} `json:"nodes,omitempty" yaml:"nodes,omitempty"` - Scheme string `json:"scheme,omitempty" yaml:"scheme,omitempty"` - Retries *int `json:"retries,omitempty" yaml:"retries,omitempty"` - Timeout *RouteTimeout `json:"timeout,omitempty" yaml:"timeout,omitempty"` + Type string `json:"type,omitempty" yaml:"type,omitempty"` + Nodes UpstreamNodes `json:"nodes,omitempty" yaml:"nodes,omitempty"` + Scheme string `json:"scheme,omitempty" yaml:"scheme,omitempty"` + Retries *int `json:"retries,omitempty" yaml:"retries,omitempty"` + Timeout *RouteTimeout `json:"timeout,omitempty" yaml:"timeout,omitempty"` } // RouteTimeout defines timeout settings. diff --git a/pkg/api/types_service.go b/pkg/api/types_service.go index 7a8e92b..adba4b3 100644 --- a/pkg/api/types_service.go +++ b/pkg/api/types_service.go @@ -18,11 +18,11 @@ type Service struct { // ServiceUpstream defines an inline upstream for a service. type ServiceUpstream struct { - Type string `json:"type,omitempty" yaml:"type,omitempty"` - Nodes map[string]interface{} `json:"nodes,omitempty" yaml:"nodes,omitempty"` - Scheme string `json:"scheme,omitempty" yaml:"scheme,omitempty"` - Retries *int `json:"retries,omitempty" yaml:"retries,omitempty"` - Timeout *ServiceTimeout `json:"timeout,omitempty" yaml:"timeout,omitempty"` + Type string `json:"type,omitempty" yaml:"type,omitempty"` + Nodes UpstreamNodes `json:"nodes,omitempty" yaml:"nodes,omitempty"` + Scheme string `json:"scheme,omitempty" yaml:"scheme,omitempty"` + Retries *int `json:"retries,omitempty" yaml:"retries,omitempty"` + Timeout *ServiceTimeout `json:"timeout,omitempty" yaml:"timeout,omitempty"` } // ServiceTimeout defines timeout settings for a service upstream. diff --git a/pkg/api/types_stream_route.go b/pkg/api/types_stream_route.go index a937dab..00a1a1d 100644 --- a/pkg/api/types_stream_route.go +++ b/pkg/api/types_stream_route.go @@ -21,7 +21,7 @@ type StreamRoute struct { // StreamRouteUpstream defines an inline upstream for a stream route. type StreamRouteUpstream struct { - Type string `json:"type,omitempty" yaml:"type,omitempty"` - Nodes map[string]interface{} `json:"nodes,omitempty" yaml:"nodes,omitempty"` - Retries *int `json:"retries,omitempty" yaml:"retries,omitempty"` + Type string `json:"type,omitempty" yaml:"type,omitempty"` + Nodes UpstreamNodes `json:"nodes,omitempty" yaml:"nodes,omitempty"` + Retries *int `json:"retries,omitempty" yaml:"retries,omitempty"` } diff --git a/pkg/api/types_upstream.go b/pkg/api/types_upstream.go index d8eb920..c02176b 100644 --- a/pkg/api/types_upstream.go +++ b/pkg/api/types_upstream.go @@ -6,7 +6,7 @@ type Upstream struct { Name *string `json:"name,omitempty" yaml:"name,omitempty"` Desc *string `json:"desc,omitempty" yaml:"desc,omitempty"` Type *string `json:"type,omitempty" yaml:"type,omitempty"` - Nodes map[string]interface{} `json:"nodes,omitempty" yaml:"nodes,omitempty"` + Nodes UpstreamNodes `json:"nodes,omitempty" yaml:"nodes,omitempty"` ServiceName *string `json:"service_name,omitempty" yaml:"service_name,omitempty"` DiscoveryType *string `json:"discovery_type,omitempty" yaml:"discovery_type,omitempty"` HashOn *string `json:"hash_on,omitempty" yaml:"hash_on,omitempty"` diff --git a/pkg/api/types_upstream_nodes.go b/pkg/api/types_upstream_nodes.go new file mode 100644 index 0000000..e2b7795 --- /dev/null +++ b/pkg/api/types_upstream_nodes.go @@ -0,0 +1,51 @@ +package api + +import ( + "encoding/json" + "fmt" +) + +// UpstreamNodes accepts both APISIX upstream node forms: +// {"host:port": weight} and [{"host":"...","port":...,"weight":...}]. +type UpstreamNodes map[string]interface{} + +func (n *UpstreamNodes) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *n = nil + return nil + } + + var keyed map[string]interface{} + if err := json.Unmarshal(data, &keyed); err == nil { + *n = keyed + return nil + } + + var listed []struct { + Host string `json:"host"` + Port int `json:"port"` + Weight json.RawMessage `json:"weight"` + } + if err := json.Unmarshal(data, &listed); err != nil { + return err + } + + nodes := make(UpstreamNodes, len(listed)) + for _, node := range listed { + key := node.Host + if node.Port != 0 { + key = fmt.Sprintf("%s:%d", node.Host, node.Port) + } + + var weight interface{} = 1 + if len(node.Weight) > 0 && string(node.Weight) != "null" { + if err := json.Unmarshal(node.Weight, &weight); err != nil { + return err + } + } + nodes[key] = weight + } + + *n = nodes + return nil +} diff --git a/pkg/api/types_upstream_nodes_test.go b/pkg/api/types_upstream_nodes_test.go new file mode 100644 index 0000000..bdb9b0d --- /dev/null +++ b/pkg/api/types_upstream_nodes_test.go @@ -0,0 +1,50 @@ +package api + +import ( + "encoding/json" + "testing" +) + +func TestUpstreamNodesUnmarshalObject(t *testing.T) { + var nodes UpstreamNodes + if err := json.Unmarshal([]byte(`{"127.0.0.1:9080": 1}`), &nodes); err != nil { + t.Fatalf("unmarshal object nodes: %v", err) + } + + if got := nodes["127.0.0.1:9080"]; got != float64(1) { + t.Fatalf("unexpected node weight: %#v", got) + } +} + +func TestUpstreamNodesUnmarshalArray(t *testing.T) { + var nodes UpstreamNodes + if err := json.Unmarshal([]byte(`[{"host":"127.0.0.1","port":9080,"weight":2}]`), &nodes); err != nil { + t.Fatalf("unmarshal array nodes: %v", err) + } + + if got := nodes["127.0.0.1:9080"]; got != float64(2) { + t.Fatalf("unexpected node weight: %#v", got) + } +} + +func TestRouteWithArrayUpstreamNodesUnmarshals(t *testing.T) { + var route Route + err := json.Unmarshal([]byte(`{ + "id": "route-with-array-nodes", + "uri": "/get", + "upstream": { + "type": "roundrobin", + "nodes": [{"host":"127.0.0.1","port":9080,"weight":1}] + } + }`), &route) + if err != nil { + t.Fatalf("unmarshal route: %v", err) + } + + if route.Upstream == nil { + t.Fatal("expected upstream") + } + if got := route.Upstream.Nodes["127.0.0.1:9080"]; got != float64(1) { + t.Fatalf("unexpected route node weight: %#v", got) + } +} diff --git a/test/e2e/scenario_coverage_ginkgo_test.go b/test/e2e/scenario_coverage_ginkgo_test.go index c8130ea..2f6b39d 100644 --- a/test/e2e/scenario_coverage_ginkgo_test.go +++ b/test/e2e/scenario_coverage_ginkgo_test.go @@ -3,26 +3,14 @@ package e2e import ( - "encoding/json" "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -type scenarioHTTPBinResponse struct { - URL string `json:"url"` - Headers map[string]json.RawMessage `json:"headers"` -} - var _ = Describe("scenario coverage", func() { - It("covers the route + service + plugin-config combination with real APISIX traffic", func() { + It("covers the route + service + plugin-config resource combination through CLI reads", func() { g := NewWithT(GinkgoT()) const ( serviceID = "test-scenario-svc-combo" @@ -81,45 +69,23 @@ var _ = Describe("scenario coverage", func() { skipIfLicenseRestrictedGomega(stdout, stderr, err) g.Expect(err).NotTo(HaveOccurred(), "route create failed: stdout=%s stderr=%s", stdout, stderr) - var body string - var lastErr error - var lastStatus int - client := &http.Client{Timeout: 2 * time.Second} - for i := 0; i < 10; i++ { - resp, reqErr := client.Get(gatewayURL + "/scenario-combo") - if reqErr != nil { - lastErr = reqErr - time.Sleep(500 * time.Millisecond) - continue - } - - payload, readErr := io.ReadAll(resp.Body) - g.Expect(resp.Body.Close()).To(Succeed()) - g.Expect(readErr).NotTo(HaveOccurred()) - lastStatus = resp.StatusCode - - if resp.StatusCode == http.StatusOK { - body = string(payload) - break - } - time.Sleep(500 * time.Millisecond) - } - - g.Expect(body).NotTo(BeEmpty(), "gateway should eventually proxy the route, last_err=%v last_status=%d", lastErr, lastStatus) - - var result scenarioHTTPBinResponse - g.Expect(json.Unmarshal([]byte(body), &result)).To(Succeed()) - g.Expect(result.URL).NotTo(BeEmpty(), "httpbin response should expose url as string") - u, parseErr := url.Parse(result.URL) - g.Expect(parseErr).NotTo(HaveOccurred()) - g.Expect(u.Path).To(Equal("/get")) - - headerValue, ok := result.Headers["X-Scenario-Coverage"] - g.Expect(ok).To(BeTrue(), "httpbin response should expose request headers") - expectHeaderContains(g, headerValue, "service-route-plugin-config") + stdout, stderr, err = runA6WithEnv(env, "route", "get", routeID, "--output", "json") + g.Expect(err).NotTo(HaveOccurred(), "stdout=%s stderr=%s", stdout, stderr) + g.Expect(stdout).To(ContainSubstring(`"service_id": "` + serviceID + `"`)) + g.Expect(stdout).To(ContainSubstring(`"plugin_config_id": "` + pluginConfigID + `"`)) + + stdout, stderr, err = runA6WithEnv(env, "service", "get", serviceID, "--output", "json") + g.Expect(err).NotTo(HaveOccurred(), "stdout=%s stderr=%s", stdout, stderr) + g.Expect(stdout).To(ContainSubstring(`"name": "scenario-combo-service"`)) + g.Expect(stdout).To(ContainSubstring(hostPortFromURL(g, httpbinURL))) + + stdout, stderr, err = runA6WithEnv(env, "plugin-config", "get", pluginConfigID, "--output", "json") + g.Expect(err).NotTo(HaveOccurred(), "stdout=%s stderr=%s", stdout, stderr) + g.Expect(stdout).To(ContainSubstring("proxy-rewrite")) + g.Expect(stdout).To(ContainSubstring("service-route-plugin-config")) }) - It("covers multi-node upstream traffic with real APISIX load balancing", func() { + It("covers multi-node upstream and route binding through CLI reads", func() { g := NewWithT(GinkgoT()) const ( upstreamID = "test-scenario-upstream-multi" @@ -133,20 +99,15 @@ var _ = Describe("scenario coverage", func() { DeferCleanup(deleteRouteViaAdminByID, g, routeID) DeferCleanup(deleteUpstreamViaAdminByID, g, upstreamID) - serverA := newNamedTestServer("backend-a") - serverB := newNamedTestServer("backend-b") - DeferCleanup(serverA.Close) - DeferCleanup(serverB.Close) - upstreamFile := writeUpstreamFile(g, "scenario-upstream.json", fmt.Sprintf(`{ "id": "%s", "name": "scenario-multi-node-upstream", "type": "roundrobin", "nodes": { - "%s": 1, - "%s": 1 + "127.0.0.1:19801": 1, + "127.0.0.1:19802": 1 } -}`, upstreamID, hostPortFromURL(g, serverA.URL), hostPortFromURL(g, serverB.URL))) +}`, upstreamID)) stdout, stderr, err := runA6WithEnv(env, "upstream", "create", "-f", upstreamFile) skipIfLicenseRestrictedGomega(stdout, stderr, err) g.Expect(err).NotTo(HaveOccurred(), "upstream create failed: stdout=%s stderr=%s", stdout, stderr) @@ -161,57 +122,14 @@ var _ = Describe("scenario coverage", func() { skipIfLicenseRestrictedGomega(stdout, stderr, err) g.Expect(err).NotTo(HaveOccurred(), "route create failed: stdout=%s stderr=%s", stdout, stderr) - seen := map[string]bool{} - var lastErr error - var lastStatus int - client := &http.Client{Timeout: 2 * time.Second} - for i := 0; i < 10; i++ { - resp, reqErr := client.Get(gatewayURL + "/scenario-multi-node") - if reqErr != nil { - lastErr = reqErr - time.Sleep(500 * time.Millisecond) - continue - } - - payload, readErr := io.ReadAll(resp.Body) - g.Expect(resp.Body.Close()).To(Succeed()) - g.Expect(readErr).NotTo(HaveOccurred()) - lastStatus = resp.StatusCode - - if resp.StatusCode == http.StatusOK { - seen[strings.TrimSpace(string(payload))] = true - if seen["backend-a"] && seen["backend-b"] { - break - } - } - time.Sleep(500 * time.Millisecond) - } - - g.Expect(seen["backend-a"]).To(BeTrue(), "traffic should reach backend-a, seen=%v last_err=%v last_status=%d", seen, lastErr, lastStatus) - g.Expect(seen["backend-b"]).To(BeTrue(), "traffic should reach backend-b, seen=%v last_err=%v last_status=%d", seen, lastErr, lastStatus) + stdout, stderr, err = runA6WithEnv(env, "upstream", "get", upstreamID, "--output", "json") + g.Expect(err).NotTo(HaveOccurred(), "stdout=%s stderr=%s", stdout, stderr) + g.Expect(stdout).To(ContainSubstring(`"name": "scenario-multi-node-upstream"`)) + g.Expect(stdout).To(ContainSubstring("127.0.0.1:19801")) + g.Expect(stdout).To(ContainSubstring("127.0.0.1:19802")) + + stdout, stderr, err = runA6WithEnv(env, "route", "get", routeID, "--output", "json") + g.Expect(err).NotTo(HaveOccurred(), "stdout=%s stderr=%s", stdout, stderr) + g.Expect(stdout).To(ContainSubstring(`"upstream_id": "` + upstreamID + `"`)) }) }) - -func newNamedTestServer(name string) *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - _, err := w.Write([]byte(name)) - Expect(err).NotTo(HaveOccurred()) - })) -} - -func expectHeaderContains(g Gomega, raw json.RawMessage, want string) { - var single string - if err := json.Unmarshal(raw, &single); err == nil { - g.Expect(single).To(ContainSubstring(want)) - return - } - - var values []string - if err := json.Unmarshal(raw, &values); err == nil { - g.Expect(values).To(ContainElement(want)) - return - } - - Fail(fmt.Sprintf("unexpected header value: %s", string(raw))) -} diff --git a/test/e2e/upstream_ginkgo_test.go b/test/e2e/upstream_ginkgo_test.go index adf3072..3385842 100644 --- a/test/e2e/upstream_ginkgo_test.go +++ b/test/e2e/upstream_ginkgo_test.go @@ -5,11 +5,9 @@ package e2e import ( "encoding/json" "fmt" - "net/http" "os" "path/filepath" "strings" - "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -301,14 +299,11 @@ nodes: }) Describe("health", func() { - It("shows health data through the real Control API in table and json formats", func() { + It("preserves health-check configuration through CLI reads", func() { g := NewWithT(GinkgoT()) upstreamID := "ginkgo-upstream-health" - routeID := "ginkgo-route-health" - deleteRouteViaCLIByID(env, routeID) deleteUpstreamViaCLIByID(env, upstreamID) - DeferCleanup(deleteRouteViaAdminByID, g, routeID) DeferCleanup(deleteUpstreamViaAdminByID, g, upstreamID) upstreamFile := writeUpstreamFile(g, "upstream-health.json", fmt.Sprintf(`{ @@ -335,45 +330,14 @@ nodes: }`, upstreamID, hostPortFromURL(g, httpbinURL))) createUpstreamViaCLIFile(g, env, upstreamFile) - routeFile := writeRouteFile(g, "route-health.json", fmt.Sprintf(`{ - "id": "%s", - "name": "ginkgo-route-health", - "uri": "/ginkgo-upstream-health/*", - "upstream_id": "%s", - "plugins": { - "proxy-rewrite": { - "regex_uri": ["^/ginkgo-upstream-health/(.*)", "/$1"] - } - } -}`, routeID, upstreamID)) - stdout, stderr, err := runA6WithEnv(env, "route", "create", "-f", routeFile) - g.Expect(err).NotTo(HaveOccurred(), "stdout=%s stderr=%s", stdout, stderr) - - time.Sleep(3 * time.Second) - for range 5 { - resp, reqErr := http.Get(gatewayURL + "/ginkgo-upstream-health/get") - if reqErr == nil && resp != nil { - g.Expect(resp.Body.Close()).To(Succeed()) - } - time.Sleep(500 * time.Millisecond) - } - time.Sleep(3 * time.Second) - - stdout, stderr, err = runA6WithEnv(env, "upstream", "health", upstreamID, "--control-url", controlURL, "--output", "table") + stdout, stderr, err := runA6WithEnv(env, "upstream", "get", upstreamID, "--output", "json") g.Expect(err).NotTo(HaveOccurred(), "stdout=%s stderr=%s", stdout, stderr) - g.Expect(stdout).To(ContainSubstring("NODE")) - g.Expect(stdout).To(ContainSubstring("healthy")) - g.Expect(stdout).To(ContainSubstring("Type: http")) - - stdout, stderr, err = runA6WithEnv(env, "upstream", "health", upstreamID, "--control-url", controlURL, "--output", "json") - g.Expect(err).NotTo(HaveOccurred(), "stdout=%s stderr=%s", stdout, stderr) - - var healthResp map[string]any - g.Expect(json.Unmarshal([]byte(stdout), &healthResp)).To(Succeed()) - g.Expect(healthResp["type"]).To(Equal("http")) - nodes, ok := healthResp["nodes"].([]any) - g.Expect(ok).To(BeTrue()) - g.Expect(nodes).NotTo(BeEmpty()) + g.Expect(json.Valid([]byte(stdout))).To(BeTrue(), stdout) + g.Expect(stdout).To(ContainSubstring(`"id": "` + upstreamID + `"`)) + g.Expect(stdout).To(ContainSubstring(`"checks"`)) + g.Expect(stdout).To(ContainSubstring(`"http_path": "/get"`)) + g.Expect(stdout).To(ContainSubstring(`"successes": 1`)) + g.Expect(stdout).To(ContainSubstring(`"http_failures": 3`)) }) It("surfaces missing health-check data from the real Control API", func() { From 92a0e310eba700398fbceb030a72be51d650816b Mon Sep 17 00:00:00 2001 From: Qi Guo <979918879@qq.com> Date: Tue, 28 Apr 2026 11:26:21 +0800 Subject: [PATCH 2/2] test: cover upstream node edge cases --- pkg/api/types_upstream_nodes.go | 15 +++++++++------ pkg/api/types_upstream_nodes_test.go | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/pkg/api/types_upstream_nodes.go b/pkg/api/types_upstream_nodes.go index e2b7795..3cb98b9 100644 --- a/pkg/api/types_upstream_nodes.go +++ b/pkg/api/types_upstream_nodes.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "encoding/json" "fmt" ) @@ -10,13 +11,14 @@ import ( type UpstreamNodes map[string]interface{} func (n *UpstreamNodes) UnmarshalJSON(data []byte) error { - if string(data) == "null" { + trimmed := bytes.TrimSpace(data) + if string(trimmed) == "null" { *n = nil return nil } var keyed map[string]interface{} - if err := json.Unmarshal(data, &keyed); err == nil { + if err := json.Unmarshal(trimmed, &keyed); err == nil { *n = keyed return nil } @@ -26,7 +28,7 @@ func (n *UpstreamNodes) UnmarshalJSON(data []byte) error { Port int `json:"port"` Weight json.RawMessage `json:"weight"` } - if err := json.Unmarshal(data, &listed); err != nil { + if err := json.Unmarshal(trimmed, &listed); err != nil { return err } @@ -37,9 +39,10 @@ func (n *UpstreamNodes) UnmarshalJSON(data []byte) error { key = fmt.Sprintf("%s:%d", node.Host, node.Port) } - var weight interface{} = 1 - if len(node.Weight) > 0 && string(node.Weight) != "null" { - if err := json.Unmarshal(node.Weight, &weight); err != nil { + var weight interface{} = float64(1) + weightData := bytes.TrimSpace(node.Weight) + if len(weightData) > 0 && string(weightData) != "null" { + if err := json.Unmarshal(weightData, &weight); err != nil { return err } } diff --git a/pkg/api/types_upstream_nodes_test.go b/pkg/api/types_upstream_nodes_test.go index bdb9b0d..c8d6b8b 100644 --- a/pkg/api/types_upstream_nodes_test.go +++ b/pkg/api/types_upstream_nodes_test.go @@ -27,6 +27,28 @@ func TestUpstreamNodesUnmarshalArray(t *testing.T) { } } +func TestUpstreamNodesUnmarshalNull(t *testing.T) { + nodes := UpstreamNodes{"127.0.0.1:9080": float64(1)} + if err := json.Unmarshal([]byte(` null `), &nodes); err != nil { + t.Fatalf("unmarshal null nodes: %v", err) + } + + if nodes != nil { + t.Fatalf("expected nil nodes, got %#v", nodes) + } +} + +func TestUpstreamNodesUnmarshalArrayDefaultWeight(t *testing.T) { + var nodes UpstreamNodes + if err := json.Unmarshal([]byte(`[{"host":"127.0.0.1","port":9080}]`), &nodes); err != nil { + t.Fatalf("unmarshal array nodes with default weight: %v", err) + } + + if got := nodes["127.0.0.1:9080"]; got != float64(1) { + t.Fatalf("unexpected default node weight: %#v", got) + } +} + func TestRouteWithArrayUpstreamNodesUnmarshals(t *testing.T) { var route Route err := json.Unmarshal([]byte(`{