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)) }) } }