From 08520a61158dc247cdbc27419a9be4886e5f1f7f Mon Sep 17 00:00:00 2001 From: Juan Hernandez Date: Wed, 22 Nov 2023 12:20:35 +0100 Subject: [PATCH] Add some unit tests for the resource handler This patch adds some unit tests for the resource handler. Signed-off-by: Juan Hernandez --- go.mod | 3 + go.sum | 6 + internal/data/data.go | 4 +- .../deployment_manager_handler_test.go | 20 +- .../service/resource_pool_handler_test.go | 282 ++++++++++++++++++ internal/testing/jq.go | 147 +++++++++ internal/testing/servers.go | 9 - 7 files changed, 454 insertions(+), 17 deletions(-) create mode 100644 internal/service/resource_pool_handler_test.go create mode 100644 internal/testing/jq.go diff --git a/go.mod b/go.mod index ab38fa46d..a8f6f3ca5 100644 --- a/go.mod +++ b/go.mod @@ -9,12 +9,14 @@ require ( github.com/onsi/ginkgo/v2 v2.13.0 github.com/onsi/gomega v1.29.0 go.uber.org/mock v0.3.0 + k8s.io/utils v0.0.0-20231121161247-cf03d44ff3cf ) require ( github.com/PaesslerAG/gval v1.0.0 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/timefmt-go v0.1.5 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/stretchr/testify v1.8.2 // indirect @@ -28,6 +30,7 @@ require ( github.com/golang-jwt/jwt/v4 v4.5.0 github.com/google/go-cmp v0.6.0 // indirect github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect + github.com/itchyny/gojq v0.12.13 github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 golang.org/x/net v0.18.0 diff --git a/go.sum b/go.sum index c4debdce5..18e187d47 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,10 @@ github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB7 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.13 h1:IxyYlHYIlspQHHTE0f3cJF0NKDMfajxViuhBLnHd/QU= +github.com/itchyny/gojq v0.12.13/go.mod h1:JzwzAqenfhrPUuwbmEz3nu3JQmFLlQTQMUcOdnu/Sf4= +github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE= +github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -82,3 +86,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/utils v0.0.0-20231121161247-cf03d44ff3cf h1:iTzha1p7Fi83476ypNSz8nV9iR9932jIIs26F7gNLsU= +k8s.io/utils v0.0.0-20231121161247-cf03d44ff3cf/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= diff --git a/internal/data/data.go b/internal/data/data.go index a664a0bfd..bbbc12300 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -26,8 +26,8 @@ import ( // Object represents an object containing a list of fields, each with a name and a value. type Object = map[string]any -// List represents a list of objecs. -type List = []any +// Array represents a list of objecs. +type Array = []any func GetString(o Object, path string) (result string, err error) { value, err := jsonpath.Get(path, o) diff --git a/internal/service/deployment_manager_handler_test.go b/internal/service/deployment_manager_handler_test.go index 3ba386bba..cba30f132 100644 --- a/internal/service/deployment_manager_handler_test.go +++ b/internal/service/deployment_manager_handler_test.go @@ -20,6 +20,7 @@ import ( . "github.com/onsi/ginkgo/v2/dsl/core" . "github.com/onsi/gomega" + "github.com/onsi/gomega/ghttp" . "github.com/onsi/gomega/ghttp" "github.com/openshift-kni/oran-o2ims/internal/data" @@ -112,6 +113,15 @@ var _ = Describe("Deployment manager handler", func() { backend.Close() }) + // RespondWithList creates a handler that responds with the given search results. + var RespondWithList = func(items ...data.Object) http.HandlerFunc { + return ghttp.RespondWithJSONEncoded(http.StatusOK, data.Object{ + "apiVersion": "v1", + "kind": "List", + "items": items, + }) + } + Describe("List", func() { It("Uses the configured token", func() { // Prepare a backend: @@ -146,9 +156,7 @@ var _ = Describe("Deployment manager handler", func() { It("Translates empty list of results", func() { // Prepare a backend: backend.AppendHandlers( - CombineHandlers( - RespondWithList(), - ), + RespondWithList(), ) // Send the request and verify the result: @@ -173,7 +181,7 @@ var _ = Describe("Deployment manager handler", func() { }, }, "spec": data.Object{ - "managedClusterClientConfigs": data.List{ + "managedClusterClientConfigs": data.Array{ data.Object{ "url": "https://my-cluster:6443", }, @@ -188,7 +196,7 @@ var _ = Describe("Deployment manager handler", func() { }, }, "spec": data.Object{ - "managedClusterClientConfigs": data.List{ + "managedClusterClientConfigs": data.Array{ data.Object{ "url": "https://your-cluster:6443", }, @@ -270,7 +278,7 @@ var _ = Describe("Deployment manager handler", func() { }, }, "spec": data.Object{ - "managedClusterClientConfigs": data.List{ + "managedClusterClientConfigs": data.Array{ data.Object{ "url": "https://my-cluster:6443", }, diff --git a/internal/service/resource_pool_handler_test.go b/internal/service/resource_pool_handler_test.go new file mode 100644 index 000000000..e79f3b9a7 --- /dev/null +++ b/internal/service/resource_pool_handler_test.go @@ -0,0 +1,282 @@ +/* +Copyright (c) 2023 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in +compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is +distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing permissions and limitations under the +License. +*/ + +package service + +import ( + "context" + "net/http" + + . "github.com/onsi/ginkgo/v2/dsl/core" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/ghttp" + "k8s.io/utils/ptr" + + "github.com/openshift-kni/oran-o2ims/internal/data" + "github.com/openshift-kni/oran-o2ims/internal/searchapi" + . "github.com/openshift-kni/oran-o2ims/internal/testing" + "github.com/openshift-kni/oran-o2ims/internal/text" +) + +var _ = Describe("Resource pool handler", func() { + Describe("Creation", func() { + It("Can't be created without a logger", func() { + handler, err := NewResourcePoolHandler(). + SetCloudID("123"). + SetBackendURL("https://my-backend:6443"). + SetBackendToken("my-token"). + Build() + Expect(err).To(HaveOccurred()) + Expect(handler).To(BeNil()) + msg := err.Error() + Expect(msg).To(ContainSubstring("logger")) + Expect(msg).To(ContainSubstring("mandatory")) + }) + + It("Can't be created without a cloud identifier", func() { + handler, err := NewResourcePoolHandler(). + SetLogger(logger). + SetBackendURL("https://my-backend:6443"). + SetBackendToken("my-token"). + Build() + Expect(err).To(HaveOccurred()) + Expect(handler).To(BeNil()) + msg := err.Error() + Expect(msg).To(ContainSubstring("cloud identifier")) + Expect(msg).To(ContainSubstring("mandatory")) + }) + + It("Can't be created without a backend URL", func() { + handler, err := NewResourcePoolHandler(). + SetLogger(logger). + SetCloudID("123"). + SetBackendToken("my-token"). + Build() + Expect(err).To(HaveOccurred()) + Expect(handler).To(BeNil()) + msg := err.Error() + Expect(msg).To(ContainSubstring("backend URL")) + Expect(msg).To(ContainSubstring("mandatory")) + }) + + It("Can't be created without a backend token", func() { + handler, err := NewResourcePoolHandler(). + SetLogger(logger). + SetCloudID("123"). + SetBackendURL("https://my-backend:6443"). + Build() + Expect(err).To(HaveOccurred()) + Expect(handler).To(BeNil()) + msg := err.Error() + Expect(msg).To(ContainSubstring("backend token")) + Expect(msg).To(ContainSubstring("mandatory")) + }) + }) + + Describe("Behaviour", func() { + var ( + ctx context.Context + backend *Server + handler *ResourcePoolHandler + ) + + BeforeEach(func() { + var err error + + // Create a context: + ctx = context.Background() + + // Create the backend server: + backend = MakeTCPServer() + + // Create the handler: + handler, err = NewResourcePoolHandler(). + SetLogger(logger). + SetCloudID("123"). + SetBackendURL(backend.URL()). + SetBackendToken("my-token"). + SetGraphqlQuery(text.Dedent(` + query ($input: [SearchInput]) { + searchResult: search(input: $input) { + items, + related { + items + }, + } + } + `)). + SetGraphqlVars(&searchapi.SearchInput{ + Filters: []*searchapi.SearchFilter{ + { + Property: "kind", + Values: []*string{ + ptr.To("cluster"), + }, + }, + }, + RelatedKinds: []*string{ + ptr.To("Node"), + }, + }). + Build() + Expect(err).ToNot(HaveOccurred()) + Expect(handler).ToNot(BeNil()) + }) + + AfterEach(func() { + backend.Close() + }) + + // RespondWithItems creates a handler that responds with the given search results. + var RespondWithItems = func(items ...data.Object) http.HandlerFunc { + return RespondWithObject(data.Object{ + "data": data.Object{ + "searchResult": data.Array{ + data.Object{ + "items": items, + "related": data.Array{ + data.Object{ + "items": data.Array{}, + }, + }, + }, + }, + }, + }) + } + + Describe("List", func() { + It("Uses the configured token", func() { + // Prepare a backend: + backend.AppendHandlers( + CombineHandlers( + VerifyHeaderKV("Authorization", "Bearer my-token"), + RespondWithItems(), + ), + ) + + // Send the request. Note that we ignore the error here because + // all we care about in this test is that it sends the token, no + // matter what is the result. + _, _ = handler.List(ctx, &ListRequest{}) + }) + + It("Translates empty list of results", func() { + // Prepare the backend: + backend.AppendHandlers( + RespondWithItems(), + ) + + // Send the request and verify the result: + response, err := handler.List(ctx, &ListRequest{}) + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + items, err := data.Collect(ctx, response.Items) + Expect(err).ToNot(HaveOccurred()) + Expect(items).To(BeEmpty()) + }) + + It("Translates non empty list of results", func() { + // Prepare the backend: + backend.AppendHandlers( + RespondWithItems( + data.Object{ + "cluster": "0", + "label": "a=b; c=d", + "name": "my-cluster-0", + }, + data.Object{ + "cluster": "1", + "label": "a=b; c=d", + "name": "my-cluster-1", + }, + ), + ) + + // Send the request: + response, err := handler.List(ctx, &ListRequest{}) + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + items, err := data.Collect(ctx, response.Items) + Expect(err).ToNot(HaveOccurred()) + Expect(items).To(HaveLen(2)) + + // Verify first result: + Expect(items[0]).To(MatchJQ(`.description`, "")) + Expect(items[0]).To(MatchJQ(`.globalLocationID`, "")) + Expect(items[0]).To(MatchJQ(`.location`, "")) + Expect(items[0]).To(MatchJQ(`.name`, "my-cluster-0")) + Expect(items[0]).To(MatchJQ(`.oCloudID`, "123")) + Expect(items[0]).To(MatchJQ(`.resourcePoolID`, "0")) + + // Verify second result: + Expect(items[1]).To(MatchJQ(`.description`, "")) + Expect(items[1]).To(MatchJQ(`.globalLocationID`, "")) + Expect(items[1]).To(MatchJQ(`.location`, "")) + Expect(items[1]).To(MatchJQ(`.name`, "my-cluster-1")) + Expect(items[1]).To(MatchJQ(`.oCloudID`, "123")) + Expect(items[1]).To(MatchJQ(`.resourcePoolID`, "1")) + }) + }) + + Describe("Get", func() { + It("Uses the configured token", func() { + // Prepare a backend: + backend.AppendHandlers( + CombineHandlers( + VerifyHeaderKV("Authorization", "Bearer my-token"), + RespondWithItems(), + ), + ) + + // Send the request. Note that we ignore the error here because + // all we care about in this test is that it sends the token, no + // matter what is the response. + _, _ = handler.Get(ctx, &GetRequest{ + ID: "123", + }) + }) + + It("Translates result", func() { + // Prepare a backend: + backend.AppendHandlers( + CombineHandlers( + RespondWithItems( + data.Object{ + "cluster": "0", + "label": "a=b; c=d", + "name": "my-cluster-0", + }, + ), + ), + ) + + // Send the request: + response, err := handler.Get(ctx, &GetRequest{ + ID: "0", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + + // Verify the result: + Expect(response.Object).To(MatchJQ(`.description`, "")) + Expect(response.Object).To(MatchJQ(`.globalLocationID`, "")) + Expect(response.Object).To(MatchJQ(`.location`, "")) + Expect(response.Object).To(MatchJQ(`.name`, "my-cluster-0")) + Expect(response.Object).To(MatchJQ(`.oCloudID`, "123")) + Expect(response.Object).To(MatchJQ(`.resourcePoolID`, "0")) + }) + }) + }) +}) diff --git a/internal/testing/jq.go b/internal/testing/jq.go new file mode 100644 index 000000000..e6717dd34 --- /dev/null +++ b/internal/testing/jq.go @@ -0,0 +1,147 @@ +/* +Copyright 2023 Red Hat Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in +compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is +distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing permissions and limitations under the +License. +*/ + +package testing + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "strings" + + "github.com/itchyny/gojq" + "github.com/onsi/gomega/types" +) + +// JQ runs the given `jq` filter on the given object and returns the list of results. The returned +// slice will never be nil; if there are no results it will be empty. +func JQ(filter string, input interface{}) (results []interface{}, err error) { + // Parse the filter: + query, err := gojq.Parse(filter) + if err != nil { + return + } + + // If the input is an array of bytes or an string then we need to unmarshal it, so that the + // rest of the code doesn't need to handle that explicitly: + switch typed := input.(type) { + case []byte: + err = json.Unmarshal(typed, &input) + if err != nil { + return + } + case string: + err = json.Unmarshal([]byte(typed), &input) + if err != nil { + return + } + } + + // Run the query: + iterator := query.Run(input) + for { + result, ok := iterator.Next() + if !ok { + break + } + results = append(results, result) + } + return +} + +// MatchJQ creates a matcher that checks that the all the results of applying a `jq` filter to the +// actual value is the given expected value. +func MatchJQ(filter string, expected interface{}) types.GomegaMatcher { + return &jqMatcher{ + filter: filter, + expected: expected, + } +} + +type jqMatcher struct { + filter string + expected interface{} + results []interface{} +} + +func (m *jqMatcher) Match(actual interface{}) (success bool, err error) { + // Run the query: + m.results, err = JQ(m.filter, actual) + if err != nil { + return + } + + // Check that there is at least one result: + if len(m.results) == 0 { + return + } + + // We consider the match sucessful if all the results returned by the JQ filter are exactly + // equal to the expected value. + success = true + for _, result := range m.results { + if !reflect.DeepEqual(result, m.expected) { + success = false + break + } + } + return +} + +func (m *jqMatcher) FailureMessage(actual interface{}) string { + return fmt.Sprintf( + "Expected results of running 'jq' filter\n\t%s\n"+ + "on input\n\t%s\n"+ + "to be\n\t%s\n"+ + "but at list one of the following results isn't\n\t%s\n", + m.filter, m.pretty(actual), m.pretty(m.expected), m.pretty(m.results), + ) +} + +func (m *jqMatcher) NegatedFailureMessage(actual interface{}) (message string) { + return fmt.Sprintf( + "Expected results of running 'jq' filter\n\t%s\n"+ + "on input\n\t%s\n"+ + "to not be\n\t%s\n", + m.filter, m.pretty(actual), m.pretty(m.expected), + ) +} + +func (m *jqMatcher) pretty(object interface{}) string { + // If the object is an array of bytes or an string then we need to unmarshal it so that we + // can later marshal it with indentation: + switch typed := object.(type) { + case []byte: + var tmp interface{} + if json.Unmarshal(typed, &tmp) == nil { + object = tmp + } + case string: + var tmp interface{} + if json.Unmarshal([]byte(typed), &tmp) == nil { + object = tmp + } + } + + // Marshal the object with indentation, to make it easier to read: + var buffer bytes.Buffer + encoder := json.NewEncoder(&buffer) + encoder.SetIndent("\t", " ") + err := encoder.Encode(object) + if err != nil { + return fmt.Sprintf("\t%v", object) + } + return strings.TrimRight(buffer.String(), "\n") +} diff --git a/internal/testing/servers.go b/internal/testing/servers.go index a79e90958..f6bb9135a 100644 --- a/internal/testing/servers.go +++ b/internal/testing/servers.go @@ -128,15 +128,6 @@ func RespondWithJSON(status int, body string) http.HandlerFunc { return RespondWithContent(status, "application/json", body) } -// RespondWithList returns an HTTP handler that responds with a list of objects. -func RespondWithList(items ...any) http.HandlerFunc { - return ghttp.RespondWithJSONEncoded(http.StatusOK, data.Object{ - "apiVersion": "v1", - "kind": "List", - "items": items, - }) -} - // RespondWithObject returns an HTTP handler that responds with a single object. func RespondWithObject(object data.Object) http.HandlerFunc { return ghttp.RespondWithJSONEncoded(http.StatusOK, object)