Преглед на файлове

Implement plugin API upgrades (#2956)

* Implement plugin API upgrades

Signed-off-by: Alex Meijer <ameijer@kubecost.com>

* add tests

Signed-off-by: Alex Meijer <ameijer@kubecost.com>

* bugfix

Signed-off-by: Alex Meijer <ameijer@kubecost.com>

* fix test case

Signed-off-by: Alex Meijer <ameijer@kubecost.com>

* impl sorting

Signed-off-by: Alex Meijer <ameijer@kubecost.com>

* additional testing

Signed-off-by: Alex Meijer <ameijer@kubecost.com>

* undo gomod changes

Signed-off-by: Alex Meijer <ameijer@kubecost.com>

---------

Signed-off-by: Alex Meijer <ameijer@kubecost.com>
Alex Meijer преди 1 година
родител
ревизия
8e4cfe6c89
променени са 4 файла, в които са добавени 530 реда и са изтрити 61 реда
  1. 74 10
      pkg/customcost/queryservice_helper.go
  2. 11 7
      pkg/customcost/repositoryquerier.go
  3. 146 44
      pkg/customcost/types.go
  4. 299 0
      pkg/customcost/types_test.go

+ 74 - 10
pkg/customcost/queryservice_helper.go

@@ -40,17 +40,61 @@ func ParseCustomCostTotalRequest(qp mapper.PrimitiveMap) (*CostTotalRequest, err
 		}
 	}
 
+	costTypeStr := qp.Get("costType", string(CostTypeBlended))
+	parsedCostType, err := ParseCostType(costTypeStr)
+	if err != nil {
+		return nil, fmt.Errorf("parsing 'costType' parameter: %s", err)
+	}
+
+	sortByStr := qp.Get("sortBy", string(SortPropertyCost))
+	parsedSortBy, err := ParseSortBy(sortByStr)
+	if err != nil {
+		return nil, fmt.Errorf("parsing 'sortBy' parameter: %s", err)
+	}
+
+	sortDirStr := qp.Get("sortDirection", string(SortDirectionDesc))
+	parsedSortDir, err := ParseSortDirection(sortDirStr)
+	if err != nil {
+		return nil, fmt.Errorf("parsing 'sortDirection' parameter: %s", err)
+	}
+
 	opts := &CostTotalRequest{
-		Start:       *window.Start(),
-		End:         *window.End(),
-		AggregateBy: aggregateBy,
-		Accumulate:  accumulate,
-		Filter:      filter,
+		Start:         *window.Start(),
+		End:           *window.End(),
+		AggregateBy:   aggregateBy,
+		Accumulate:    accumulate,
+		Filter:        filter,
+		CostType:      parsedCostType,
+		SortBy:        parsedSortBy,
+		SortDirection: parsedSortDir,
 	}
 
 	return opts, nil
 }
 
+func ParseSortDirection(sortDirStr string) (SortDirection, error) {
+	switch sortDirStr {
+	case string(SortDirectionAsc):
+		return SortDirectionAsc, nil
+	case string(SortDirectionDesc):
+		return SortDirectionDesc, nil
+	default:
+		return "", fmt.Errorf("unrecognized sortDirection field: %s", sortDirStr)
+	}
+}
+
+func ParseSortBy(sortByStr string) (SortProperty, error) {
+	switch sortByStr {
+	case string(SortPropertyCost):
+		return SortPropertyCost, nil
+	case string(SortPropertyAggregate):
+		return SortPropertyAggregate, nil
+	case string(SortPropertyCostType):
+		return SortPropertyCostType, nil
+	default:
+		return "", fmt.Errorf("unrecognized sortBy field: %s", sortByStr)
+	}
+}
 func ParseCustomCostTimeseriesRequest(qp mapper.PrimitiveMap) (*CostTimeseriesRequest, error) {
 	windowStr := qp.Get("window", "")
 	if windowStr == "" {
@@ -82,13 +126,33 @@ func ParseCustomCostTimeseriesRequest(qp mapper.PrimitiveMap) (*CostTimeseriesRe
 			return nil, fmt.Errorf("parsing 'filter' parameter: %s", err)
 		}
 	}
+	costTypeStr := qp.Get("costType", string(CostTypeBlended))
+	parsedCostType, err := ParseCostType(costTypeStr)
+	if err != nil {
+		return nil, fmt.Errorf("parsing 'costType' parameter: %s", err)
+	}
+
+	sortByStr := qp.Get("sortBy", string(SortPropertyCost))
+	parsedSortBy, err := ParseSortBy(sortByStr)
+	if err != nil {
+		return nil, fmt.Errorf("parsing 'sortBy' parameter: %s", err)
+	}
+
+	sortDirStr := qp.Get("sortDirection", string(SortDirectionDesc))
+	parsedSortDir, err := ParseSortDirection(sortDirStr)
+	if err != nil {
+		return nil, fmt.Errorf("parsing 'sortDirection' parameter: %s", err)
+	}
 
 	opts := &CostTimeseriesRequest{
-		Start:       *window.Start(),
-		End:         *window.End(),
-		AggregateBy: aggregateBy,
-		Accumulate:  accumulate,
-		Filter:      filter,
+		Start:         *window.Start(),
+		End:           *window.End(),
+		AggregateBy:   aggregateBy,
+		Accumulate:    accumulate,
+		Filter:        filter,
+		CostType:      parsedCostType,
+		SortBy:        parsedSortBy,
+		SortDirection: parsedSortDir,
 	}
 
 	return opts, nil

+ 11 - 7
pkg/customcost/repositoryquerier.go

@@ -63,7 +63,7 @@ func (rq *RepositoryQuerier) QueryTotal(ctx context.Context, request CostTotalRe
 				continue
 			}
 
-			customCosts := ParseCustomCostResponse(ccResponse)
+			customCosts := ParseCustomCostResponse(ccResponse, request.CostType)
 			for _, customCost := range customCosts {
 				if matcher.Matches(customCost) {
 					ccs.Add(customCost)
@@ -79,7 +79,8 @@ func (rq *RepositoryQuerier) QueryTotal(ctx context.Context, request CostTotalRe
 		return nil, err
 	}
 
-	return NewCostResponse(ccs), nil
+	ccs.Sort(request.SortBy, request.SortDirection)
+	return NewCostResponse(ccs, request.CostType), nil
 }
 
 func (rq *RepositoryQuerier) QueryTimeseries(ctx context.Context, request CostTimeseriesRequest) (*CostTimeseriesResponse, error) {
@@ -105,11 +106,14 @@ func (rq *RepositoryQuerier) QueryTimeseries(ctx context.Context, request CostTi
 		go func(i int, window opencost.Window, res []*CostResponse) {
 			defer wg.Done()
 			totals[i], errors[i] = rq.QueryTotal(ctx, CostTotalRequest{
-				Start:       *window.Start(),
-				End:         *window.End(),
-				AggregateBy: request.AggregateBy,
-				Filter:      request.Filter,
-				Accumulate:  accumulate,
+				Start:         *window.Start(),
+				End:           *window.End(),
+				AggregateBy:   request.AggregateBy,
+				Filter:        request.Filter,
+				Accumulate:    accumulate,
+				CostType:      request.CostType,
+				SortBy:        request.SortBy,
+				SortDirection: request.SortDirection,
 			})
 		}(i, w, totals)
 	}

+ 146 - 44
pkg/customcost/types.go

@@ -2,54 +2,79 @@ package customcost
 
 import (
 	"fmt"
+	"sort"
 	"strings"
 	"time"
 
 	"github.com/opencost/opencost/core/pkg/filter"
+	"github.com/opencost/opencost/core/pkg/log"
 	"github.com/opencost/opencost/core/pkg/model/pb"
 	"github.com/opencost/opencost/core/pkg/opencost"
 )
 
+type CostType string
+type SortProperty string
+type SortDirection string
+
+const (
+	CostTypeBlended CostType = "blended"
+	CostTypeList    CostType = "list"
+	CostTypeBilled  CostType = "billed"
+
+	SortPropertyCost      SortProperty = "cost"
+	SortPropertyAggregate SortProperty = "aggregate"
+	SortPropertyCostType  SortProperty = "costType"
+
+	SortDirectionAsc  SortDirection = "asc"
+	SortDirectionDesc SortDirection = "desc"
+)
+
 type CostTotalRequest struct {
-	Start       time.Time
-	End         time.Time
-	AggregateBy []CustomCostProperty
-	Accumulate  opencost.AccumulateOption
-	Filter      filter.Filter
+	Start         time.Time
+	End           time.Time
+	AggregateBy   []CustomCostProperty
+	Accumulate    opencost.AccumulateOption
+	Filter        filter.Filter
+	CostType      CostType
+	SortBy        SortProperty
+	SortDirection SortDirection
 }
 
 type CostTimeseriesRequest struct {
-	Start       time.Time
-	End         time.Time
-	AggregateBy []CustomCostProperty
-	Accumulate  opencost.AccumulateOption
-	Filter      filter.Filter
+	Start         time.Time
+	End           time.Time
+	AggregateBy   []CustomCostProperty
+	Accumulate    opencost.AccumulateOption
+	Filter        filter.Filter
+	CostType      CostType
+	SortBy        SortProperty
+	SortDirection SortDirection
 }
 
 type CostResponse struct {
-	Window          opencost.Window `json:"window"`
-	TotalBilledCost float32         `json:"totalBilledCost"`
-	TotalListCost   float32         `json:"totalListCost"`
-	CustomCosts     []*CustomCost   `json:"customCosts"`
+	Window        opencost.Window `json:"window"`
+	TotalCost     float32         `json:"totalCost"`
+	TotalCostType CostType        `json:"totalCostType"`
+	CustomCosts   []*CustomCost   `json:"customCosts"`
 }
 
 type CustomCost struct {
-	Id             string  `json:"id"`
-	Zone           string  `json:"zone"`
-	AccountName    string  `json:"account_name"`
-	ChargeCategory string  `json:"charge_category"`
-	Description    string  `json:"description"`
-	ResourceName   string  `json:"resource_name"`
-	ResourceType   string  `json:"resource_type"`
-	ProviderId     string  `json:"provider_id"`
-	BilledCost     float32 `json:"billedCost"`
-	ListCost       float32 `json:"listCost"`
-	ListUnitPrice  float32 `json:"list_unit_price"`
-	UsageQuantity  float32 `json:"usage_quantity"`
-	UsageUnit      string  `json:"usage_unit"`
-	Domain         string  `json:"domain"`
-	CostSource     string  `json:"cost_source"`
-	Aggregate      string  `json:"aggregate"`
+	Id             string   `json:"id"`
+	Zone           string   `json:"zone"`
+	AccountName    string   `json:"account_name"`
+	ChargeCategory string   `json:"charge_category"`
+	Description    string   `json:"description"`
+	ResourceName   string   `json:"resource_name"`
+	ResourceType   string   `json:"resource_type"`
+	ProviderId     string   `json:"provider_id"`
+	Cost           float32  `json:"cost"`
+	ListUnitPrice  float32  `json:"list_unit_price"`
+	UsageQuantity  float32  `json:"usage_quantity"`
+	UsageUnit      string   `json:"usage_unit"`
+	Domain         string   `json:"domain"`
+	CostSource     string   `json:"cost_source"`
+	Aggregate      string   `json:"aggregate"`
+	CostType       CostType `json:"cost_type"`
 }
 
 type CostTimeseriesResponse struct {
@@ -57,27 +82,45 @@ type CostTimeseriesResponse struct {
 	Timeseries []*CostResponse `json:"timeseries"`
 }
 
-func NewCostResponse(ccs *CustomCostSet) *CostResponse {
+func NewCostResponse(ccs *CustomCostSet, costType CostType) *CostResponse {
 	costResponse := &CostResponse{
-		Window:      ccs.Window,
-		CustomCosts: []*CustomCost{},
+		Window:        ccs.Window,
+		CustomCosts:   []*CustomCost{},
+		TotalCostType: costType,
 	}
 
 	for _, cc := range ccs.CustomCosts {
-		costResponse.TotalBilledCost += cc.BilledCost
-		costResponse.TotalListCost += cc.ListCost
+		costResponse.TotalCost += cc.Cost
 		costResponse.CustomCosts = append(costResponse.CustomCosts, cc)
 	}
 
 	return costResponse
 }
 
-func ParseCustomCostResponse(ccResponse *pb.CustomCostResponse) []*CustomCost {
+func ParseCostType(costTypeStr string) (CostType, error) {
+	switch costTypeStr {
+	case string(CostTypeBlended):
+		return CostTypeBlended, nil
+	case string(CostTypeList):
+		return CostTypeList, nil
+	case string(CostTypeBilled):
+		return CostTypeBilled, nil
+	default:
+		return "", fmt.Errorf("unsupported cost type: %s", costTypeStr)
+	}
+}
+
+func ParseCustomCostResponse(ccResponse *pb.CustomCostResponse, costType CostType) []*CustomCost {
 	costs := ccResponse.GetCosts()
 
-	customCosts := make([]*CustomCost, len(costs))
-	for i, cost := range costs {
-		customCosts[i] = &CustomCost{
+	customCosts := []*CustomCost{}
+	for _, cost := range costs {
+		selectedCost, selectedCostType := determineCost(cost, costType)
+		if selectedCost == 0 {
+			log.Debugf("cost %s had 0 cost for cost type %s, not including in response", cost.ProviderId, costType)
+			continue
+		}
+		customCosts = append(customCosts, &CustomCost{
 			Id:             cost.GetId(),
 			Zone:           cost.GetZone(),
 			AccountName:    cost.GetAccountName(),
@@ -86,24 +129,47 @@ func ParseCustomCostResponse(ccResponse *pb.CustomCostResponse) []*CustomCost {
 			ResourceName:   cost.GetResourceName(),
 			ResourceType:   cost.GetResourceType(),
 			ProviderId:     cost.GetProviderId(),
-			BilledCost:     cost.GetBilledCost(),
-			ListCost:       cost.GetListCost(),
+			Cost:           selectedCost,
 			ListUnitPrice:  cost.GetListUnitPrice(),
 			UsageQuantity:  cost.GetUsageQuantity(),
 			UsageUnit:      cost.GetUsageUnit(),
 			Domain:         ccResponse.GetDomain(),
 			CostSource:     ccResponse.GetCostSource(),
-		}
+			CostType:       selectedCostType,
+		})
 	}
 
 	return customCosts
 }
 
+func determineCost(cc *pb.CustomCost, costType CostType) (float32, CostType) {
+	switch costType {
+	// if the cost type is blended, first check if the billed cost is non-zero
+	// if it is, return the billed cost
+	// if it is zero, return the list cost
+	case CostTypeBlended:
+		if cc.BilledCost > 0 {
+			return cc.BilledCost, CostTypeBilled
+		}
+		return cc.ListCost, CostTypeList
+		// if the cost type is list, return the list cost
+	case CostTypeList:
+		return cc.ListCost, CostTypeList
+		// if the cost type is billed, return the billed cost
+	case CostTypeBilled:
+		return cc.BilledCost, CostTypeBilled
+	default:
+		return 0, ""
+	}
+}
 func (cc *CustomCost) Add(other *CustomCost) {
-	cc.BilledCost += other.BilledCost
-	cc.ListCost += other.ListCost
+	cc.Cost += other.Cost
 	cc.ListUnitPrice += other.ListUnitPrice
 
+	if cc.CostType != other.CostType {
+		cc.CostType = CostTypeBlended
+	}
+
 	if cc.Id != other.Id {
 		cc.Id = ""
 	}
@@ -240,3 +306,39 @@ func generateAggKey(cc *CustomCost, aggregateBy []CustomCostProperty) (string, e
 
 	return aggKey, nil
 }
+
+func (ccs *CustomCostSet) Sort(sortBy SortProperty, sortDirection SortDirection) {
+
+	switch sortBy {
+	case SortPropertyCost:
+		if sortDirection == SortDirectionAsc {
+			sort.Slice(ccs.CustomCosts, func(i, j int) bool {
+				return ccs.CustomCosts[i].Cost < ccs.CustomCosts[j].Cost
+			})
+		} else {
+			sort.Slice(ccs.CustomCosts, func(i, j int) bool {
+				return ccs.CustomCosts[i].Cost > ccs.CustomCosts[j].Cost
+			})
+		}
+	case SortPropertyAggregate:
+		if sortDirection == SortDirectionAsc {
+			sort.Slice(ccs.CustomCosts, func(i, j int) bool {
+				return ccs.CustomCosts[i].Aggregate < ccs.CustomCosts[j].Aggregate
+			})
+		} else {
+			sort.Slice(ccs.CustomCosts, func(i, j int) bool {
+				return ccs.CustomCosts[i].Aggregate > ccs.CustomCosts[j].Aggregate
+			})
+		}
+	case SortPropertyCostType:
+		if sortDirection == SortDirectionAsc {
+			sort.Slice(ccs.CustomCosts, func(i, j int) bool {
+				return ccs.CustomCosts[i].CostType < ccs.CustomCosts[j].CostType
+			})
+		} else {
+			sort.Slice(ccs.CustomCosts, func(i, j int) bool {
+				return ccs.CustomCosts[i].CostType > ccs.CustomCosts[j].CostType
+			})
+		}
+	}
+}

+ 299 - 0
pkg/customcost/types_test.go

@@ -0,0 +1,299 @@
+package customcost
+
+import (
+	"testing"
+
+	"github.com/opencost/opencost/core/pkg/model/pb"
+)
+
+func TestSortByCostAsc(t *testing.T) {
+	ccs := &CustomCostSet{
+		CustomCosts: []*CustomCost{
+			{Cost: 300.0},
+			{Cost: 100.0},
+			{Cost: 200.0},
+		},
+	}
+
+	ccs.Sort(SortPropertyCost, SortDirectionAsc)
+
+	expected := []float32{100.0, 200.0, 300.0}
+	for i, cc := range ccs.CustomCosts {
+		if cc.Cost != expected[i] {
+			t.Errorf("expected cost %f, got %f", expected[i], cc.Cost)
+		}
+	}
+}
+
+func TestSortByCostDesc(t *testing.T) {
+	ccs := &CustomCostSet{
+		CustomCosts: []*CustomCost{
+			{Cost: 300.0},
+			{Cost: 100.0},
+			{Cost: 200.0},
+		},
+	}
+
+	ccs.Sort(SortPropertyCost, SortDirectionDesc)
+
+	expected := []float32{300.0, 200.0, 100.0}
+	for i, cc := range ccs.CustomCosts {
+		if cc.Cost != expected[i] {
+			t.Errorf("expected cost %f, got %f", expected[i], cc.Cost)
+		}
+	}
+}
+
+func TestSortByAggregateAsc(t *testing.T) {
+	ccs := &CustomCostSet{
+		CustomCosts: []*CustomCost{
+			{Aggregate: "c"},
+			{Aggregate: "a"},
+			{Aggregate: "b"},
+		},
+	}
+
+	ccs.Sort(SortPropertyAggregate, SortDirectionAsc)
+
+	expected := []string{"a", "b", "c"}
+	for i, cc := range ccs.CustomCosts {
+		if cc.Aggregate != expected[i] {
+			t.Errorf("expected aggregate %s, got %s", expected[i], cc.Aggregate)
+		}
+	}
+}
+
+func TestSortByAggregateDesc(t *testing.T) {
+	ccs := &CustomCostSet{
+		CustomCosts: []*CustomCost{
+			{Aggregate: "c"},
+			{Aggregate: "a"},
+			{Aggregate: "b"},
+		},
+	}
+
+	ccs.Sort(SortPropertyAggregate, SortDirectionDesc)
+
+	expected := []string{"c", "b", "a"}
+	for i, cc := range ccs.CustomCosts {
+		if cc.Aggregate != expected[i] {
+			t.Errorf("expected aggregate %s, got %s", expected[i], cc.Aggregate)
+		}
+	}
+}
+
+func TestSortByCostTypeAsc(t *testing.T) {
+	ccs := &CustomCostSet{
+		CustomCosts: []*CustomCost{
+			{CostType: CostTypeBilled},
+			{CostType: CostTypeList},
+			{CostType: CostTypeBlended},
+		},
+	}
+
+	ccs.Sort(SortPropertyCostType, SortDirectionAsc)
+
+	expected := []CostType{CostTypeBilled, CostTypeBlended, CostTypeList}
+	for i, cc := range ccs.CustomCosts {
+		if cc.CostType != expected[i] {
+			t.Errorf("expected cost type %s, got %s", expected[i], cc.CostType)
+		}
+	}
+}
+
+func TestSortByCostTypeDesc(t *testing.T) {
+	ccs := &CustomCostSet{
+		CustomCosts: []*CustomCost{
+			{CostType: CostTypeBilled},
+			{CostType: CostTypeList},
+			{CostType: CostTypeBlended},
+		},
+	}
+
+	ccs.Sort(SortPropertyCostType, SortDirectionDesc)
+
+	expected := []CostType{CostTypeList, CostTypeBlended, CostTypeBilled}
+	for i, cc := range ccs.CustomCosts {
+		if cc.CostType != expected[i] {
+			t.Errorf("expected cost type %s, got %s", expected[i], cc.CostType)
+		}
+	}
+}
+
+func TestParseCustomCostResponse(t *testing.T) {
+	tests := []struct {
+		name       string
+		ccResponse *pb.CustomCostResponse
+		costType   CostType
+		expected   []*CustomCost
+	}{
+		{
+			name: "BlendedCost",
+			ccResponse: &pb.CustomCostResponse{
+				Costs: []*pb.CustomCost{
+					{
+						Id:             "1",
+						Zone:           "us-east-1a",
+						AccountName:    "account1",
+						ChargeCategory: "category1",
+						Description:    "description1",
+						ResourceName:   "resource1",
+						ResourceType:   "type1",
+						ProviderId:     "provider1",
+						BilledCost:     100.0,
+						ListCost:       120.0,
+						ListUnitPrice:  1.2,
+						UsageQuantity:  100,
+						UsageUnit:      "unit1",
+					},
+				},
+				Domain:     "domain1",
+				CostSource: "source1",
+			},
+			costType: CostTypeBlended,
+			expected: []*CustomCost{
+				{
+					Id:             "1",
+					Zone:           "us-east-1a",
+					AccountName:    "account1",
+					ChargeCategory: "category1",
+					Description:    "description1",
+					ResourceName:   "resource1",
+					ResourceType:   "type1",
+					ProviderId:     "provider1",
+					Cost:           100.0,
+					ListUnitPrice:  1.2,
+					UsageQuantity:  100,
+					UsageUnit:      "unit1",
+					Domain:         "domain1",
+					CostSource:     "source1",
+					CostType:       CostTypeBilled,
+				},
+			},
+		},
+		{
+			name: "ListCost",
+			ccResponse: &pb.CustomCostResponse{
+				Costs: []*pb.CustomCost{
+					{
+						Id:             "2",
+						Zone:           "us-west-2b",
+						AccountName:    "account2",
+						ChargeCategory: "category2",
+						Description:    "description2",
+						ResourceName:   "resource2",
+						ResourceType:   "type2",
+						ProviderId:     "provider2",
+						BilledCost:     0.0,
+						ListCost:       150.0,
+						ListUnitPrice:  1.5,
+						UsageQuantity:  100,
+						UsageUnit:      "unit2",
+					},
+				},
+				Domain:     "domain2",
+				CostSource: "source2",
+			},
+			costType: CostTypeList,
+			expected: []*CustomCost{
+				{
+					Id:             "2",
+					Zone:           "us-west-2b",
+					AccountName:    "account2",
+					ChargeCategory: "category2",
+					Description:    "description2",
+					ResourceName:   "resource2",
+					ResourceType:   "type2",
+					ProviderId:     "provider2",
+					Cost:           150.0,
+					ListUnitPrice:  1.5,
+					UsageQuantity:  100,
+					UsageUnit:      "unit2",
+					Domain:         "domain2",
+					CostSource:     "source2",
+					CostType:       CostTypeList,
+				},
+			},
+		},
+		{
+			name: "ZeroCost",
+			ccResponse: &pb.CustomCostResponse{
+				Costs: []*pb.CustomCost{
+					{
+						Id:             "3",
+						Zone:           "us-central-1c",
+						AccountName:    "account3",
+						ChargeCategory: "category3",
+						Description:    "description3",
+						ResourceName:   "resource3",
+						ResourceType:   "type3",
+						ProviderId:     "provider3",
+						BilledCost:     0.0,
+						ListCost:       0.0,
+						ListUnitPrice:  0.0,
+						UsageQuantity:  0,
+						UsageUnit:      "unit3",
+					},
+				},
+				Domain:     "domain3",
+				CostSource: "source3",
+			},
+			costType: CostTypeBlended,
+			expected: []*CustomCost{},
+		},
+		{
+			name: "Non Matching cost",
+			ccResponse: &pb.CustomCostResponse{
+				Costs: []*pb.CustomCost{
+					{
+						Id:             "3",
+						Zone:           "us-central-1c",
+						AccountName:    "account3",
+						ChargeCategory: "category3",
+						Description:    "description3",
+						ResourceName:   "resource3",
+						ResourceType:   "type3",
+						ProviderId:     "provider3",
+						BilledCost:     0.0,
+						ListCost:       1.0,
+						ListUnitPrice:  0.0,
+						UsageQuantity:  0,
+						UsageUnit:      "unit3",
+					},
+				},
+				Domain:     "domain3",
+				CostSource: "source3",
+			},
+			costType: CostTypeBilled,
+			expected: []*CustomCost{},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := ParseCustomCostResponse(tt.ccResponse, tt.costType)
+			if len(result) != len(tt.expected) {
+				t.Errorf("expected %d custom costs, got %d", len(tt.expected), len(result))
+			}
+			for i, expectedCost := range tt.expected {
+				if result[i].Id != expectedCost.Id ||
+					result[i].Zone != expectedCost.Zone ||
+					result[i].AccountName != expectedCost.AccountName ||
+					result[i].ChargeCategory != expectedCost.ChargeCategory ||
+					result[i].Description != expectedCost.Description ||
+					result[i].ResourceName != expectedCost.ResourceName ||
+					result[i].ResourceType != expectedCost.ResourceType ||
+					result[i].ProviderId != expectedCost.ProviderId ||
+					result[i].Cost != expectedCost.Cost ||
+					result[i].ListUnitPrice != expectedCost.ListUnitPrice ||
+					result[i].UsageQuantity != expectedCost.UsageQuantity ||
+					result[i].UsageUnit != expectedCost.UsageUnit ||
+					result[i].Domain != expectedCost.Domain ||
+					result[i].CostSource != expectedCost.CostSource ||
+					result[i].CostType != expectedCost.CostType {
+					t.Errorf("expected %+v, got %+v", expectedCost, result[i])
+				}
+			}
+		})
+	}
+}