usageintegration_http_test.go 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. package ibm
  2. import (
  3. "encoding/json"
  4. "net/http"
  5. "net/http/httptest"
  6. "net/url"
  7. "strings"
  8. "sync"
  9. "testing"
  10. "time"
  11. "github.com/IBM/go-sdk-core/v5/core"
  12. "github.com/IBM/platform-services-go-sdk/usagereportsv4"
  13. "github.com/opencost/opencost/pkg/cloud"
  14. )
  15. const usageReportsTestAccountID = "b09edf5642ebfad587c594f4d4a354b0"
  16. type capturedUsageRequest struct {
  17. method string
  18. path string
  19. query url.Values
  20. }
  21. func TestUsageIntegrationGetCloudCostPaginatesAndMapsResponses(t *testing.T) {
  22. var (
  23. mu sync.Mutex
  24. requests []capturedUsageRequest
  25. )
  26. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  27. query := r.URL.Query()
  28. mu.Lock()
  29. requests = append(requests, capturedUsageRequest{method: r.Method, path: r.URL.EscapedPath(), query: query})
  30. mu.Unlock()
  31. w.Header().Set("Content-Type", "application/json")
  32. response := map[string]any{
  33. "count": 1,
  34. "limit": 1,
  35. }
  36. switch query.Get("_start") {
  37. case "":
  38. response["next"] = map[string]any{
  39. "href": "https://unused.test/resource-usage?_start=page-2",
  40. }
  41. response["resources"] = []any{
  42. usageReportsTestResource("instance-1", "is.instance", 31, 62, "env:test"),
  43. }
  44. case "page-2":
  45. response["resources"] = []any{
  46. usageReportsTestResource("instance-2", "cloud-object-storage", 62, 93, "team:storage"),
  47. }
  48. default:
  49. http.Error(w, `{"error":"unexpected page token"}`, http.StatusBadRequest)
  50. return
  51. }
  52. if err := json.NewEncoder(w).Encode(response); err != nil {
  53. t.Errorf("encoding response: %v", err)
  54. }
  55. }))
  56. t.Cleanup(server.Close)
  57. integration := newUsageReportsTestIntegration(t, server.URL)
  58. start := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC)
  59. end := time.Date(2026, 1, 16, 0, 0, 0, 0, time.UTC)
  60. asOf := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
  61. result, err := integration.getCloudCost(start, end, asOf)
  62. if err != nil {
  63. t.Fatalf("getCloudCost: %v", err)
  64. }
  65. if integration.ConnectionStatus != cloud.SuccessfulConnection {
  66. t.Fatalf("ConnectionStatus = %s, want %s", integration.ConnectionStatus, cloud.SuccessfulConnection)
  67. }
  68. mu.Lock()
  69. gotRequests := append([]capturedUsageRequest(nil), requests...)
  70. mu.Unlock()
  71. if len(gotRequests) != 2 {
  72. t.Fatalf("request count = %d, want 2", len(gotRequests))
  73. }
  74. wantPath := "/v4/accounts/" + usageReportsTestAccountID + "/resource_instances/usage/2026-01"
  75. for i, request := range gotRequests {
  76. if request.method != http.MethodGet {
  77. t.Errorf("request %d method = %q, want GET", i+1, request.method)
  78. }
  79. if request.path != wantPath {
  80. t.Errorf("request %d path = %q, want %q", i+1, request.path, wantPath)
  81. }
  82. for key, want := range map[string]string{
  83. "_limit": "200",
  84. "_names": "true",
  85. "_tags": "true",
  86. } {
  87. if got := request.query.Get(key); got != want {
  88. t.Errorf("request %d query %s = %q, want %q", i+1, key, got, want)
  89. }
  90. }
  91. }
  92. if got := gotRequests[0].query.Get("_start"); got != "" {
  93. t.Errorf("first request _start = %q, want empty", got)
  94. }
  95. if got := gotRequests[1].query.Get("_start"); got != "page-2" {
  96. t.Errorf("second request _start = %q, want page-2", got)
  97. }
  98. if len(result.CloudCostSets) != 31 {
  99. t.Fatalf("CloudCostSet count = %d, want 31", len(result.CloudCostSets))
  100. }
  101. accumulated, err := result.AccumulateAll()
  102. if err != nil {
  103. t.Fatalf("accumulating result: %v", err)
  104. }
  105. if accumulated.Length() != 2 {
  106. t.Fatalf("mapped CloudCost count = %d, want 2", accumulated.Length())
  107. }
  108. byProviderID := map[string]float64{}
  109. for _, cost := range accumulated.CloudCosts {
  110. byProviderID[cost.Properties.ProviderID] = cost.NetCost.Cost
  111. if cost.Properties.AccountID != usageReportsTestAccountID {
  112. t.Errorf("AccountID = %q, want normalized %q", cost.Properties.AccountID, usageReportsTestAccountID)
  113. }
  114. switch cost.Properties.ProviderID {
  115. case "instance-1":
  116. if cost.Properties.Service != "is.instance" || cost.Properties.Labels["env"] != "test" {
  117. t.Errorf("instance-1 mapping = service %q, labels %#v", cost.Properties.Service, cost.Properties.Labels)
  118. }
  119. case "instance-2":
  120. if cost.Properties.Service != "cloud-object-storage" || cost.Properties.Labels["team"] != "storage" {
  121. t.Errorf("instance-2 mapping = service %q, labels %#v", cost.Properties.Service, cost.Properties.Labels)
  122. }
  123. default:
  124. t.Errorf("unexpected ProviderID %q", cost.Properties.ProviderID)
  125. }
  126. }
  127. if got := byProviderID["instance-1"]; got != 31 {
  128. t.Errorf("instance-1 NetCost = %v, want 31", got)
  129. }
  130. if got := byProviderID["instance-2"]; got != 62 {
  131. t.Errorf("instance-2 NetCost = %v, want 62", got)
  132. }
  133. }
  134. func TestUsageIntegrationGetCloudCostMarksMissingData(t *testing.T) {
  135. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  136. w.Header().Set("Content-Type", "application/json")
  137. if err := json.NewEncoder(w).Encode(map[string]any{
  138. "count": 0,
  139. "limit": 200,
  140. "resources": []any{},
  141. }); err != nil {
  142. t.Errorf("encoding response: %v", err)
  143. }
  144. }))
  145. t.Cleanup(server.Close)
  146. integration := newUsageReportsTestIntegration(t, server.URL)
  147. start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
  148. end := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
  149. result, err := integration.getCloudCost(start, end, end)
  150. if err != nil {
  151. t.Fatalf("getCloudCost: %v", err)
  152. }
  153. if !result.IsEmpty() {
  154. t.Fatal("expected an empty CloudCostSetRange")
  155. }
  156. if integration.ConnectionStatus != cloud.MissingData {
  157. t.Errorf("ConnectionStatus = %s, want %s", integration.ConnectionStatus, cloud.MissingData)
  158. }
  159. }
  160. func TestUsageIntegrationGetCloudCostMarksFailedConnection(t *testing.T) {
  161. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  162. w.Header().Set("Content-Type", "application/json")
  163. w.WriteHeader(http.StatusInternalServerError)
  164. _, _ = w.Write([]byte(`{"errors":[{"code":"server_error","message":"test failure"}]}`))
  165. }))
  166. t.Cleanup(server.Close)
  167. integration := newUsageReportsTestIntegration(t, server.URL)
  168. start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
  169. end := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
  170. result, err := integration.getCloudCost(start, end, end)
  171. if err == nil {
  172. t.Fatal("getCloudCost error = nil, want request failure")
  173. }
  174. if result != nil {
  175. t.Fatal("getCloudCost result is non-nil on request failure")
  176. }
  177. if !strings.Contains(err.Error(), "querying IBM resource usage for 2026-01") {
  178. t.Errorf("error = %q, want month-scoped query context", err)
  179. }
  180. if integration.ConnectionStatus != cloud.FailedConnection {
  181. t.Errorf("ConnectionStatus = %s, want %s", integration.ConnectionStatus, cloud.FailedConnection)
  182. }
  183. }
  184. func newUsageReportsTestIntegration(t *testing.T, serviceURL string) *UsageIntegration {
  185. t.Helper()
  186. client, err := usagereportsv4.NewUsageReportsV4(&usagereportsv4.UsageReportsV4Options{
  187. URL: serviceURL,
  188. Authenticator: &core.NoAuthAuthenticator{},
  189. })
  190. if err != nil {
  191. t.Fatalf("creating Usage Reports test client: %v", err)
  192. }
  193. return &UsageIntegration{
  194. UsageConfiguration: UsageConfiguration{AccountID: "a/" + usageReportsTestAccountID},
  195. clientFactory: func() (*usagereportsv4.UsageReportsV4, error) {
  196. return client, nil
  197. },
  198. }
  199. }
  200. func usageReportsTestResource(providerID, service string, cost, ratedCost float64, tag string) map[string]any {
  201. return map[string]any{
  202. "account_id": "a/" + usageReportsTestAccountID,
  203. "resource_instance_id": providerID,
  204. "resource_id": service,
  205. "resource_name": service + " display name",
  206. "pricing_country": "USA",
  207. "currency_code": "USD",
  208. "currency_rate": 1,
  209. "billable": true,
  210. "plan_id": "test-plan",
  211. "month": "2026-01",
  212. "tags": []any{tag},
  213. "usage": []any{
  214. map[string]any{
  215. "metric": "VCPU_HOURS",
  216. "quantity": 1,
  217. "cost": cost,
  218. "rated_cost": ratedCost,
  219. "discounts": []any{},
  220. },
  221. },
  222. }
  223. }