Ctrl K
ComunidadGuías…

internal/budget/types_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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))
		})
	}
}