internal/budget/types_test.go
Rama main · réplica de lectura del repositorio original.
package budget
import (
"math"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestApplySettingValueIgnoresUnknownNonInteger(t *testing.T) {
settings := DefaultSettings()
err := applySettingValue(&settings, "unknown_setting", "not-an-int")
require.NoError(t, err)
}
func TestApplySettingValueRejectsKnownNonInteger(t *testing.T) {
settings := DefaultSettings()
err := applySettingValue(&settings, settingDailyResetHour, "not-an-int")
require.ErrorContains(t, err, "must be an integer")
}
func TestNormalizeBudgetRejectsNonFiniteAmount(t *testing.T) {
tests := []struct {
name string
amount float64
}{
{name: "nan", amount: math.NaN()},
{name: "positive infinity", amount: math.Inf(1)},
{name: "negative infinity", amount: math.Inf(-1)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NormalizeBudget(Budget{
Subject: "/team",
PeriodSeconds: PeriodDailySeconds,
Amount: tt.amount,
})
require.ErrorContains(t, err, "amount must be a finite number greater than 0")
})
}
}
func TestPeriodBoundsUsesConfiguredAnchors(t *testing.T) {
settings := Settings{
DailyResetHour: 6,
DailyResetMinute: 30,
WeeklyResetWeekday: int(time.Wednesday),
WeeklyResetHour: 9,
WeeklyResetMinute: 15,
MonthlyResetDay: 31,
MonthlyResetHour: 2,
MonthlyResetMinute: 45,
}
tests := []struct {
name string
now time.Time
period int64
wantStart time.Time
wantEnd time.Time
}{
{
name: "daily exactly at anchor",
now: time.Date(2026, time.April, 25, 6, 30, 0, 0, time.UTC),
period: PeriodDailySeconds,
wantStart: time.Date(2026, time.April, 25, 6, 30, 0, 0, time.UTC),
wantEnd: time.Date(2026, time.April, 26, 6, 30, 0, 0, time.UTC),
},
{
name: "weekly on anchor weekday before anchor",
now: time.Date(2026, time.April, 22, 9, 14, 59, 0, time.UTC),
period: PeriodWeeklySeconds,
wantStart: time.Date(2026, time.April, 15, 9, 15, 0, 0, time.UTC),
wantEnd: time.Date(2026, time.April, 22, 9, 15, 0, 0, time.UTC),
},
{
name: "weekly exactly at anchor",
now: time.Date(2026, time.April, 22, 9, 15, 0, 0, time.UTC),
period: PeriodWeeklySeconds,
wantStart: time.Date(2026, time.April, 22, 9, 15, 0, 0, time.UTC),
wantEnd: time.Date(2026, time.April, 29, 9, 15, 0, 0, time.UTC),
},
{
name: "weekly on anchor weekday after anchor",
now: time.Date(2026, time.April, 22, 9, 15, 1, 0, time.UTC),
period: PeriodWeeklySeconds,
wantStart: time.Date(2026, time.April, 22, 9, 15, 0, 0, time.UTC),
wantEnd: time.Date(2026, time.April, 29, 9, 15, 0, 0, time.UTC),
},
{
name: "monthly day 31 before non-leap February anchor",
now: time.Date(2026, time.February, 28, 2, 44, 59, 0, time.UTC),
period: PeriodMonthlySeconds,
wantStart: time.Date(2026, time.January, 31, 2, 45, 0, 0, time.UTC),
wantEnd: time.Date(2026, time.February, 28, 2, 45, 0, 0, time.UTC),
},
{
name: "monthly day 31 at non-leap February anchor",
now: time.Date(2026, time.February, 28, 2, 45, 0, 0, time.UTC),
period: PeriodMonthlySeconds,
wantStart: time.Date(2026, time.February, 28, 2, 45, 0, 0, time.UTC),
wantEnd: time.Date(2026, time.March, 31, 2, 45, 0, 0, time.UTC),
},
{
name: "monthly day 31 before March anchor uses February anchor",
now: time.Date(2026, time.March, 30, 3, 0, 0, 0, time.UTC),
period: PeriodMonthlySeconds,
wantStart: time.Date(2026, time.February, 28, 2, 45, 0, 0, time.UTC),
wantEnd: time.Date(2026, time.March, 31, 2, 45, 0, 0, time.UTC),
},
{
name: "monthly day 31 at leap February anchor",
now: time.Date(2024, time.February, 29, 2, 45, 0, 0, time.UTC),
period: PeriodMonthlySeconds,
wantStart: time.Date(2024, time.February, 29, 2, 45, 0, 0, time.UTC),
wantEnd: time.Date(2024, time.March, 31, 2, 45, 0, 0, time.UTC),
},
{
name: "monthly day 31 clamps April",
now: time.Date(2026, time.April, 30, 3, 0, 0, 0, time.UTC),
period: PeriodMonthlySeconds,
wantStart: time.Date(2026, time.April, 30, 2, 45, 0, 0, time.UTC),
wantEnd: time.Date(2026, time.May, 31, 2, 45, 0, 0, time.UTC),
},
{
name: "monthly day 31 clamps June",
now: time.Date(2026, time.June, 30, 3, 0, 0, 0, time.UTC),
period: PeriodMonthlySeconds,
wantStart: time.Date(2026, time.June, 30, 2, 45, 0, 0, time.UTC),
wantEnd: time.Date(2026, time.July, 31, 2, 45, 0, 0, time.UTC),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
start, end := PeriodBounds(tt.now, tt.period, settings)
require.True(t, start.Equal(tt.wantStart), "start = %s, want %s", start, tt.wantStart)
require.True(t, end.Equal(tt.wantEnd), "end = %s, want %s", end, tt.wantEnd)
})
}
}
func TestCheckResultUsageRatio(t *testing.T) {
got := (CheckResult{Budget: Budget{Amount: 100}, Spent: 25}).UsageRatio()
require.Equal(t, 0.25, got)
got = (CheckResult{Budget: Budget{Amount: 0}, Spent: 25}).UsageRatio()
require.Equal(t, float64(0), got)
// Deliberately unclamped: >1 signals an exceeded budget to the dashboard.
got = (CheckResult{Budget: Budget{Amount: 100}, Spent: 150}).UsageRatio()
require.Equal(t, 1.5, got)
}
func TestCheckResultPeriodRatio(t *testing.T) {
start := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
end := start.Add(10 * time.Hour)
result := CheckResult{PeriodStart: start, PeriodEnd: end}
got := result.PeriodRatio(start.Add(5 * time.Hour))
require.Equal(t, 0.5, got)
got = result.PeriodRatio(start.Add(-time.Hour))
require.Equal(t, float64(0), got)
got = result.PeriodRatio(end.Add(time.Hour))
require.Equal(t, float64(1), got)
got = (CheckResult{PeriodStart: start, PeriodEnd: start}).PeriodRatio(start)
require.Equal(t, float64(0), got)
}
func TestNormalizeProviderBudget(t *testing.T) {
tests := []struct {
name string
subject string
want string
wantErr string
}{
{name: "lowercased and trimmed", subject: " OpenAI-Prod ", want: "openai-prod"},
{name: "empty", subject: " ", wantErr: "provider budget subject is required"},
{name: "qualified model", subject: "openai/gpt-4o", wantErr: "without slashes or spaces"},
{name: "inner space", subject: "open ai", wantErr: "without slashes or spaces"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := NormalizeBudget(Budget{
Scope: "Provider",
Subject: tt.subject,
PeriodSeconds: PeriodDailySeconds,
Amount: 10,
})
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
require.Equal(t, ScopeProvider, got.Scope)
require.Equal(t, tt.want, got.Subject)
})
}
}
func TestNormalizeProviderBudgetRejectsPerChild(t *testing.T) {
_, err := NormalizeBudget(Budget{
Scope: ScopeProvider,
Subject: "openai",
PerChild: true,
PeriodSeconds: PeriodDailySeconds,
Amount: 10,
})
require.ErrorContains(t, err, "per_child is only valid for user_path budgets")
}
func TestProviderBudgetAppliesTo(t *testing.T) {
b := Budget{Scope: ScopeProvider, Subject: "openai-prod"}
tests := []struct {
name string
subjects Subjects
want bool
}{
{name: "same provider", subjects: Subjects{UserPath: "/team", Provider: "openai-prod"}, want: true},
{name: "case-insensitive", subjects: Subjects{UserPath: "/", Provider: "OpenAI-Prod"}, want: true},
{name: "other provider", subjects: Subjects{UserPath: "/", Provider: "openai-free"}, want: false},
{name: "route unknown", subjects: Subjects{UserPath: "/"}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, b.appliesTo(tt.subjects))
})
}
}