Skip to content
This repository was archived by the owner on Jan 30, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions internal/catalogapi/catalogapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand All @@ -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()
Expand Down
13 changes: 13 additions & 0 deletions internal/catalogapi/catalogapi_suite_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
12 changes: 12 additions & 0 deletions internal/catalogapi/catalogapi_test.go
Original file line number Diff line number Diff line change
@@ -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.
})
54 changes: 54 additions & 0 deletions internal/catalogapi/client_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
})
})
30 changes: 30 additions & 0 deletions internal/catalogapi/gqlerr_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
})
})
5 changes: 4 additions & 1 deletion internal/catalogapi/paging.go
Original file line number Diff line number Diff line change
Expand Up @@ -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](
Expand All @@ -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...)
Expand Down
139 changes: 139 additions & 0 deletions internal/catalogapi/paging_test.go
Original file line number Diff line number Diff line change
@@ -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)))
})
})
})
})
1 change: 0 additions & 1 deletion internal/transport/tokenauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down