Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions pkg/api/allocation.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func (api *API) GetAllocation(req AllocationRequest) (*AllocationResponse, error

type AllocationRequest struct {
Accumulate string
AccumulateBy string
Aggregate string
CostUnit string
Filter string
Expand All @@ -34,6 +35,7 @@ type AllocationRequest struct {
ShareLabels string
ShareNamespaces string
ShareSplit string
Step string
ShareTenancyCosts string
Window string
}
Expand All @@ -44,6 +46,9 @@ func (ar AllocationRequest) QueryString() string {
if ar.Accumulate != "" {
params = append(params, fmt.Sprintf("accumulate=%s", ar.Accumulate))
}
if ar.AccumulateBy != "" {
params = append(params, fmt.Sprintf("accumulateBy=%s", ar.AccumulateBy))
}
if ar.Aggregate != "" {
params = append(params, fmt.Sprintf("aggregate=%s", ar.Aggregate))
}
Expand Down Expand Up @@ -83,6 +88,9 @@ func (ar AllocationRequest) QueryString() string {
if ar.ShareSplit != "" {
params = append(params, fmt.Sprintf("shareSplit=%s", ar.ShareSplit))
}
if ar.Step != "" {
params = append(params, fmt.Sprintf("step=%s", ar.Step))
}
if ar.ShareTenancyCosts != "" {
params = append(params, fmt.Sprintf("shareTenancyCosts=%s", ar.ShareTenancyCosts))
}
Expand Down
208 changes: 208 additions & 0 deletions test/integration/api/allocation/accumulate_parameter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package allocation

import (
"testing"
"time"

"github.com/opencost/opencost-integration-tests/pkg/api"
)

func mustSummary(t *testing.T, req api.AllocationRequest) *api.AllocationSummaryResponse {
t.Helper()

client := api.NewAPI()
resp, err := client.GetAllocationSummary(req)
if err != nil {
t.Fatalf("error querying /allocation/summary: %v", err)
}

return resp
}

func lastSundayUTC(ts time.Time) time.Time {
d := int(ts.Weekday())
dayStart := ts.UTC().Truncate(24 * time.Hour)
return dayStart.AddDate(0, 0, -d)
}

func firstOfMonthUTC(ts time.Time) time.Time {
utc := ts.UTC()
return time.Date(utc.Year(), utc.Month(), 1, 0, 0, 0, 0, time.UTC)
}

func requireNonEmptyAllocationsInEachSet(t *testing.T, sets []api.AllocationSummaryDataItem) {
t.Helper()
for i, set := range sets {
if len(set.Allocations) == 0 {
t.Fatalf("expected non-empty allocations in set %d (%s to %s)", i, set.Window.Start, set.Window.End)
}
}
}

func TestAccumulateLegacyTruthyValues(t *testing.T) {
tests := []string{"true", "1", "t", "TRUE"}

for _, value := range tests {
t.Run(value, func(t *testing.T) {
resp := mustSummary(t, api.AllocationRequest{
Window: "14d",
Aggregate: "namespace",
Accumulate: value,
})
if resp.Code != 200 {
t.Fatalf("expected 200, got %d", resp.Code)
}
if len(resp.Data.Sets) != 1 {
t.Fatalf("expected one accumulated set for accumulate=%q, got %d", value, len(resp.Data.Sets))
}
requireNonEmptyAllocationsInEachSet(t, resp.Data.Sets)
})
}
Comment on lines +42 to +60

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new Go test file won’t run in CI via bats unless it’s added to test/integration/api/allocation/test.bats (that file currently enumerates each go test ..._test.go). Please wire this test into the bats suite (or adjust the harness to run go test ./test/integration/api/allocation/...) so the new coverage is actually executed.

Copilot uses AI. Check for mistakes.
}

func TestAccumulateCalendarBucketCounts(t *testing.T) {
now := time.Now().UTC()
dayEnd := now.Truncate(24 * time.Hour)
weekEnd := lastSundayUTC(dayEnd)
monthEnd := firstOfMonthUTC(dayEnd)

tests := []struct {
name string
windowStart time.Time
windowEnd time.Time
accumulate string
expectedCount int
}{
{
name: "day over 2 days",
windowStart: dayEnd.AddDate(0, 0, -2),
windowEnd: dayEnd,
accumulate: "day",
expectedCount: 2,
},
{
name: "week over 2 weeks",
windowStart: weekEnd.AddDate(0, 0, -14),
windowEnd: weekEnd,
accumulate: "week",
expectedCount: 2,
},
{
name: "month over 2 months",
windowStart: monthEnd.AddDate(0, -2, 0),
windowEnd: monthEnd,
accumulate: "month",
expectedCount: 2,
},
{
name: "quarter over 2 quarters",
windowStart: monthEnd.AddDate(0, -6, 0),
windowEnd: monthEnd,
accumulate: "quarter",
expectedCount: 2,
},
Comment on lines +98 to +103

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "quarter over 2 quarters" case isn’t aligned to calendar quarter boundaries: monthEnd.AddDate(0, -6, 0) will start mid‑quarter for most months, which can legitimately produce 3 quarter buckets (e.g., Nov→May spans Q4+Q1+Q2). To make the expected set count deterministic, compute the start of the current quarter (Jan/Apr/Jul/Oct 1st at 00:00 UTC) and use that as the window end, with the start set to exactly 2 quarters earlier.

Copilot uses AI. Check for mistakes.
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
window := tc.windowStart.Format(time.RFC3339) + "," + tc.windowEnd.Format(time.RFC3339)
resp := mustSummary(t, api.AllocationRequest{
Window: window,
Aggregate: "namespace",
Accumulate: tc.accumulate,
})
if resp.Code != 200 {
t.Fatalf("expected 200, got %d", resp.Code)
}
if len(resp.Data.Sets) != tc.expectedCount {
t.Fatalf("expected %d sets, got %d (window=%s, accumulate=%s)", tc.expectedCount, len(resp.Data.Sets), window, tc.accumulate)
}
requireNonEmptyAllocationsInEachSet(t, resp.Data.Sets)
})
Comment on lines +117 to +121

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test enforces requireNonEmptyAllocationsInEachSet for month/quarter buckets, which will fail in environments without 2+ months (and especially 6+ months) of retained allocation data. Other allocation integration tests in this repo stick to short windows like today/yesterday/week/14d, so consider either (a) relaxing the non-empty requirement for older buckets, or (b) skipping the month/quarter subtests when the response indicates no data in the requested window.

Copilot uses AI. Check for mistakes.
}
}

func TestAccumulateByPrecedence(t *testing.T) {
now := time.Now().UTC()
weekEnd := lastSundayUTC(now)
window := weekEnd.AddDate(0, 0, -14).Format(time.RFC3339) + "," + weekEnd.Format(time.RFC3339)

t.Run("accumulateBy overrides false accumulate", func(t *testing.T) {
resp := mustSummary(t, api.AllocationRequest{
Window: window,
Aggregate: "namespace",
Accumulate: "false",
AccumulateBy: "week",
})
if resp.Code != 200 {
t.Fatalf("expected 200, got %d", resp.Code)
}
if len(resp.Data.Sets) != 2 {
t.Fatalf("expected 2 weekly sets from accumulateBy override, got %d", len(resp.Data.Sets))
}
requireNonEmptyAllocationsInEachSet(t, resp.Data.Sets)
})

t.Run("accumulateBy all overrides accumulate week", func(t *testing.T) {
resp := mustSummary(t, api.AllocationRequest{
Window: window,
Aggregate: "namespace",
Accumulate: "week",
AccumulateBy: "all",
})
if resp.Code != 200 {
t.Fatalf("expected 200, got %d", resp.Code)
}
if len(resp.Data.Sets) != 1 {
t.Fatalf("expected 1 set for accumulateBy=all override, got %d", len(resp.Data.Sets))
}
requireNonEmptyAllocationsInEachSet(t, resp.Data.Sets)
})
}

func TestAccumulateStepParameterVariants(t *testing.T) {
now := time.Now().UTC()
weekEnd := lastSundayUTC(now)
window := weekEnd.AddDate(0, 0, -14).Format(time.RFC3339) + "," + weekEnd.Format(time.RFC3339)

validSteps := []string{"week", "168h", "month"}
for _, step := range validSteps {
t.Run("step="+step, func(t *testing.T) {
resp := mustSummary(t, api.AllocationRequest{
Window: window,
Aggregate: "namespace",
Accumulate: "week",
Step: step,
})
if resp.Code != 200 {
t.Fatalf("expected 200 for step=%s, got %d", step, resp.Code)
}
requireNonEmptyAllocationsInEachSet(t, resp.Data.Sets)
})
}
}

func TestAccumulateInvalidParamsReturnBadRequest(t *testing.T) {
t.Run("invalid accumulateBy", func(t *testing.T) {
resp := mustSummary(t, api.AllocationRequest{
Window: "14d",
Aggregate: "namespace",
AccumulateBy: "not-valid",
})
if resp.Code == 200 {
t.Fatalf("expected non-200 for invalid accumulateBy")
}
})

t.Run("invalid step", func(t *testing.T) {
resp := mustSummary(t, api.AllocationRequest{
Window: "14d",
Aggregate: "namespace",
Accumulate: "week",
Step: "not-a-duration",
})
if resp.Code == 200 {
t.Fatalf("expected non-200 for invalid step")
}
Comment on lines +185 to +206

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestAccumulateInvalidParamsReturnBadRequest only asserts "non-200". Since the test name is specifically about BadRequest, it should assert the expected status/code (typically 400) so the test can’t pass if the API returns an unexpected error (e.g., 500) or an unexpected success payload with a non-200 Code field.

Copilot uses AI. Check for mistakes.
})
}