queryservice_test.go 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. package customcost
  2. import (
  3. "fmt"
  4. "math"
  5. "testing"
  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 newCostResponse() *CostResponse {
  33. return &CostResponse{
  34. Window: opencost.NewWindow(nil, nil),
  35. TotalCost: 150,
  36. TotalCostType: CostTypeBlended,
  37. CustomCosts: []*CustomCost{
  38. {Id: "cc-a", Cost: 100, ListUnitPrice: 10, UsageQuantity: 7},
  39. {Id: "cc-b", Cost: 50, ListUnitPrice: 5, UsageQuantity: 3},
  40. },
  41. }
  42. }
  43. // fpEq compares float32s with a small tolerance to absorb the
  44. // float64->float32 round-trip the helper performs.
  45. func fpEq(got, want float32) bool {
  46. const tol = 1e-5
  47. return math.Abs(float64(got-want)) < tol
  48. }
  49. func TestConvertCustomCostResponse_USDIsNoOp(t *testing.T) {
  50. resp := newCostResponse()
  51. m := &mockConverter{rate: 2.0}
  52. if err := convertCustomCostResponse(resp, m, "USD"); err != nil {
  53. t.Fatalf("unexpected error: %v", err)
  54. }
  55. if m.calls != 0 {
  56. t.Fatalf("USD must not call converter, got %d calls", m.calls)
  57. }
  58. if resp.TotalCost != 150 {
  59. t.Fatalf("USD path mutated TotalCost: got %v", resp.TotalCost)
  60. }
  61. }
  62. func TestConvertCustomCostResponse_NilGuards(t *testing.T) {
  63. if err := convertCustomCostResponse(nil, &mockConverter{rate: 2.0}, "EUR"); err != nil {
  64. t.Fatalf("nil response: %v", err)
  65. }
  66. resp := newCostResponse()
  67. if err := convertCustomCostResponse(resp, nil, "EUR"); err != nil {
  68. t.Fatalf("nil converter: %v", err)
  69. }
  70. if resp.TotalCost != 150 {
  71. t.Fatalf("nil converter mutated TotalCost: got %v", resp.TotalCost)
  72. }
  73. }
  74. func TestConvertCustomCostResponse_MutatesAllCostFields(t *testing.T) {
  75. resp := newCostResponse()
  76. m := &mockConverter{rate: 2.0}
  77. if err := convertCustomCostResponse(resp, m, "EUR"); err != nil {
  78. t.Fatalf("unexpected error: %v", err)
  79. }
  80. if !fpEq(resp.TotalCost, 300) {
  81. t.Errorf("TotalCost: got %v want 300", resp.TotalCost)
  82. }
  83. if !fpEq(resp.CustomCosts[0].Cost, 200) {
  84. t.Errorf("CustomCosts[0].Cost: got %v want 200", resp.CustomCosts[0].Cost)
  85. }
  86. if !fpEq(resp.CustomCosts[0].ListUnitPrice, 20) {
  87. t.Errorf("CustomCosts[0].ListUnitPrice: got %v want 20", resp.CustomCosts[0].ListUnitPrice)
  88. }
  89. if !fpEq(resp.CustomCosts[1].Cost, 100) {
  90. t.Errorf("CustomCosts[1].Cost: got %v want 100", resp.CustomCosts[1].Cost)
  91. }
  92. if !fpEq(resp.CustomCosts[1].ListUnitPrice, 10) {
  93. t.Errorf("CustomCosts[1].ListUnitPrice: got %v want 10", resp.CustomCosts[1].ListUnitPrice)
  94. }
  95. // UsageQuantity must NOT be touched -- it is a quantity, not a cost.
  96. if resp.CustomCosts[0].UsageQuantity != 7 {
  97. t.Errorf("UsageQuantity must not be touched: got %v want 7", resp.CustomCosts[0].UsageQuantity)
  98. }
  99. }
  100. func TestConvertCustomCostResponse_BestEffortOnPartialFailure(t *testing.T) {
  101. resp := newCostResponse()
  102. // Fail only the ListUnitPrice for cc-a (value 10).
  103. m := &mockConverter{
  104. rate: 2.0,
  105. failValues: map[float64]bool{10: true},
  106. }
  107. if err := convertCustomCostResponse(resp, m, "EUR"); err != nil {
  108. t.Fatalf("best-effort helper must not return error; got %v", err)
  109. }
  110. if !fpEq(resp.CustomCosts[0].ListUnitPrice, 10) {
  111. t.Errorf("failed field must retain USD: got %v want 10", resp.CustomCosts[0].ListUnitPrice)
  112. }
  113. if !fpEq(resp.CustomCosts[0].Cost, 200) {
  114. t.Errorf("other fields must still convert: got %v want 200", resp.CustomCosts[0].Cost)
  115. }
  116. }
  117. func TestConvertCustomCostTimeseriesResponse(t *testing.T) {
  118. ts := &CostTimeseriesResponse{
  119. Window: opencost.NewWindow(nil, nil),
  120. Timeseries: []*CostResponse{newCostResponse(), newCostResponse()},
  121. }
  122. m := &mockConverter{rate: 3.0}
  123. if err := convertCustomCostTimeseriesResponse(ts, m, "EUR"); err != nil {
  124. t.Fatalf("unexpected error: %v", err)
  125. }
  126. for i, cr := range ts.Timeseries {
  127. if !fpEq(cr.TotalCost, 450) {
  128. t.Errorf("ts[%d].TotalCost: got %v want 450", i, cr.TotalCost)
  129. }
  130. }
  131. }
  132. func TestConvertCustomCostResponse_GetRateFailsReturnsError(t *testing.T) {
  133. resp := newCostResponse()
  134. m := &mockConverter{rate: 2.0, getRateFail: true}
  135. err := convertCustomCostResponse(resp, m, "XXX")
  136. if err == nil {
  137. t.Fatal("expected error when GetRate fails")
  138. }
  139. if m.calls != 0 {
  140. t.Errorf("no Convert calls expected on precheck failure; got %d", m.calls)
  141. }
  142. if resp.TotalCost != 150 {
  143. t.Errorf("data must not be mutated on precheck failure; TotalCost=%v", resp.TotalCost)
  144. }
  145. }
  146. func TestConvertCustomCostResponse_USDNormalized(t *testing.T) {
  147. for _, target := range []string{"usd", " USD ", ""} {
  148. resp := newCostResponse()
  149. m := &mockConverter{rate: 2.0}
  150. if err := convertCustomCostResponse(resp, m, target); err != nil {
  151. t.Fatalf("target %q: %v", target, err)
  152. }
  153. if m.calls != 0 || m.getRateCalls != 0 {
  154. t.Errorf("target %q: expected zero converter activity", target)
  155. }
  156. }
  157. }
  158. func TestConvertCustomCostTimeseriesResponse_NilGuards(t *testing.T) {
  159. if err := convertCustomCostTimeseriesResponse(nil, &mockConverter{rate: 2.0}, "EUR"); err != nil {
  160. t.Fatalf("nil response: %v", err)
  161. }
  162. ts := &CostTimeseriesResponse{}
  163. if err := convertCustomCostTimeseriesResponse(ts, nil, "EUR"); err != nil {
  164. t.Fatalf("nil converter: %v", err)
  165. }
  166. }