Просмотр исходного кода

test: unit-test currency conversion helpers

Covers the gap flagged in the upstream review: no tests existed for
the currency conversion paths added by PR #3553.

Added suites:

  pkg/costmodel/currency_helper_test.go (9 tests)
    - USD no-op (zero converter calls)
    - nil alloc / nil converter guards
    - full-field mutation at rate 2.0 (22 cost fields checked)
    - nested PV / LB / SharedCostBreakdown coverage with
      non-cost fields (ByteHours, Service, Name) preserved
    - best-effort semantics: single field failure leaves that field
      in USD while others convert
    - zero-valued fields skip the converter entirely
    - ConvertAllocationSet / ConvertAllocationSetRange propagation

  pkg/cloudcost/queryservice_test.go (4 tests)
    - USD no-op, nil guards, full CostMetric.Cost mutation,
      KubernetesPercent preservation, best-effort partial failure

  pkg/customcost/queryservice_test.go (6 tests)
    - USD no-op, nil guards, TotalCost/Cost/ListUnitPrice mutation,
      UsageQuantity preservation (not a cost), best-effort partial
      failure, timeseries propagation
    - float32 comparisons use a tolerance to absorb the
      float64->float32 round-trip in the helper

mockConverter duplicated across the three packages because each test
file lives in the owning package; avoiding an import cycle with a
shared test helper.

Signed-off-by: Warwick Peatey <warwick@automatic.systems>
Assisted-by: Claude Code
Warwick Peatey 1 месяц назад
Родитель
Сommit
2b847181c3

+ 132 - 0
pkg/cloudcost/queryservice_test.go

@@ -0,0 +1,132 @@
+package cloudcost
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+type mockConverter struct {
+	rate       float64
+	failValues map[float64]bool
+	calls      int
+}
+
+func (m *mockConverter) Convert(amount float64, from, to string) (float64, error) {
+	m.calls++
+	if from != "USD" {
+		return 0, fmt.Errorf("mock only converts from USD, got %q", from)
+	}
+	if m.failValues[amount] {
+		return 0, fmt.Errorf("simulated converter failure for amount %v", amount)
+	}
+	return amount * m.rate, nil
+}
+
+func (m *mockConverter) GetRate(from, to string) (float64, error) {
+	return m.rate, nil
+}
+
+func newCCSR(t *testing.T) *opencost.CloudCostSetRange {
+	t.Helper()
+	start := time.Date(2026, 4, 16, 0, 0, 0, 0, time.UTC)
+	end := start.Add(time.Hour)
+	set := opencost.NewCloudCostSet(start, end)
+	cc := opencost.NewCloudCost(
+		start, end,
+		&opencost.CloudCostProperties{ProviderID: "cc-a"},
+		0.5, // kubernetesPercent -- must remain untouched
+		100, // listCost
+		90,  // netCost
+		80,  // amortizedNetCost
+		110, // invoicedCost
+		85,  // amortizedCost
+	)
+	set.Insert(cc)
+	return &opencost.CloudCostSetRange{
+		CloudCostSets: []*opencost.CloudCostSet{set},
+		Window:        opencost.NewWindow(&start, &end),
+	}
+}
+
+func TestConvertCloudCostSetRange_USDIsNoOp(t *testing.T) {
+	ccsr := newCCSR(t)
+	m := &mockConverter{rate: 2.0}
+	if err := convertCloudCostSetRange(ccsr, m, "USD"); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if m.calls != 0 {
+		t.Fatalf("USD must not call converter, got %d calls", m.calls)
+	}
+}
+
+func TestConvertCloudCostSetRange_NilGuards(t *testing.T) {
+	if err := convertCloudCostSetRange(nil, &mockConverter{rate: 2.0}, "EUR"); err != nil {
+		t.Fatalf("nil range: %v", err)
+	}
+	ccsr := newCCSR(t)
+	if err := convertCloudCostSetRange(ccsr, nil, "EUR"); err != nil {
+		t.Fatalf("nil converter: %v", err)
+	}
+}
+
+func TestConvertCloudCostSetRange_MutatesAllCostMetrics(t *testing.T) {
+	ccsr := newCCSR(t)
+	m := &mockConverter{rate: 2.0}
+	if err := convertCloudCostSetRange(ccsr, m, "EUR"); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	var cc *opencost.CloudCost
+	for _, v := range ccsr.CloudCostSets[0].CloudCosts {
+		cc = v
+		break
+	}
+	if cc == nil {
+		t.Fatal("CloudCost missing from set")
+	}
+
+	checks := map[string]struct{ got, want float64 }{
+		"ListCost.Cost":         {cc.ListCost.Cost, 200},
+		"NetCost.Cost":          {cc.NetCost.Cost, 180},
+		"AmortizedNetCost.Cost": {cc.AmortizedNetCost.Cost, 160},
+		"InvoicedCost.Cost":     {cc.InvoicedCost.Cost, 220},
+		"AmortizedCost.Cost":    {cc.AmortizedCost.Cost, 170},
+	}
+	for name, c := range checks {
+		if c.got != c.want {
+			t.Errorf("%s: got %v want %v", name, c.got, c.want)
+		}
+	}
+
+	// KubernetesPercent must be preserved exactly.
+	if cc.ListCost.KubernetesPercent != 0.5 {
+		t.Errorf("KubernetesPercent must not be touched: got %v want 0.5", cc.ListCost.KubernetesPercent)
+	}
+}
+
+func TestConvertCloudCostSetRange_BestEffortOnPartialFailure(t *testing.T) {
+	ccsr := newCCSR(t)
+	// Fail only NetCost (90), leave others to convert.
+	m := &mockConverter{
+		rate:       2.0,
+		failValues: map[float64]bool{90: true},
+	}
+	if err := convertCloudCostSetRange(ccsr, m, "EUR"); err != nil {
+		t.Fatalf("best-effort helper must not return error; got %v", err)
+	}
+
+	var cc *opencost.CloudCost
+	for _, v := range ccsr.CloudCostSets[0].CloudCosts {
+		cc = v
+		break
+	}
+	if cc.NetCost.Cost != 90 {
+		t.Errorf("failed field must retain USD: NetCost got %v want 90", cc.NetCost.Cost)
+	}
+	if cc.ListCost.Cost != 200 {
+		t.Errorf("other fields must still convert: ListCost got %v want 200", cc.ListCost.Cost)
+	}
+}

+ 287 - 0
pkg/costmodel/currency_helper_test.go

@@ -0,0 +1,287 @@
+package costmodel
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+// mockConverter is a test double for currency.Converter. Rate is applied
+// uniformly unless failFields contains the specific source amount, in
+// which case Convert returns an error (useful for testing partial-
+// failure / best-effort behavior).
+type mockConverter struct {
+	rate       float64
+	failValues map[float64]bool
+	calls      int
+}
+
+func (m *mockConverter) Convert(amount float64, from, to string) (float64, error) {
+	m.calls++
+	if from != "USD" {
+		return 0, fmt.Errorf("mock only converts from USD, got %q", from)
+	}
+	if m.failValues[amount] {
+		return 0, fmt.Errorf("simulated converter failure for amount %v", amount)
+	}
+	return amount * m.rate, nil
+}
+
+func (m *mockConverter) GetRate(from, to string) (float64, error) {
+	return m.rate, nil
+}
+
+func newFullAllocation() *opencost.Allocation {
+	start := time.Date(2026, 4, 16, 0, 0, 0, 0, time.UTC)
+	end := start.Add(time.Hour)
+	return &opencost.Allocation{
+		Name:                         "ns/pod/container",
+		Start:                        start,
+		End:                          end,
+		CPUCost:                      10,
+		CPUCostAdjustment:            1,
+		CPUCostIdle:                  2,
+		GPUCost:                      20,
+		GPUCostAdjustment:            3,
+		GPUCostIdle:                  4,
+		NetworkCost:                  30,
+		NetworkCrossZoneCost:         5,
+		NetworkCrossRegionCost:       6,
+		NetworkInternetCost:          7,
+		NetworkCostAdjustment:        8,
+		NetworkNatGatewayEgressCost:  11,
+		NetworkNatGatewayIngressCost: 12,
+		LoadBalancerCost:             40,
+		LoadBalancerCostAdjustment:   9,
+		PVCostAdjustment:             13,
+		RAMCost:                      50,
+		RAMCostAdjustment:            14,
+		RAMCostIdle:                  15,
+		SharedCost:                   60,
+		ExternalCost:                 70,
+		UnmountedPVCost:              16,
+		PVs: opencost.PVAllocations{
+			opencost.PVKey{Cluster: "c1", Name: "pv-a"}: {Cost: 100, Adjustment: 5, ByteHours: 1},
+		},
+		LoadBalancers: opencost.LbAllocations{
+			"lb-a": {Service: "svc-a", Cost: 80},
+		},
+		SharedCostBreakdown: opencost.SharedCostBreakdowns{
+			"shared-a": {
+				Name:         "shared-a",
+				TotalCost:    200,
+				CPUCost:      40,
+				GPUCost:      41,
+				RAMCost:      42,
+				PVCost:       43,
+				NetworkCost:  44,
+				LBCost:       45,
+				ExternalCost: 46,
+			},
+		},
+	}
+}
+
+func TestConvertAllocation_USDIsNoOp(t *testing.T) {
+	a := newFullAllocation()
+	before := *a
+	m := &mockConverter{rate: 2.0}
+	if err := ConvertAllocation(a, m, "USD"); err != nil {
+		t.Fatalf("ConvertAllocation(USD) returned error: %v", err)
+	}
+	if m.calls != 0 {
+		t.Fatalf("expected zero converter calls for USD, got %d", m.calls)
+	}
+	if a.CPUCost != before.CPUCost || a.RAMCost != before.RAMCost {
+		t.Fatalf("USD path mutated allocation")
+	}
+}
+
+func TestConvertAllocation_NilSafe(t *testing.T) {
+	if err := ConvertAllocation(nil, &mockConverter{rate: 2.0}, "EUR"); err != nil {
+		t.Fatalf("nil Allocation: unexpected error %v", err)
+	}
+	a := newFullAllocation()
+	if err := ConvertAllocation(a, nil, "EUR"); err != nil {
+		t.Fatalf("nil Converter: unexpected error %v", err)
+	}
+	// nil converter must not mutate
+	if a.CPUCost != 10 {
+		t.Fatalf("nil converter mutated CPUCost: got %v want 10", a.CPUCost)
+	}
+}
+
+func TestConvertAllocation_MutatesAllCostFields(t *testing.T) {
+	a := newFullAllocation()
+	m := &mockConverter{rate: 2.0}
+
+	if err := ConvertAllocation(a, m, "EUR"); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	checks := map[string]struct{ got, want float64 }{
+		"CPUCost":                      {a.CPUCost, 20},
+		"CPUCostAdjustment":            {a.CPUCostAdjustment, 2},
+		"CPUCostIdle":                  {a.CPUCostIdle, 4},
+		"GPUCost":                      {a.GPUCost, 40},
+		"GPUCostAdjustment":            {a.GPUCostAdjustment, 6},
+		"GPUCostIdle":                  {a.GPUCostIdle, 8},
+		"NetworkCost":                  {a.NetworkCost, 60},
+		"NetworkCrossZoneCost":         {a.NetworkCrossZoneCost, 10},
+		"NetworkCrossRegionCost":       {a.NetworkCrossRegionCost, 12},
+		"NetworkInternetCost":          {a.NetworkInternetCost, 14},
+		"NetworkCostAdjustment":        {a.NetworkCostAdjustment, 16},
+		"NetworkNatGatewayEgressCost":  {a.NetworkNatGatewayEgressCost, 22},
+		"NetworkNatGatewayIngressCost": {a.NetworkNatGatewayIngressCost, 24},
+		"LoadBalancerCost":             {a.LoadBalancerCost, 80},
+		"LoadBalancerCostAdjustment":   {a.LoadBalancerCostAdjustment, 18},
+		"PVCostAdjustment":             {a.PVCostAdjustment, 26},
+		"RAMCost":                      {a.RAMCost, 100},
+		"RAMCostAdjustment":            {a.RAMCostAdjustment, 28},
+		"RAMCostIdle":                  {a.RAMCostIdle, 30},
+		"SharedCost":                   {a.SharedCost, 120},
+		"ExternalCost":                 {a.ExternalCost, 140},
+		"UnmountedPVCost":              {a.UnmountedPVCost, 32},
+	}
+	for name, c := range checks {
+		if c.got != c.want {
+			t.Errorf("%s: got %v want %v", name, c.got, c.want)
+		}
+	}
+}
+
+func TestConvertAllocation_NestedStructures(t *testing.T) {
+	a := newFullAllocation()
+	m := &mockConverter{rate: 2.0}
+
+	if err := ConvertAllocation(a, m, "EUR"); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	pv := a.PVs[opencost.PVKey{Cluster: "c1", Name: "pv-a"}]
+	if pv == nil {
+		t.Fatal("PV was lost")
+	}
+	if pv.Cost != 200 {
+		t.Errorf("PV.Cost: got %v want 200", pv.Cost)
+	}
+	if pv.Adjustment != 10 {
+		t.Errorf("PV.Adjustment: got %v want 10", pv.Adjustment)
+	}
+	if pv.ByteHours != 1 {
+		t.Errorf("PV.ByteHours must not be touched: got %v want 1", pv.ByteHours)
+	}
+
+	lb := a.LoadBalancers["lb-a"]
+	if lb == nil {
+		t.Fatal("LB was lost")
+	}
+	if lb.Cost != 160 {
+		t.Errorf("LB.Cost: got %v want 160", lb.Cost)
+	}
+	if lb.Service != "svc-a" {
+		t.Errorf("LB.Service must not be touched: got %q", lb.Service)
+	}
+
+	scb, ok := a.SharedCostBreakdown["shared-a"]
+	if !ok {
+		t.Fatal("SharedCostBreakdown entry was lost")
+	}
+	if scb.TotalCost != 400 {
+		t.Errorf("SCB.TotalCost: got %v want 400", scb.TotalCost)
+	}
+	if scb.CPUCost != 80 {
+		t.Errorf("SCB.CPUCost: got %v want 80", scb.CPUCost)
+	}
+	if scb.ExternalCost != 92 {
+		t.Errorf("SCB.ExternalCost: got %v want 92", scb.ExternalCost)
+	}
+	if scb.Name != "shared-a" {
+		t.Errorf("SCB.Name must not be touched: got %q", scb.Name)
+	}
+}
+
+func TestConvertAllocation_BestEffortOnPartialFailure(t *testing.T) {
+	a := newFullAllocation()
+	// Inject a failure for the exact CPUCost value. All other fields
+	// should still convert.
+	m := &mockConverter{
+		rate:       2.0,
+		failValues: map[float64]bool{10: true},
+	}
+
+	if err := ConvertAllocation(a, m, "EUR"); err != nil {
+		t.Fatalf("best-effort helper must not return error; got %v", err)
+	}
+
+	if a.CPUCost != 10 {
+		t.Errorf("failed field must retain USD value: CPUCost got %v want 10", a.CPUCost)
+	}
+	if a.RAMCost != 100 {
+		t.Errorf("unrelated field must convert: RAMCost got %v want 100", a.RAMCost)
+	}
+}
+
+func TestConvertAllocation_ZeroValuesSkipped(t *testing.T) {
+	a := &opencost.Allocation{Name: "zero"} // everything is 0
+	m := &mockConverter{rate: 2.0}
+
+	if err := ConvertAllocation(a, m, "EUR"); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if m.calls != 0 {
+		t.Errorf("zero-valued fields must not call the converter; got %d calls", m.calls)
+	}
+}
+
+func TestConvertAllocationSet(t *testing.T) {
+	start := time.Date(2026, 4, 16, 0, 0, 0, 0, time.UTC)
+	end := start.Add(time.Hour)
+	set := opencost.NewAllocationSet(start, end)
+	set.Insert(&opencost.Allocation{Name: "a1", Start: start, End: end, CPUCost: 5})
+	set.Insert(&opencost.Allocation{Name: "a2", Start: start, End: end, CPUCost: 7})
+
+	m := &mockConverter{rate: 3.0}
+	if err := ConvertAllocationSet(set, m, "EUR"); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if set.Allocations["a1"].CPUCost != 15 {
+		t.Errorf("a1 CPUCost: got %v want 15", set.Allocations["a1"].CPUCost)
+	}
+	if set.Allocations["a2"].CPUCost != 21 {
+		t.Errorf("a2 CPUCost: got %v want 21", set.Allocations["a2"].CPUCost)
+	}
+}
+
+func TestConvertAllocationSetRange(t *testing.T) {
+	start := time.Date(2026, 4, 16, 0, 0, 0, 0, time.UTC)
+	end := start.Add(time.Hour)
+	set1 := opencost.NewAllocationSet(start, end)
+	set1.Insert(&opencost.Allocation{Name: "a1", Start: start, End: end, CPUCost: 5})
+	set2 := opencost.NewAllocationSet(end, end.Add(time.Hour))
+	set2.Insert(&opencost.Allocation{Name: "a2", Start: end, End: end.Add(time.Hour), CPUCost: 9})
+	asr := opencost.NewAllocationSetRange(set1, set2)
+
+	m := &mockConverter{rate: 4.0}
+	if err := ConvertAllocationSetRange(asr, m, "EUR"); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if got := set1.Allocations["a1"].CPUCost; got != 20 {
+		t.Errorf("set1.a1.CPUCost: got %v want 20", got)
+	}
+	if got := set2.Allocations["a2"].CPUCost; got != 36 {
+		t.Errorf("set2.a2.CPUCost: got %v want 36", got)
+	}
+}
+
+func TestConvertAllocationSetRange_NilGuards(t *testing.T) {
+	if err := ConvertAllocationSetRange(nil, &mockConverter{rate: 2}, "EUR"); err != nil {
+		t.Fatalf("nil range: %v", err)
+	}
+	asr := opencost.NewAllocationSetRange()
+	if err := ConvertAllocationSetRange(asr, nil, "EUR"); err != nil {
+		t.Fatalf("nil converter: %v", err)
+	}
+}

+ 147 - 0
pkg/customcost/queryservice_test.go

@@ -0,0 +1,147 @@
+package customcost
+
+import (
+	"fmt"
+	"math"
+	"testing"
+
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+type mockConverter struct {
+	rate       float64
+	failValues map[float64]bool
+	calls      int
+}
+
+func (m *mockConverter) Convert(amount float64, from, to string) (float64, error) {
+	m.calls++
+	if from != "USD" {
+		return 0, fmt.Errorf("mock only converts from USD, got %q", from)
+	}
+	if m.failValues[amount] {
+		return 0, fmt.Errorf("simulated converter failure for amount %v", amount)
+	}
+	return amount * m.rate, nil
+}
+
+func (m *mockConverter) GetRate(from, to string) (float64, error) {
+	return m.rate, nil
+}
+
+func newCostResponse() *CostResponse {
+	return &CostResponse{
+		Window:        opencost.NewWindow(nil, nil),
+		TotalCost:     150,
+		TotalCostType: CostTypeBlended,
+		CustomCosts: []*CustomCost{
+			{Id: "cc-a", Cost: 100, ListUnitPrice: 10, UsageQuantity: 7},
+			{Id: "cc-b", Cost: 50, ListUnitPrice: 5, UsageQuantity: 3},
+		},
+	}
+}
+
+// fpEq compares float32s with a small tolerance to absorb the
+// float64->float32 round-trip the helper performs.
+func fpEq(got, want float32) bool {
+	const tol = 1e-5
+	return math.Abs(float64(got-want)) < tol
+}
+
+func TestConvertCustomCostResponse_USDIsNoOp(t *testing.T) {
+	resp := newCostResponse()
+	m := &mockConverter{rate: 2.0}
+	if err := convertCustomCostResponse(resp, m, "USD"); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if m.calls != 0 {
+		t.Fatalf("USD must not call converter, got %d calls", m.calls)
+	}
+	if resp.TotalCost != 150 {
+		t.Fatalf("USD path mutated TotalCost: got %v", resp.TotalCost)
+	}
+}
+
+func TestConvertCustomCostResponse_NilGuards(t *testing.T) {
+	if err := convertCustomCostResponse(nil, &mockConverter{rate: 2.0}, "EUR"); err != nil {
+		t.Fatalf("nil response: %v", err)
+	}
+	resp := newCostResponse()
+	if err := convertCustomCostResponse(resp, nil, "EUR"); err != nil {
+		t.Fatalf("nil converter: %v", err)
+	}
+	if resp.TotalCost != 150 {
+		t.Fatalf("nil converter mutated TotalCost: got %v", resp.TotalCost)
+	}
+}
+
+func TestConvertCustomCostResponse_MutatesAllCostFields(t *testing.T) {
+	resp := newCostResponse()
+	m := &mockConverter{rate: 2.0}
+	if err := convertCustomCostResponse(resp, m, "EUR"); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if !fpEq(resp.TotalCost, 300) {
+		t.Errorf("TotalCost: got %v want 300", resp.TotalCost)
+	}
+	if !fpEq(resp.CustomCosts[0].Cost, 200) {
+		t.Errorf("CustomCosts[0].Cost: got %v want 200", resp.CustomCosts[0].Cost)
+	}
+	if !fpEq(resp.CustomCosts[0].ListUnitPrice, 20) {
+		t.Errorf("CustomCosts[0].ListUnitPrice: got %v want 20", resp.CustomCosts[0].ListUnitPrice)
+	}
+	if !fpEq(resp.CustomCosts[1].Cost, 100) {
+		t.Errorf("CustomCosts[1].Cost: got %v want 100", resp.CustomCosts[1].Cost)
+	}
+	if !fpEq(resp.CustomCosts[1].ListUnitPrice, 10) {
+		t.Errorf("CustomCosts[1].ListUnitPrice: got %v want 10", resp.CustomCosts[1].ListUnitPrice)
+	}
+	// UsageQuantity must NOT be touched -- it is a quantity, not a cost.
+	if resp.CustomCosts[0].UsageQuantity != 7 {
+		t.Errorf("UsageQuantity must not be touched: got %v want 7", resp.CustomCosts[0].UsageQuantity)
+	}
+}
+
+func TestConvertCustomCostResponse_BestEffortOnPartialFailure(t *testing.T) {
+	resp := newCostResponse()
+	// Fail only the ListUnitPrice for cc-a (value 10).
+	m := &mockConverter{
+		rate:       2.0,
+		failValues: map[float64]bool{10: true},
+	}
+	if err := convertCustomCostResponse(resp, m, "EUR"); err != nil {
+		t.Fatalf("best-effort helper must not return error; got %v", err)
+	}
+	if !fpEq(resp.CustomCosts[0].ListUnitPrice, 10) {
+		t.Errorf("failed field must retain USD: got %v want 10", resp.CustomCosts[0].ListUnitPrice)
+	}
+	if !fpEq(resp.CustomCosts[0].Cost, 200) {
+		t.Errorf("other fields must still convert: got %v want 200", resp.CustomCosts[0].Cost)
+	}
+}
+
+func TestConvertCustomCostTimeseriesResponse(t *testing.T) {
+	ts := &CostTimeseriesResponse{
+		Window:     opencost.NewWindow(nil, nil),
+		Timeseries: []*CostResponse{newCostResponse(), newCostResponse()},
+	}
+	m := &mockConverter{rate: 3.0}
+	if err := convertCustomCostTimeseriesResponse(ts, m, "EUR"); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	for i, cr := range ts.Timeseries {
+		if !fpEq(cr.TotalCost, 450) {
+			t.Errorf("ts[%d].TotalCost: got %v want 450", i, cr.TotalCost)
+		}
+	}
+}
+
+func TestConvertCustomCostTimeseriesResponse_NilGuards(t *testing.T) {
+	if err := convertCustomCostTimeseriesResponse(nil, &mockConverter{rate: 2.0}, "EUR"); err != nil {
+		t.Fatalf("nil response: %v", err)
+	}
+	ts := &CostTimeseriesResponse{}
+	if err := convertCustomCostTimeseriesResponse(ts, nil, "EUR"); err != nil {
+		t.Fatalf("nil converter: %v", err)
+	}
+}