From 3bdc20165bea974ab7849a88c0285da432a01027 Mon Sep 17 00:00:00 2001 From: noah-philip Date: Tue, 16 Jun 2026 11:14:54 -0500 Subject: [PATCH 1/2] Add aggregation cost conservation test Signed-off-by: noah-philip --- .../aggregation_cost_conservation_test.go | 141 ++++++++++++++++++ test/integration/api/allocation/test.bats | 4 + 2 files changed, 145 insertions(+) create mode 100644 test/integration/api/allocation/aggregation_cost_conservation_test.go diff --git a/test/integration/api/allocation/aggregation_cost_conservation_test.go b/test/integration/api/allocation/aggregation_cost_conservation_test.go new file mode 100644 index 0000000..2055b2a --- /dev/null +++ b/test/integration/api/allocation/aggregation_cost_conservation_test.go @@ -0,0 +1,141 @@ +package allocation + +// Description - Verify that allocation total cost is conserved when the same +// window is queried using different aggregation levels. +// +// Implementation Details +// - Every request uses the same completed historical window and query parameters. +// - Idle cost is included separately and is not redistributed. +// - Each aggregation's total is calculated by summing every returned item's TotalCost. +// - Cluster aggregation is used as the comparison baseline. +// - Small differences can occur from floating-point accumulation and rounding. +// +// Passing Criteria +// - Cluster, node, namespace, pod, and container totals match within the +// documented absolute-or-relative tolerance. +// - Failure output identifies the divergent aggregation and difference. + +import ( + "math" + "testing" + + "github.com/opencost/opencost-integration-tests/pkg/api" +) + +const ( + aggregationAbsoluteTolerance = 0.01 + aggregationRelativeTolerance = 0.001 +) + +var aggregationLevels = []string{ + "cluster", + "node", + "namespace", + "pod", + "container", +} + +func sumAllocationTotalCost(t *testing.T, response *api.AllocationResponse) float64 { + t.Helper() + + total := 0.0 + itemCount := 0 + + for _, allocationSet := range response.Data { + for _, item := range allocationSet { + total += item.TotalCost + itemCount++ + } + } + + if itemCount == 0 { + t.Fatal("allocation response contained no items") + } + + return total +} + +func aggregationAbsoluteDiff(expected, actual float64) float64 { + return math.Abs(expected - actual) +} + +func aggregationRelativeDiff(expected, actual float64) float64 { + denominator := math.Max(math.Abs(expected), math.Abs(actual)) + if denominator == 0 { + return 0 + } + + return aggregationAbsoluteDiff(expected, actual) / denominator +} + +func withinAggregationTolerance(expected, actual float64) bool { + return aggregationAbsoluteDiff(expected, actual) <= aggregationAbsoluteTolerance || + aggregationRelativeDiff(expected, actual) <= aggregationRelativeTolerance +} + +func fetchAggregationTotal( + t *testing.T, + apiClient *api.API, + window string, + aggregation string, +) float64 { + t.Helper() + + allocationRequest := api.AllocationRequest{ + Window: window, + Aggregate: aggregation, + Accumulate: "true", + IncludeIdle: "true", + ShareIdle: "false", + } + + allocationResponse, err := apiClient.GetAllocation(allocationRequest) + if err != nil { + t.Fatalf("Error while calling Allocation API for aggregation %s: %v", aggregation, err) + } + + if allocationResponse.Code != 200 { + t.Fatalf("API returned non-200 code for aggregation %s: %d", aggregation, allocationResponse.Code) + } + + return sumAllocationTotalCost(t, allocationResponse) +} + +func TestAllocationCostConservedAcrossAggregations(t *testing.T) { + apiClient := api.NewAPI() + + // A completed historical window prevents totals from changing between the + // sequential aggregation requests. + window := "yesterday" + + aggregationTotals := make(map[string]float64, len(aggregationLevels)) + + for _, aggregation := range aggregationLevels { + total := fetchAggregationTotal(t, apiClient, window, aggregation) + aggregationTotals[aggregation] = total + t.Logf("aggregation=%s totalCost=%.6f", aggregation, total) + } + + clusterTotal, ok := aggregationTotals["cluster"] + if !ok { + t.Fatal("cluster aggregation total not found") + } + + for aggregation, total := range aggregationTotals { + if aggregation == "cluster" { + continue + } + + if !withinAggregationTolerance(clusterTotal, total) { + t.Errorf( + "aggregation=%s diverged from cluster: cluster=%.6f %s=%.6f diff=%.6f relativeDiff=%.4f%%", + aggregation, + clusterTotal, + aggregation, + total, + aggregationAbsoluteDiff(clusterTotal, total), + aggregationRelativeDiff(clusterTotal, total)*100, + ) + } + } +} \ No newline at end of file diff --git a/test/integration/api/allocation/test.bats b/test/integration/api/allocation/test.bats index 2d41bd7..07b6143 100644 --- a/test/integration/api/allocation/test.bats +++ b/test/integration/api/allocation/test.bats @@ -26,6 +26,10 @@ teardown() { go test namespace_annotations_test.go } +@test "validate_api: validate cost across aggregations" { + go test aggregation_cost_conservation_test.go +} + @test "validate_api: negative idle cost values" { go test idle_cost_negative_test.go } From 6073cda83d169ebd1e2382811401ecab402e7250 Mon Sep 17 00:00:00 2001 From: noah-philip Date: Tue, 16 Jun 2026 11:27:02 -0500 Subject: [PATCH 2/2] Use deterministic aggregation comparison order Signed-off-by: noah-philip --- .../api/allocation/aggregation_cost_conservation_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/integration/api/allocation/aggregation_cost_conservation_test.go b/test/integration/api/allocation/aggregation_cost_conservation_test.go index 2055b2a..45c72f7 100644 --- a/test/integration/api/allocation/aggregation_cost_conservation_test.go +++ b/test/integration/api/allocation/aggregation_cost_conservation_test.go @@ -121,11 +121,12 @@ func TestAllocationCostConservedAcrossAggregations(t *testing.T) { t.Fatal("cluster aggregation total not found") } - for aggregation, total := range aggregationTotals { + for _, aggregation := range aggregationLevels { if aggregation == "cluster" { continue } + total := aggregationTotals[aggregation] if !withinAggregationTolerance(clusterTotal, total) { t.Errorf( "aggregation=%s diverged from cluster: cluster=%.6f %s=%.6f diff=%.6f relativeDiff=%.4f%%",