diff --git a/internal/catalogapi/catalogapi.go b/internal/catalogapi/catalogapi.go index a9cb794..0da30a0 100644 --- a/internal/catalogapi/catalogapi.go +++ b/internal/catalogapi/catalogapi.go @@ -13,6 +13,11 @@ import ( "github.com/opdev/productctl/internal/resource" ) +var ( + ErrMissingName = errors.New("listing did not have a name and it is required") + ErrDetachingComponents = errors.New("unable to detach components from product listing") +) + // APIEndpoint represents a full URL to a given Catalog API instance. type APIEndpoint = string @@ -40,7 +45,7 @@ func ApplyProduct( } if !declaration.Spec.HasName() { - return nil, errors.New("listing did not have a name and it is required") + return nil, ErrMissingName } if updateListing { @@ -54,11 +59,11 @@ func ApplyProduct( L.Info("declaration enumerated no components. detaching all components from product (if necessary)") resp, err := genpyxis.SetComponentsForProduct(ctx, client, declaration.Spec.ID, []string{}) if err != nil { - return nil, err + return nil, errors.Join(ErrDetachingComponents, err) } if gqlErr := resp.Update_product_listing.GetError(); gqlErr != nil { - return nil, ParseGraphQLResponseError(gqlErr) + return nil, errors.Join(ErrDetachingComponents, ParseGraphQLResponseError(gqlErr)) } declaration.Spec.LastUpdateDate = resp.Update_product_listing.GetData().GetLast_update_date() diff --git a/internal/catalogapi/catalogapi_suite_test.go b/internal/catalogapi/catalogapi_suite_test.go new file mode 100644 index 0000000..2fc2caa --- /dev/null +++ b/internal/catalogapi/catalogapi_suite_test.go @@ -0,0 +1,13 @@ +package catalogapi_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCatalogapi(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Catalogapi Suite") +} diff --git a/internal/catalogapi/catalogapi_test.go b/internal/catalogapi/catalogapi_test.go new file mode 100644 index 0000000..a98b67d --- /dev/null +++ b/internal/catalogapi/catalogapi_test.go @@ -0,0 +1,12 @@ +package catalogapi_test + +import ( + . "github.com/onsi/ginkgo/v2" + // . "github.com/onsi/gomega" + // "github.com/opdev/productctl/internal/catalogapi" +) + +var _ = PDescribe("catalogapi", func() { + // Unimplemented. At the time of writing. Significant GraphQL client mocking + // needed in order to allow business logic testing. +}) diff --git a/internal/catalogapi/client_test.go b/internal/catalogapi/client_test.go new file mode 100644 index 0000000..16db49a --- /dev/null +++ b/internal/catalogapi/client_test.go @@ -0,0 +1,54 @@ +package catalogapi_test + +import ( + "bytes" + "log/slog" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opdev/productctl/internal/catalogapi" +) + +var _ = Describe("Client", func() { + var ( + testLogger *slog.Logger + testServer *httptest.Server + ) + + BeforeEach(func() { + testLogger = slog.New(slog.DiscardHandler) + testServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) + }) + AfterEach(func() { + testServer.Close() + }) + When("when building an HTTP client", func() { + var testToken string + + BeforeEach(func() { + testToken = "test-token" + }) + When("a token is provided", func() { + It("should be included in the client", func() { + client := catalogapi.TokenAuthenticatedHTTPClient(testToken, testLogger) + req, err := http.NewRequest(http.MethodGet, testServer.URL, bytes.NewBuffer([]byte("testRequest"))) + Expect(err).ToNot(HaveOccurred()) + _, err = client.Do(req) + Expect(err).ToNot(HaveOccurred()) + Expect(req.Header.Get("X-API-KEY")).To(Equal(testToken)) + }) + }) + + It("should have the appropriate user agent configured", func() { + client := catalogapi.TokenAuthenticatedHTTPClient(testToken, testLogger) + req, err := http.NewRequest(http.MethodGet, testServer.URL, bytes.NewBuffer([]byte("testRequest"))) + Expect(err).ToNot(HaveOccurred()) + _, err = client.Do(req) + Expect(err).ToNot(HaveOccurred()) + Expect(req.Header.Get("User-Agent")).To(Equal(catalogapi.UserAgent)) + }) + }) +}) diff --git a/internal/catalogapi/gqlerr_test.go b/internal/catalogapi/gqlerr_test.go new file mode 100644 index 0000000..3b07e64 --- /dev/null +++ b/internal/catalogapi/gqlerr_test.go @@ -0,0 +1,30 @@ +package catalogapi_test + +import ( + "strconv" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opdev/productctl/internal/catalogapi" + "github.com/opdev/productctl/internal/genpyxis" +) + +var _ = Describe("Gqlerr", func() { + var gqlErr catalogapi.GraphQLResponseError + + When("parsing GraphQL errors", func() { + BeforeEach(func() { + gqlErr = &genpyxis.MutateProductListingCommonResponseError{ + Status: 404, + Detail: "some error detail", + } + }) + It("should return an error message containing the Status and Detail values", func() { + parsed := catalogapi.ParseGraphQLResponseError(gqlErr) + statusStr := strconv.Itoa(gqlErr.GetStatus()) + Expect(parsed.Error()).To(ContainSubstring(gqlErr.GetDetail())) + Expect(parsed.Error()).To(ContainSubstring(statusStr)) + }) + }) +}) diff --git a/internal/catalogapi/paging.go b/internal/catalogapi/paging.go index a775fac..302533e 100644 --- a/internal/catalogapi/paging.go +++ b/internal/catalogapi/paging.go @@ -2,10 +2,13 @@ package catalogapi import ( "context" + "errors" "github.com/opdev/productctl/internal/logger" ) +var ErrQueryPageFailed = errors.New("failed to query page") + // QueryAll returns all items of type T in a paginated response from // startingPage with the set pageSize. func QueryAll[T any]( @@ -23,7 +26,7 @@ func QueryAll[T any]( L := L.With("page", page) returned, total, err := queryPageFn(page, pageSize) if err != nil { - return nil, err + return nil, errors.Join(ErrQueryPageFailed, err) } allItems = append(allItems, returned...) diff --git a/internal/catalogapi/paging_test.go b/internal/catalogapi/paging_test.go new file mode 100644 index 0000000..c9ba743 --- /dev/null +++ b/internal/catalogapi/paging_test.go @@ -0,0 +1,139 @@ +package catalogapi_test + +import ( + "context" + "errors" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opdev/productctl/internal/catalogapi" +) + +func mockData(size uint) []int { + d := make([]int, size) + for i := range d { + // value is 1-based, to align with the size input. + d[i] = i + 1 + } + + return d +} + +func mockSuccessfulPaginatedQueryWithInput(inputData []int) func(page, pageSize int) ([]int, int, error) { + // maximum number of "queries" allowed. + max := 8 + counter := 0 + return func(page, pageSize int) ([]int, int, error) { + if counter > max { + return nil, -1, errors.New("TESTING CONSTRAINT REACHED: Max query count reached in mock function - Adjust unit tests to use less queries") + } + defer func() { counter++ }() + start := (page - 1) * pageSize + // account for start and end points that are outside of inputData bounds. + if start > len(inputData) { + return []int{}, 0, nil + } + end := start + pageSize + if end > len(inputData) { + end = len(inputData) + } + return inputData[start:end], len(inputData), nil + } +} + +var _ = Describe("Paging", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = context.TODO() + }) + + When("querying all records", func() { + var ( + inputData []int + startingPage int + pageSize int + ) + + BeforeEach(func() { + inputData = mockData(5) + startingPage = 1 + pageSize = catalogapi.DefaultPageSize + }) + + When("the query function returns an error", func() { + It("should return library errors and the provided error", func() { + returnedErr := errors.New("query error") + _, err := catalogapi.QueryAll( + ctx, + startingPage, + pageSize, + func(page, pageSize int) (returnedItems []int, totalItems int, queryError error) { + return nil, 0, returnedErr + }, + ) + Expect(err).To(MatchError(returnedErr)) + Expect(err).To(MatchError(catalogapi.ErrQueryPageFailed)) + }) + }) + + When("you start at page 0", func() { + BeforeEach(func() { + startingPage = 1 + }) + + It("should query all records", func() { + records, err := catalogapi.QueryAll( + ctx, + startingPage, + pageSize, + mockSuccessfulPaginatedQueryWithInput(inputData), + ) + Expect(err).ToNot(HaveOccurred()) + Expect(records).To(BeEquivalentTo(inputData)) + }) + }) + + When("you start at a specific page", func() { + BeforeEach(func() { + inputData = mockData(3) + startingPage = 2 + // pageSize out of scope, but adjusted to make this assertion easier. + pageSize = 1 + }) + + It("should only contain the records from that point forward", func() { + records, err := catalogapi.QueryAll( + ctx, + startingPage, + pageSize, + mockSuccessfulPaginatedQueryWithInput(inputData), + ) + Expect(err).ToNot(HaveOccurred()) + Expect(records).To(BeEquivalentTo(inputData[(startingPage-1)*pageSize:])) + }) + }) + When("you define a page size", func() { + BeforeEach(func() { + inputData = mockData(5) + startingPage = 1 + pageSize = 1 + }) + It("should use the expected number of queries to query all records", func() { + totalQueryCount := 0 + _, err := catalogapi.QueryAll( + ctx, + startingPage, + pageSize, + func(page, pageSize int) (returnedItems []int, totalItems int, queryError error) { + totalQueryCount++ + return mockSuccessfulPaginatedQueryWithInput(inputData)(page, pageSize) + }, + ) + Expect(err).ToNot(HaveOccurred()) + Expect(totalQueryCount).To(BeEquivalentTo(len(inputData))) + }) + }) + }) +}) diff --git a/internal/transport/tokenauth.go b/internal/transport/tokenauth.go index 72dc986..55b9904 100644 --- a/internal/transport/tokenauth.go +++ b/internal/transport/tokenauth.go @@ -23,7 +23,6 @@ type APITokenAuthenticated struct { func (t *APITokenAuthenticated) RoundTrip(req *http.Request) (*http.Response, error) { t.logger().Debug("adding api key and user agent headers to request") req.Header.Set("X-API-KEY", t.Token) - req.Header.Set("User-Agent", t.UserAgent) return t.Wrapped.RoundTrip(req) }