diff --git a/test/integration/api/schema/allocation_test.go b/test/integration/api/schema/allocation_test.go new file mode 100644 index 0000000..78b9cb4 --- /dev/null +++ b/test/integration/api/schema/allocation_test.go @@ -0,0 +1,83 @@ +package schema + +import ( + "fmt" + "testing" + + "github.com/opencost/opencost-integration-tests/pkg/api" +) + +// Description - Assert that /allocation responses retain their expected public +// JSON fields, nested objects, and important field types. +// +// Implementation Details +// - Raw JSON is inspected instead of typed API structs so removed or renamed +// fields are detected. +// - Additional response fields are allowed to preserve compatible API extensions. +// - The test is skipped when the endpoint returns no allocation items. +// +// Passing Criteria +// - The endpoint returns a successful response. +// - Every allocation item contains the required fields. +// - Required window, properties, name, and totalCost values have expected types. + +var allocationRequiredFields = []string{ + "name", + "cpuCost", + "ramCost", + "gpuCost", + "pvCost", + "networkCost", + "loadBalancerCost", + "sharedCost", + "externalCost", + "totalCost", + "minutes", + "window", + "properties", +} + +func TestAllocationResponseSchemaStability(t *testing.T) { + apiClient := api.NewAPI() + + resp := fetchRawEndpoint(t, apiClient, "/allocation", api.AutocompleteRequest{ + Window: "1h", + }) + + requireSuccessfulResponse(t, "/allocation response", resp) + + data := requireArray(t, "/allocation data", resp["data"]) + if len(data) == 0 { + t.Skip("/allocation returned no data sets") + } + + validatedItems := 0 + + for setIndex, rawSet := range data { + setContext := fmt.Sprintf("/allocation data[%d]", setIndex) + set := requireMap(t, setContext, rawSet) + + for itemName, rawItem := range set { + validatedItems++ + + itemContext := fmt.Sprintf("/allocation item %q", itemName) + item := requireMap(t, itemContext, rawItem) + + requireFields(t, itemContext, item, allocationRequiredFields) + requireString(t, itemContext+" name", item["name"]) + requireNumber(t, itemContext+" totalCost", item["totalCost"]) + + windowContext := itemContext + " window" + window := requireMap(t, windowContext, item["window"]) + requireFields(t, windowContext, window, windowRequiredFields) + requireString(t, windowContext+" start", window["start"]) + requireString(t, windowContext+" end", window["end"]) + + requireMap(t, itemContext+" properties", item["properties"]) + } + } + + if validatedItems == 0 { + t.Skip("/allocation returned no allocation items") + } +} \ No newline at end of file diff --git a/test/integration/api/schema/assets_test.go b/test/integration/api/schema/assets_test.go new file mode 100644 index 0000000..59d090a --- /dev/null +++ b/test/integration/api/schema/assets_test.go @@ -0,0 +1,91 @@ +package schema + +import ( + "fmt" + "testing" + + "github.com/opencost/opencost-integration-tests/pkg/api" +) + +// Description - Assert that /assets responses retain their expected public +// JSON fields, nested objects, and important field types. +// +// Implementation Details +// - Fields shared by every asset are validated separately from fields that are +// specific to Node assets. +// - Additional response fields and non-Node asset types are allowed. +// - The test is skipped when the endpoint returns no assets. +// +// Passing Criteria +// - The endpoint returns a successful response. +// - Every asset contains the common required fields. +// - Node assets contain their Node-specific fields and properties. + +var assetCommonRequiredFields = []string{ + "type", + "properties", + "window", + "start", + "end", + "minutes", + "totalCost", +} + +var assetCommonPropertiesRequiredFields = []string{ + "category", + "provider", + "service", +} + +var assetNodeRequiredFields = []string{ + "cpuCost", + "ramCost", + "gpuCost", + "nodeType", + "cpuCores", + "ramBytes", +} + +var assetNodePropertiesRequiredFields = []string{ + "name", + "providerID", +} + +func TestAssetsResponseSchemaStability(t *testing.T) { + apiClient := api.NewAPI() + + resp := fetchRawEndpoint(t, apiClient, "/assets", api.AutocompleteRequest{ + Window: "1h", + }) + + requireSuccessfulResponse(t, "/assets response", resp) + + data := requireMap(t, "/assets data", resp["data"]) + if len(data) == 0 { + t.Skip("/assets returned no asset items") + } + + for assetName, rawAsset := range data { + itemContext := fmt.Sprintf("/assets item %q", assetName) + asset := requireMap(t, itemContext, rawAsset) + + requireFields(t, itemContext, asset, assetCommonRequiredFields) + requireNumber(t, itemContext+" totalCost", asset["totalCost"]) + + windowContext := itemContext + " window" + window := requireMap(t, windowContext, asset["window"]) + requireFields(t, windowContext, window, windowRequiredFields) + requireString(t, windowContext+" start", window["start"]) + requireString(t, windowContext+" end", window["end"]) + + propertiesContext := itemContext + " properties" + properties := requireMap(t, propertiesContext, asset["properties"]) + requireFields(t, propertiesContext, properties, assetCommonPropertiesRequiredFields) + + assetType := requireString(t, itemContext+" type", asset["type"]) + if assetType == "Node" { + requireFields(t, itemContext, asset, assetNodeRequiredFields) + requireFields(t, propertiesContext, properties, assetNodePropertiesRequiredFields) + } + } +} \ No newline at end of file diff --git a/test/integration/api/schema/cloudcost_test.go b/test/integration/api/schema/cloudcost_test.go new file mode 100644 index 0000000..3799361 --- /dev/null +++ b/test/integration/api/schema/cloudcost_test.go @@ -0,0 +1,116 @@ +package schema + +import ( + "fmt" + "testing" + + "github.com/opencost/opencost-integration-tests/pkg/api" +) + +// Description - Assert that /cloudCost responses retain their expected public +// JSON fields, nested objects, and important field types. +// +// Implementation Details +// - Each CloudCost item, properties object, window, and nested cost object is +// validated using raw JSON. +// - Empty sets are ignored while other returned sets continue to be validated. +// - The test is skipped when no CloudCost items are returned. +// +// Passing Criteria +// - The endpoint returns a successful response. +// - Every CloudCost item contains the required fields and properties. +// - Each supported nested cost object contains numeric cost and +// kubernetesPercent fields. + +var cloudCostPropertiesRequiredFields = []string{ + "provider", + "accountID", + "accountName", + "invoiceEntityID", + "invoiceEntityName", + "service", + "category", +} + +var cloudCostCostObjectRequiredFields = []string{ + "cost", + "kubernetesPercent", +} + +var cloudCostFields = []string{ + "netCost", + "amortizedCost", + "amortizedNetCost", + "invoicedCost", + "listCost", +} + +var cloudCostItemRequiredFields = append( + []string{ + "properties", + "window", + }, + cloudCostFields..., +) + +func TestCloudCostResponseSchemaStability(t *testing.T) { + apiClient := api.NewAPI() + + resp := fetchRawEndpoint(t, apiClient, "/cloudCost", api.AutocompleteRequest{ + Window: "1d", + }) + + requireSuccessfulResponse(t, "/cloudCost response", resp) + + data := requireMap(t, "/cloudCost data", resp["data"]) + requireFields(t, "/cloudCost data", data, []string{"sets"}) + + sets := requireArray(t, "/cloudCost data.sets", data["sets"]) + if len(sets) == 0 { + t.Skip("/cloudCost returned no sets") + } + + validatedItems := 0 + + for setIndex, rawSet := range sets { + setContext := fmt.Sprintf("/cloudCost set[%d]", setIndex) + set := requireMap(t, setContext, rawSet) + + requireFields(t, setContext, set, []string{"cloudCosts"}) + + cloudCostsContext := setContext + ".cloudCosts" + cloudCosts := requireMap(t, cloudCostsContext, set["cloudCosts"]) + + for itemName, rawItem := range cloudCosts { + validatedItems++ + + itemContext := fmt.Sprintf("/cloudCost item %q", itemName) + item := requireMap(t, itemContext, rawItem) + + requireFields(t, itemContext, item, cloudCostItemRequiredFields) + + windowContext := itemContext + " window" + window := requireMap(t, windowContext, item["window"]) + requireFields(t, windowContext, window, windowRequiredFields) + requireString(t, windowContext+" start", window["start"]) + requireString(t, windowContext+" end", window["end"]) + + propertiesContext := itemContext + " properties" + properties := requireMap(t, propertiesContext, item["properties"]) + requireFields(t, propertiesContext, properties, cloudCostPropertiesRequiredFields) + + for _, costField := range cloudCostFields { + costContext := itemContext + " " + costField + costObject := requireMap(t, costContext, item[costField]) + + requireFields(t, costContext, costObject, cloudCostCostObjectRequiredFields) + requireNumber(t, costContext+" cost", costObject["cost"]) + requireNumber(t, costContext+" kubernetesPercent", costObject["kubernetesPercent"]) + } + } + } + + if validatedItems == 0 { + t.Skip("/cloudCost returned no cloud cost items") + } +} \ No newline at end of file diff --git a/test/integration/api/schema/helpers_test.go b/test/integration/api/schema/helpers_test.go new file mode 100644 index 0000000..2fac5e0 --- /dev/null +++ b/test/integration/api/schema/helpers_test.go @@ -0,0 +1,114 @@ +package schema + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/opencost/opencost-integration-tests/pkg/api" +) + +var windowRequiredFields = []string{ + "start", + "end", +} + +// fetchRawEndpoint fetches and decodes a raw API response. GetAutocompleteStatus +// is currently the only API helper that exposes both the HTTP status and raw +// response body. AutocompleteRequest supplies the shared window query parameter. +func fetchRawEndpoint(t *testing.T, apiClient *api.API, path string, req api.AutocompleteRequest) map[string]any { + t.Helper() + + status, body, err := apiClient.GetAutocompleteStatus(path, req) + if err != nil { + t.Fatalf("%s request failed: %v", path, err) + } + + if status != http.StatusOK { + t.Fatalf("%s returned HTTP %d: %s", path, status, strings.TrimSpace(string(body))) + } + + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf( + "%s returned non-JSON or malformed JSON: %v\nbody: %s", + path, + err, + strings.TrimSpace(string(body)), + ) + } + + return parsed +} + +// requireSuccessfulResponse verifies the common successful API response fields. +func requireSuccessfulResponse(t *testing.T, context string, response map[string]any) { + t.Helper() + + requireFields(t, context, response, []string{"code", "data"}) + + code := requireNumber(t, context+" code", response["code"]) + if code != http.StatusOK { + t.Fatalf("%s code was %.0f, expected %d", context, code, http.StatusOK) + } +} + +// requireFields verifies that all required fields are present. +func requireFields(t *testing.T, context string, obj map[string]any, fields []string) { + t.Helper() + + for _, field := range fields { + if _, ok := obj[field]; !ok { + t.Errorf("%s missing required field %q", context, field) + } + } +} + +// requireMap verifies that a value is a JSON object. +func requireMap(t *testing.T, context string, value any) map[string]any { + t.Helper() + + obj, ok := value.(map[string]any) + if !ok { + t.Fatalf("%s expected object, got %T", context, value) + } + + return obj +} + +// requireArray verifies that a value is a JSON array. +func requireArray(t *testing.T, context string, value any) []any { + t.Helper() + + arr, ok := value.([]any) + if !ok { + t.Fatalf("%s expected array, got %T", context, value) + } + + return arr +} + +// requireString verifies that a value is a JSON string. +func requireString(t *testing.T, context string, value any) string { + t.Helper() + + result, ok := value.(string) + if !ok { + t.Fatalf("%s expected string, got %T", context, value) + } + + return result +} + +// requireNumber verifies that a value is a JSON number. +func requireNumber(t *testing.T, context string, value any) float64 { + t.Helper() + + result, ok := value.(float64) + if !ok { + t.Fatalf("%s expected number, got %T", context, value) + } + + return result +} \ No newline at end of file diff --git a/test/integration/api/schema/test.bats b/test/integration/api/schema/test.bats new file mode 100644 index 0000000..ce974c9 --- /dev/null +++ b/test/integration/api/schema/test.bats @@ -0,0 +1,20 @@ +setup() { + DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" >/dev/null 2>&1 && pwd)" + cd "$DIR" +} + +teardown() { + : # Nothing to tear down. +} + +@test "api: Allocation Response Schema Stability" { + go test -count=1 . -run '^TestAllocationResponseSchemaStability$' +} + +@test "api: Assets Response Schema Stability" { + go test -count=1 . -run '^TestAssetsResponseSchemaStability$' +} + +@test "api: CloudCost Response Schema Stability" { + go test -count=1 . -run '^TestCloudCostResponseSchemaStability$' +} \ No newline at end of file