queryservice_test.go 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. package cloudcost
  2. import (
  3. "fmt"
  4. "testing"
  5. "time"
  6. "github.com/opencost/opencost/core/pkg/opencost"
  7. )
  8. type mockConverter struct {
  9. rate float64
  10. failValues map[float64]bool
  11. getRateFail bool
  12. getRateCalls int
  13. calls int
  14. }
  15. func (m *mockConverter) Convert(amount float64, from, to string) (float64, error) {
  16. m.calls++
  17. if from != "USD" {
  18. return 0, fmt.Errorf("mock only converts from USD, got %q", from)
  19. }
  20. if m.failValues[amount] {
  21. return 0, fmt.Errorf("simulated converter failure for amount %v", amount)
  22. }
  23. return amount * m.rate, nil
  24. }
  25. func (m *mockConverter) GetRate(from, to string) (float64, error) {
  26. m.getRateCalls++
  27. if m.getRateFail {
  28. return 0, fmt.Errorf("simulated rate lookup failure USD->%s", to)
  29. }
  30. return m.rate, nil
  31. }
  32. func newCCSR(t *testing.T) *opencost.CloudCostSetRange {
  33. t.Helper()
  34. start := time.Date(2026, 4, 16, 0, 0, 0, 0, time.UTC)
  35. end := start.Add(time.Hour)
  36. set := opencost.NewCloudCostSet(start, end)
  37. cc := opencost.NewCloudCost(
  38. start, end,
  39. &opencost.CloudCostProperties{ProviderID: "cc-a"},
  40. 0.5, // kubernetesPercent -- must remain untouched
  41. 100, // listCost
  42. 90, // netCost
  43. 80, // amortizedNetCost
  44. 110, // invoicedCost
  45. 85, // amortizedCost
  46. )
  47. set.Insert(cc)
  48. return &opencost.CloudCostSetRange{
  49. CloudCostSets: []*opencost.CloudCostSet{set},
  50. Window: opencost.NewWindow(&start, &end),
  51. }
  52. }
  53. func TestConvertCloudCostSetRange_USDIsNoOp(t *testing.T) {
  54. ccsr := newCCSR(t)
  55. m := &mockConverter{rate: 2.0}
  56. if err := convertCloudCostSetRange(ccsr, m, "USD"); err != nil {
  57. t.Fatalf("unexpected error: %v", err)
  58. }
  59. if m.calls != 0 {
  60. t.Fatalf("USD must not call converter, got %d calls", m.calls)
  61. }
  62. }
  63. func TestConvertCloudCostSetRange_NilGuards(t *testing.T) {
  64. if err := convertCloudCostSetRange(nil, &mockConverter{rate: 2.0}, "EUR"); err != nil {
  65. t.Fatalf("nil range: %v", err)
  66. }
  67. ccsr := newCCSR(t)
  68. if err := convertCloudCostSetRange(ccsr, nil, "EUR"); err != nil {
  69. t.Fatalf("nil converter: %v", err)
  70. }
  71. }
  72. func TestConvertCloudCostSetRange_MutatesAllCostMetrics(t *testing.T) {
  73. ccsr := newCCSR(t)
  74. m := &mockConverter{rate: 2.0}
  75. if err := convertCloudCostSetRange(ccsr, m, "EUR"); err != nil {
  76. t.Fatalf("unexpected error: %v", err)
  77. }
  78. var cc *opencost.CloudCost
  79. for _, v := range ccsr.CloudCostSets[0].CloudCosts {
  80. cc = v
  81. break
  82. }
  83. if cc == nil {
  84. t.Fatal("CloudCost missing from set")
  85. }
  86. checks := map[string]struct{ got, want float64 }{
  87. "ListCost.Cost": {cc.ListCost.Cost, 200},
  88. "NetCost.Cost": {cc.NetCost.Cost, 180},
  89. "AmortizedNetCost.Cost": {cc.AmortizedNetCost.Cost, 160},
  90. "InvoicedCost.Cost": {cc.InvoicedCost.Cost, 220},
  91. "AmortizedCost.Cost": {cc.AmortizedCost.Cost, 170},
  92. }
  93. for name, c := range checks {
  94. if c.got != c.want {
  95. t.Errorf("%s: got %v want %v", name, c.got, c.want)
  96. }
  97. }
  98. // KubernetesPercent must be preserved exactly.
  99. if cc.ListCost.KubernetesPercent != 0.5 {
  100. t.Errorf("KubernetesPercent must not be touched: got %v want 0.5", cc.ListCost.KubernetesPercent)
  101. }
  102. }
  103. func TestConvertCloudCostSetRange_GetRateFailsReturnsError(t *testing.T) {
  104. ccsr := newCCSR(t)
  105. m := &mockConverter{rate: 2.0, getRateFail: true}
  106. err := convertCloudCostSetRange(ccsr, m, "XXX")
  107. if err == nil {
  108. t.Fatal("expected error when GetRate fails")
  109. }
  110. if m.calls != 0 {
  111. t.Errorf("no Convert calls expected on precheck failure; got %d", m.calls)
  112. }
  113. for _, v := range ccsr.CloudCostSets[0].CloudCosts {
  114. if v.ListCost.Cost != 100 {
  115. t.Errorf("data must not be mutated on precheck failure; ListCost=%v", v.ListCost.Cost)
  116. }
  117. }
  118. }
  119. func TestConvertCloudCostSetRange_USDNormalized(t *testing.T) {
  120. for _, target := range []string{"usd", " USD ", ""} {
  121. ccsr := newCCSR(t)
  122. m := &mockConverter{rate: 2.0}
  123. if err := convertCloudCostSetRange(ccsr, m, target); err != nil {
  124. t.Fatalf("target %q: %v", target, err)
  125. }
  126. if m.calls != 0 || m.getRateCalls != 0 {
  127. t.Errorf("target %q: expected zero converter activity", target)
  128. }
  129. }
  130. }
  131. func TestConvertCloudCostSetRange_BestEffortOnPartialFailure(t *testing.T) {
  132. ccsr := newCCSR(t)
  133. // Fail only NetCost (90), leave others to convert.
  134. m := &mockConverter{
  135. rate: 2.0,
  136. failValues: map[float64]bool{90: true},
  137. }
  138. if err := convertCloudCostSetRange(ccsr, m, "EUR"); err != nil {
  139. t.Fatalf("best-effort helper must not return error; got %v", err)
  140. }
  141. var cc *opencost.CloudCost
  142. for _, v := range ccsr.CloudCostSets[0].CloudCosts {
  143. cc = v
  144. break
  145. }
  146. if cc.NetCost.Cost != 90 {
  147. t.Errorf("failed field must retain USD: NetCost got %v want 90", cc.NetCost.Cost)
  148. }
  149. if cc.ListCost.Cost != 200 {
  150. t.Errorf("other fields must still convert: ListCost got %v want 200", cc.ListCost.Cost)
  151. }
  152. }