Ctrl K
ComunidadGuías…

internal/budget/service_test.go

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

Archivo original ↗Preguntar a KiBot
package budget

import (
	"context"
	"errors"
	"testing"
	"time"

	"github.com/enterpilot/gomodel/config"
	"github.com/enterpilot/gomodel/internal/core"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

type fakeStore struct {
	budgets  []Budget
	settings Settings
	listErr  error
	sum      func(SpendWindow) (float64, bool, error)

	sumCalls        int
	lastWindows     []SpendWindow
	lastResetAt     time.Time
	replaceCalls    int
	replacedBudgets []Budget
}

func (s *fakeStore) ListBudgets(context.Context) ([]Budget, error) {
	if s.listErr != nil {
		return nil, s.listErr
	}
	return append([]Budget(nil), s.budgets...), nil
}

func (s *fakeStore) UpsertBudgets(context.Context, []Budget) error {
	return nil
}

func (s *fakeStore) DeleteBudget(context.Context, Scope, string, int64) error {
	return nil
}

func (s *fakeStore) ReplaceConfigBudgets(_ context.Context, budgets []Budget) error {
	s.replaceCalls++
	s.replacedBudgets = append([]Budget(nil), budgets...)
	return nil
}

func (s *fakeStore) GetSettings(context.Context) (Settings, error) {
	if s.settings == (Settings{}) {
		return DefaultSettings(), nil
	}
	return s.settings, nil
}

func (s *fakeStore) SaveSettings(_ context.Context, settings Settings) (Settings, error) {
	s.settings = settings
	return settings, nil
}

func (s *fakeStore) ResetBudget(_ context.Context, _ Scope, _ string, _ int64, at time.Time) error {
	s.lastResetAt = at
	return nil
}

func (s *fakeStore) ResetAllBudgets(_ context.Context, at time.Time) error {
	s.lastResetAt = at
	return nil
}

func (s *fakeStore) SumSpend(_ context.Context, windows []SpendWindow) ([]Spend, error) {
	s.sumCalls++
	s.lastWindows = append([]SpendWindow(nil), windows...)
	spends := make([]Spend, len(windows))
	for i, window := range windows {
		if s.sum == nil {
			continue
		}
		total, hasUsage, err := s.sum(window)
		if err != nil {
			return nil, err
		}
		spends[i] = Spend{Total: total, HasUsage: hasUsage}
	}
	return spends, nil
}

func (s *fakeStore) Close() error {
	return nil
}

// allCapabilities enables every optional budget behavior.
var allCapabilities = capabilities{quotaTemplates: true, billedBudgets: true}

// path builds the Subjects of a request with no labels.
func path(userPath string) Subjects {
	return Subjects{UserPath: userPath}
}

func TestServiceUnavailableOperationsReturnErrors(t *testing.T) {
	ctx := context.Background()
	service := &Service{}
	var nilService *Service
	now := time.Date(2026, time.April, 25, 12, 0, 0, 0, time.UTC)

	checks := []struct {
		name string
		run  func() error
	}{
		{name: "nil receiver", run: func() error { return nilService.Refresh(ctx) }},
		{name: "refresh", run: func() error { return service.Refresh(ctx) }},
		{name: "upsert", run: func() error {
			return service.UpsertBudgets(ctx, []Budget{{Subject: "/", PeriodSeconds: PeriodDailySeconds, Amount: 1}})
		}},
		{name: "delete", run: func() error {
			return service.DeleteBudget(ctx, ScopeUserPath, "/", PeriodDailySeconds)
		}},
		{name: "replace config", run: func() error { return service.ReplaceConfigBudgets(ctx, nil) }},
		{name: "save settings", run: func() error {
			_, err := service.SaveSettings(ctx, DefaultSettings())
			return err
		}},
		{name: "statuses", run: func() error {
			_, err := service.Statuses(ctx, now)
			return err
		}},
		{name: "reset one", run: func() error {
			return service.ResetBudget(ctx, ScopeUserPath, "/", PeriodDailySeconds, now)
		}},
		{name: "reset all", run: func() error { return service.ResetAll(ctx, now) }},
		{name: "check", run: func() error { return service.Check(ctx, path("/"), now) }},
		{name: "check with results", run: func() error {
			_, err := service.CheckWithResults(ctx, path("/"), now)
			return err
		}},
		{name: "statuses for subjects", run: func() error {
			_, err := service.StatusesFor(ctx, path("/"), now)
			return err
		}},
	}

	for _, tt := range checks {
		t.Run(tt.name, func(t *testing.T) {
			err := tt.run()
			require.ErrorIs(t, err, ErrUnavailable)
		})
	}
}

func TestServiceRejectsQuotaTemplatesWhenDisabled(t *testing.T) {
	template := Budget{
		Scope: ScopeUserPath, Subject: "/customers", PerChild: true,
		PeriodSeconds: PeriodDailySeconds, Amount: 10,
	}
	_, err := NewService(context.Background(), &fakeStore{budgets: []Budget{template}}, WithQuotaTemplates(false))
	require.ErrorIs(t, err, ErrQuotaTemplatesUnavailable)

	store := &fakeStore{}
	service, err := NewService(context.Background(), store, WithQuotaTemplates(false))
	require.NoError(t, err)
	err = service.UpsertBudgets(context.Background(), []Budget{template})
	require.ErrorIs(t, err, ErrQuotaTemplatesUnavailable)
	err = service.ReplaceConfigBudgets(context.Background(), []Budget{template})
	require.ErrorIs(t, err, ErrQuotaTemplatesUnavailable)
	require.Equal(t, 0, store.replaceCalls)
}

func TestServiceSaveSettingsReturnsSavedSnapshotWhenRefreshFails(t *testing.T) {
	ctx := context.Background()
	store := &fakeStore{}
	service, err := NewService(ctx, store)
	require.NoError(t, err)

	store.listErr = errors.New("refresh failed")

	want := DefaultSettings()
	want.DailyResetHour = 7
	saved, err := service.SaveSettings(ctx, want)

	require.ErrorContains(t, err, "refresh budget service after saving settings")
	require.Equal(t, want.DailyResetHour, saved.DailyResetHour, "saved settings = %+v, want persisted snapshot %+v", saved, want)
}

func TestServiceRefreshSortsBudgetsByScopeSubjectThenLongestPeriod(t *testing.T) {
	ctx := context.Background()
	store := &fakeStore{
		budgets: []Budget{
			{Scope: ScopeLabel, Subject: "prod", PeriodSeconds: PeriodDailySeconds, Amount: 10},
			{Scope: ScopeUserPath, Subject: "/team/beta", PeriodSeconds: PeriodDailySeconds, Amount: 10},
			{Scope: ScopeUserPath, Subject: "/team/alpha", PeriodSeconds: PeriodDailySeconds, Amount: 10},
			{Scope: ScopeUserPath, Subject: "/team/alpha", PeriodSeconds: PeriodMonthlySeconds, Amount: 100},
			{Scope: ScopeUserPath, Subject: "/team/alpha", PeriodSeconds: PeriodWeeklySeconds, Amount: 50},
		},
	}
	service, err := NewService(ctx, store)
	require.NoError(t, err)

	got := service.Budgets()
	want := []Budget{
		{Scope: ScopeLabel, Subject: "prod", PeriodSeconds: PeriodDailySeconds},
		{Scope: ScopeUserPath, Subject: "/team/alpha", PeriodSeconds: PeriodMonthlySeconds},
		{Scope: ScopeUserPath, Subject: "/team/alpha", PeriodSeconds: PeriodWeeklySeconds},
		{Scope: ScopeUserPath, Subject: "/team/alpha", PeriodSeconds: PeriodDailySeconds},
		{Scope: ScopeUserPath, Subject: "/team/beta", PeriodSeconds: PeriodDailySeconds},
	}
	require.Len(t, got, len(want))

	for i := range want {
		require.Equal(t, want[i].Scope, got[i].Scope, "budget[%d]", i)
		require.Equal(t, want[i].Subject, got[i].Subject, "budget[%d]", i)
		require.Equal(t, want[i].PeriodSeconds, got[i].PeriodSeconds, "budget[%d]", i)
	}
}

func TestSeedConfiguredBudgetsReplacesEmptyConfigSet(t *testing.T) {
	ctx := context.Background()
	store := &fakeStore{}
	err := seedConfiguredBudgets(ctx, store, config.BudgetsConfig{}, allCapabilities)
	require.NoError(t, err)
	require.Equal(t, 1, store.replaceCalls)
	require.Empty(t, store.replacedBudgets)
}

func TestSeedConfiguredBudgetsSeedsBothScopes(t *testing.T) {
	ctx := context.Background()
	store := &fakeStore{}
	err := seedConfiguredBudgets(ctx, store, config.BudgetsConfig{
		UserPaths: []config.BudgetUserPathConfig{
			{Path: "team", PerChild: true, Limits: []config.BudgetLimitConfig{{Period: "daily", Amount: 10}}},
		},
		Labels: []config.BudgetLabelConfig{
			{Label: "Mobile-App-iOS", Limits: []config.BudgetLimitConfig{{Period: "monthly", Amount: 500}}},
		},
	}, allCapabilities)
	require.NoError(t, err)

	want := []Budget{
		{Scope: ScopeUserPath, Subject: "/team", PerChild: true, PeriodSeconds: PeriodDailySeconds, Amount: 10, Source: SourceConfig},
		{Scope: ScopeLabel, Subject: "Mobile-App-iOS", PeriodSeconds: PeriodMonthlySeconds, Amount: 500, Source: SourceConfig},
	}
	require.Len(t, store.replacedBudgets, len(want))

	for i, budget := range want {
		got := store.replacedBudgets[i]
		require.Equal(t, budget.Scope, got.Scope, "replaced budget[%d]", i)
		require.Equal(t, budget.Subject, got.Subject, "replaced budget[%d]", i)
		require.Equal(t, budget.PerChild, got.PerChild, "replaced budget[%d]", i)
		require.Equal(t, budget.PeriodSeconds, got.PeriodSeconds, "replaced budget[%d]", i)
		require.Equal(t, budget.Amount, got.Amount, "replaced budget[%d]", i)
		require.Equal(t, budget.Source, got.Source, "replaced budget[%d]", i)
	}
}

func TestSeedConfiguredBudgetsRejectsInvalidPeriodBeforeReplacing(t *testing.T) {
	ctx := context.Background()
	store := &fakeStore{}
	err := seedConfiguredBudgets(ctx, store, config.BudgetsConfig{
		UserPaths: []config.BudgetUserPathConfig{
			{
				Path: "/team",
				Limits: []config.BudgetLimitConfig{
					{Period: "fortnightly", Amount: 10},
				},
			},
		},
	}, allCapabilities)

	require.ErrorContains(t, err, `invalid budget period for user_path "/team" limit 0: "fortnightly"`)
	require.Equal(t, 0, store.replaceCalls)
}

func TestServiceCheckRejectsExceededBudgetForMatchingUserPath(t *testing.T) {
	ctx := context.Background()
	store := &fakeStore{
		budgets: []Budget{
			{Scope: ScopeUserPath, Subject: "/team", PeriodSeconds: PeriodDailySeconds, Amount: 10},
		},
		sum: func(window SpendWindow) (float64, bool, error) {
			require.Equal(t, "/team", window.Subject)

			return 10, true, nil
		},
	}
	service, err := NewService(ctx, store)
	require.NoError(t, err)

	err = service.Check(ctx, path("/team/app"), time.Date(2026, time.April, 25, 12, 0, 0, 0, time.UTC))
	var exceeded *ExceededError
	require.ErrorAs(t, err, &exceeded)
	got := exceeded.Result.Budget.Subject
	require.Equal(t, "/team", got)
}

func TestServicePerChildBudgetUsesDirectChildSpendPartition(t *testing.T) {
	ctx := context.Background()
	store := &fakeStore{
		budgets: []Budget{{
			Scope: ScopeUserPath, Subject: "/users", PerChild: true,
			PeriodSeconds: PeriodDailySeconds, Amount: 10,
		}},
		sum: func(window SpendWindow) (float64, bool, error) {
			switch window.Subject {
			case "/users/alice":
				return 10, true, nil
			case "/users/bob":
				return 2, true, nil
			default:
				t.Fatalf("unexpected spend partition %q", window.Subject)
				return 0, false, nil
			}
		},
	}
	service, err := NewService(ctx, store)
	require.NoError(t, err)

	now := time.Date(2026, time.April, 25, 12, 0, 0, 0, time.UTC)

	var exceeded *ExceededError
	err = service.Check(ctx, path("/users/alice/app"), now)
	require.ErrorAs(t, err, &exceeded)
	require.Equal(t, "/users", exceeded.Result.Budget.Subject)
	require.Equal(t, "/users/alice", exceeded.Result.Budget.EffectiveSubject, "resolved alice budget = %+v", exceeded.Result.Budget)
	err = service.Check(ctx, path("/users/bob/app"), now)
	require.NoError(t, err)

	results, err := service.StatusesFor(ctx, path("/users"), now)
	require.NoError(t, err)
	require.Empty(t, results)
}

func TestServiceGlobalPerChildBudgetStatusDoesNotInventAggregate(t *testing.T) {
	store := &fakeStore{budgets: []Budget{{
		Scope: ScopeUserPath, Subject: "/users", PerChild: true,
		PeriodSeconds: PeriodDailySeconds, Amount: 10,
	}}}
	service, err := NewService(context.Background(), store)
	require.NoError(t, err)

	results, err := service.Statuses(context.Background(), time.Date(2026, time.April, 25, 12, 0, 0, 0, time.UTC))
	require.NoError(t, err)
	require.Len(t, results, 1)
	require.False(t, results[0].HasUsage)
	require.Equal(t, float64(10), results[0].Remaining)
	require.Equal(t, 0, store.sumCalls)
}

func TestServiceCheckRejectsExceededLabelBudget(t *testing.T) {
	ctx := context.Background()
	store := &fakeStore{
		budgets: []Budget{
			{Scope: ScopeLabel, Subject: "iOS", PeriodSeconds: PeriodMonthlySeconds, Amount: 100},
		},
		sum: func(window SpendWindow) (float64, bool, error) {
			require.Equal(t, ScopeLabel, window.Scope)
			require.Equal(t, "iOS", window.Subject)

			return 120, true, nil
		},
	}
	service, err := NewService(ctx, store)
	require.NoError(t, err)

	now := time.Date(2026, time.April, 25, 12, 0, 0, 0, time.UTC)

	subjects := Subjects{UserPath: "/team", Labels: []string{"android", "iOS"}}
	var exceeded *ExceededError
	err = service.Check(ctx, subjects, now)
	require.ErrorAs(t, err, &exceeded)
	got := exceeded.Error()
	require.Contains(t, got, "label iOS")
	// The label is matched verbatim, so a different casing is a different budget.
	err = service.Check(ctx, Subjects{UserPath: "/team", Labels: []string{"ios"}}, now)
	require.NoError(t, err)
}

func TestServiceCheckEvaluatesEveryMatchingBudgetInOneStoreCall(t *testing.T) {
	ctx := context.Background()
	store := &fakeStore{
		budgets: []Budget{
			{Scope: ScopeUserPath, Subject: "/", PeriodSeconds: PeriodMonthlySeconds, Amount: 1000},
			{Scope: ScopeUserPath, Subject: "/team", PeriodSeconds: PeriodDailySeconds, Amount: 10},
			{Scope: ScopeUserPath, Subject: "/other", PeriodSeconds: PeriodDailySeconds, Amount: 10},
			{Scope: ScopeLabel, Subject: "prod", PeriodSeconds: PeriodDailySeconds, Amount: 50},
			{Scope: ScopeLabel, Subject: "iOS", PeriodSeconds: PeriodDailySeconds, Amount: 50},
			{Scope: ScopeLabel, Subject: "staging", PeriodSeconds: PeriodDailySeconds, Amount: 50},
		},
	}
	service, err := NewService(ctx, store)
	require.NoError(t, err)

	subjects := Subjects{UserPath: "/team/app", Labels: []string{"prod", "iOS"}}
	results, err := service.CheckWithResults(ctx, subjects, time.Date(2026, time.April, 25, 12, 0, 0, 0, time.UTC))
	require.NoError(t, err)
	require.Len(t, results, 4)
	require.Equal(t, 1, store.sumCalls)
	require.Len(t, store.lastWindows, 4)
}

func TestServiceStatusesForReportsAllMatchingBudgetsWithoutEnforcing(t *testing.T) {
	ctx := context.Background()
	store := &fakeStore{
		budgets: []Budget{
			{Scope: ScopeUserPath, Subject: "/team", PeriodSeconds: PeriodDailySeconds, Amount: 10},
			{Scope: ScopeUserPath, Subject: "/team", PeriodSeconds: PeriodMonthlySeconds, Amount: 100},
			{Scope: ScopeUserPath, Subject: "/other", PeriodSeconds: PeriodDailySeconds, Amount: 5},
		},
		sum: func(window SpendWindow) (float64, bool, error) {
			// The daily budget is exceeded; the monthly one is not.
			if window.End.Sub(window.Start) <= 24*time.Hour {
				return 12, true, nil
			}
			return 42, true, nil
		},
	}
	service, err := NewService(ctx, store)
	require.NoError(t, err)

	results, err := service.StatusesFor(ctx, path("/team/app"), time.Date(2026, time.April, 25, 12, 0, 0, 0, time.UTC))
	require.NoError(t, err)
	require.Len(t, results, 2)

	byPeriod := map[int64]CheckResult{}
	for _, result := range results {
		require.Equal(t, "/team", result.Budget.Subject)

		byPeriod[result.Budget.PeriodSeconds] = result
	}
	got := byPeriod[PeriodDailySeconds].Spent
	require.Equal(t, float64(12), got)
	got = byPeriod[PeriodMonthlySeconds].Remaining
	require.Equal(t, float64(58), got)
}

func TestServiceStatusesForErrorPaths(t *testing.T) {
	ctx := context.Background()
	now := time.Date(2026, time.April, 25, 12, 0, 0, 0, time.UTC)
	store := &fakeStore{
		budgets: []Budget{{Scope: ScopeUserPath, Subject: "/team", PeriodSeconds: PeriodDailySeconds, Amount: 10}},
		sum: func(SpendWindow) (float64, bool, error) {
			return 0, false, errors.New("store down")
		},
	}
	service, err := NewService(ctx, store)
	require.NoError(t, err)
	_, err = service.StatusesFor(ctx, path("/te:am"), now)
	require.Error(t, err)

	results, err := service.StatusesFor(ctx, path("/team"), now)
	require.ErrorContains(t, err, "store down")
	require.Empty(t, results)
}

func TestServiceCheckBudgetAmountBoundary(t *testing.T) {
	ctx := context.Background()
	now := time.Date(2026, time.April, 25, 12, 0, 0, 0, time.UTC)

	tests := []struct {
		name      string
		spent     float64
		wantError bool
	}{
		{name: "below amount passes", spent: 9.99},
		{name: "equal amount blocks", spent: 10, wantError: true},
		{name: "above amount blocks", spent: 10.01, wantError: true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			store := &fakeStore{
				budgets: []Budget{
					{Scope: ScopeUserPath, Subject: "/team", PeriodSeconds: PeriodDailySeconds, Amount: 10},
				},
				sum: func(SpendWindow) (float64, bool, error) {
					return tt.spent, true, nil
				},
			}
			service, err := NewService(ctx, store)
			require.NoError(t, err)

			err = service.Check(ctx, path("/team/app"), now)
			var exceeded *ExceededError
			if tt.wantError {
				require.ErrorAs(t, err, &exceeded)
				require.Equal(t, tt.spent, exceeded.Result.Spent)
				return
			}
			require.NoError(t, err)
		})
	}
}

func TestServiceCheckDoesNotEnforceBudgetWithoutUsage(t *testing.T) {
	ctx := context.Background()
	now := time.Date(2026, time.April, 25, 12, 0, 0, 0, time.UTC)
	store := &fakeStore{
		budgets: []Budget{
			{Scope: ScopeUserPath, Subject: "/team", PeriodSeconds: PeriodDailySeconds, Amount: 10},
		},
		sum: func(window SpendWindow) (float64, bool, error) {
			require.Equal(t, "/team", window.Subject)

			return 100, false, nil
		},
	}
	service, err := NewService(ctx, store)
	require.NoError(t, err)
	err = service.Check(ctx, path("/team"), now)
	require.NoError(t, err)

	results, err := service.CheckWithResults(ctx, path("/team"), now)
	require.NoError(t, err)
	require.Len(t, results, 1)
	require.False(t, results[0].HasUsage)
}

func TestServiceCheckIgnoresNonMatchingSubjects(t *testing.T) {
	ctx := context.Background()
	now := time.Date(2026, time.April, 25, 12, 0, 0, 0, time.UTC)
	store := &fakeStore{
		budgets: []Budget{
			{Scope: ScopeUserPath, Subject: "/team", PeriodSeconds: PeriodDailySeconds, Amount: 10},
			{Scope: ScopeLabel, Subject: "prod", PeriodSeconds: PeriodDailySeconds, Amount: 10},
		},
	}
	service, err := NewService(ctx, store)
	require.NoError(t, err)

	// A sibling path, and a request whose labels do not include "prod".
	results, err := service.CheckWithResults(ctx, Subjects{UserPath: "/team-alpha", Labels: []string{"staging"}}, now)
	require.NoError(t, err)
	require.Empty(t, results)
	require.Equal(t, 0, store.sumCalls)
}

func TestServiceCheckStartsAtManualResetWhenNewerThanPeriodStart(t *testing.T) {
	ctx := context.Background()
	resetAt := time.Date(2026, time.April, 25, 9, 0, 0, 0, time.UTC)
	store := &fakeStore{
		budgets: []Budget{
			{Scope: ScopeUserPath, Subject: "/team", PeriodSeconds: PeriodDailySeconds, Amount: 10, LastResetAt: &resetAt},
		},
	}
	service, err := NewService(ctx, store)
	require.NoError(t, err)

	_, err = service.CheckWithResults(ctx, path("/team"), time.Date(2026, time.April, 25, 12, 0, 0, 0, time.UTC))
	require.NoError(t, err)
	require.True(t, store.lastWindows[0].Start.Equal(resetAt), "sum start = %s, want reset time %s", store.lastWindows[0].Start, resetAt)
}

func TestServiceCheckIgnoresManualResetOlderThanPeriodStart(t *testing.T) {
	ctx := context.Background()
	resetAt := time.Date(2026, time.April, 24, 9, 0, 0, 0, time.UTC)
	now := time.Date(2026, time.April, 25, 12, 0, 0, 0, time.UTC)
	store := &fakeStore{
		budgets: []Budget{
			{Scope: ScopeUserPath, Subject: "/team", PeriodSeconds: PeriodDailySeconds, Amount: 10, LastResetAt: &resetAt},
		},
	}
	service, err := NewService(ctx, store)
	require.NoError(t, err)

	_, err = service.CheckWithResults(ctx, path("/team"), now)
	require.NoError(t, err)

	want := time.Date(2026, time.April, 25, 0, 0, 0, 0, time.UTC)
	require.True(t, store.lastWindows[0].Start.Equal(want), "sum start = %s, want period start %s", store.lastWindows[0].Start, want)
}

func TestServiceCheckRejectsExceededProviderBudget(t *testing.T) {
	ctx := context.Background()
	store := &fakeStore{
		budgets: []Budget{
			{Scope: ScopeProvider, Subject: "openai-prod", PeriodSeconds: PeriodDailySeconds, Amount: 100},
		},
		sum: func(window SpendWindow) (float64, bool, error) {
			require.Equal(t, ScopeProvider, window.Scope)
			require.Equal(t, "openai-prod", window.Subject)

			return 120, true, nil
		},
	}
	service, err := NewService(ctx, store)
	require.NoError(t, err)

	now := time.Date(2026, time.April, 25, 12, 0, 0, 0, time.UTC)

	var exceeded *ExceededError
	err = service.Check(ctx, Subjects{UserPath: "/team", Provider: "openai-prod"}, now)
	require.ErrorAs(t, err, &exceeded)
	require.Contains(t, exceeded.Error(), "provider openai-prod")

	// Traffic routed elsewhere, or not routed yet, is outside the budget.
	err = service.Check(ctx, Subjects{UserPath: "/team", Provider: "openai-free"}, now)
	require.NoError(t, err)
	err = service.Check(ctx, Subjects{UserPath: "/team"}, now)
	require.NoError(t, err)
}

// Failover can relieve an exhausted provider budget but not a consumer one, so
// when both are spent the consumer breach is the one reported.
func TestServiceCheckReportsConsumerBreachBeforeProviderBreach(t *testing.T) {
	ctx := context.Background()
	store := &fakeStore{
		budgets: []Budget{
			{Scope: ScopeProvider, Subject: "openai-prod", PeriodSeconds: PeriodDailySeconds, Amount: 10},
			{Scope: ScopeUserPath, Subject: "/team", PeriodSeconds: PeriodDailySeconds, Amount: 10},
		},
		sum: func(SpendWindow) (float64, bool, error) {
			return 20, true, nil
		},
	}
	service, err := NewService(ctx, store)
	require.NoError(t, err)

	var exceeded *ExceededError
	err = service.Check(ctx, Subjects{UserPath: "/team", Provider: "openai-prod"}, time.Date(2026, time.April, 25, 12, 0, 0, 0, time.UTC))
	require.ErrorAs(t, err, &exceeded)
	require.Equal(t, ScopeUserPath, exceeded.Result.Budget.Scope)
}

func TestServiceProviderWithinBudget(t *testing.T) {
	ctx := context.Background()
	spent := map[string]float64{"openai-prod": 120, "openai-free": 5}
	store := &fakeStore{
		budgets: []Budget{
			{Scope: ScopeProvider, Subject: "openai-prod", PeriodSeconds: PeriodDailySeconds, Amount: 100},
			{Scope: ScopeProvider, Subject: "openai-free", PeriodSeconds: PeriodDailySeconds, Amount: 100},
			// Consumer budgets never gate a route.
			{Scope: ScopeUserPath, Subject: "/", PeriodSeconds: PeriodDailySeconds, Amount: 1},
		},
		sum: func(window SpendWindow) (float64, bool, error) {
			require.Equal(t, ScopeProvider, window.Scope)
			return spent[window.Subject], true, nil
		},
	}
	service, err := NewService(ctx, store)
	require.NoError(t, err)

	assert.False(t, service.ProviderWithinBudget(ctx, "OpenAI-Prod"))
	assert.True(t, service.ProviderWithinBudget(ctx, "openai-free"))
	assert.True(t, service.ProviderWithinBudget(ctx, "anthropic"))

	store.sum = func(SpendWindow) (float64, bool, error) {
		return 0, false, errors.New("store down")
	}
	assert.False(t, service.ProviderWithinBudget(ctx, "openai-free"), "a failed spend lookup must not admit the target")
}

// A request routed to a billable: false provider is gated by provider budgets
// only: its cost is not owed by the caller. Any other route, including one
// whose provider is not known yet, keeps every budget.
func TestServiceCheckSkipsConsumerBudgetsForNonBillableProvider(t *testing.T) {
	ctx := context.Background()
	store := &fakeStore{
		budgets: []Budget{
			{Scope: ScopeUserPath, Subject: "/", PeriodSeconds: PeriodDailySeconds, Amount: 1},
			{Scope: ScopeLabel, Subject: "prod", PeriodSeconds: PeriodDailySeconds, Amount: 1},
			{Scope: ScopeProvider, Subject: "byok-openai", PeriodSeconds: PeriodDailySeconds, Amount: 100},
		},
		sum: func(SpendWindow) (float64, bool, error) { return 50, true, nil },
	}
	service, err := NewService(ctx, store)
	require.NoError(t, err)
	service.SetProviderBillable(func(name string) bool { return name != "byok-openai" })
	now := time.Date(2026, time.April, 25, 12, 0, 0, 0, time.UTC)

	require.NoError(t, service.Check(ctx, Subjects{UserPath: "/team", Labels: []string{"prod"}, Provider: "byok-openai"}, now))
	require.Len(t, store.lastWindows, 1)
	assert.Equal(t, ScopeProvider, store.lastWindows[0].Scope)

	var exceeded *ExceededError
	err = service.Check(ctx, Subjects{UserPath: "/team", Labels: []string{"prod"}, Provider: "openai-prod"}, now)
	require.ErrorAs(t, err, &exceeded)
	assert.NotEqual(t, ScopeProvider, exceeded.Result.Budget.Scope, "a billable provider is gated by consumer budgets")

	err = service.Check(ctx, Subjects{UserPath: "/team", Labels: []string{"prod"}}, now)
	require.ErrorAs(t, err, &exceeded, "an unknown route is billable")

	store.sum = func(SpendWindow) (float64, bool, error) { return 200, true, nil }
	err = service.Check(ctx, Subjects{UserPath: "/team", Provider: "byok-openai"}, now)
	require.ErrorAs(t, err, &exceeded, "provider budgets still cap a non-billable provider")
	assert.Equal(t, ScopeProvider, exceeded.Result.Budget.Scope)
}

// A response-cache hit is checked against billed consumer budgets before the
// provider is called; a hit of a non-billable provider owes nothing and is
// admitted like the request itself.
func TestServiceCheckCacheHitSkipsNonBillableProvider(t *testing.T) {
	ctx := context.Background()
	store := &fakeStore{
		budgets: []Budget{
			{Scope: ScopeUserPath, Subject: "/", CostBasis: CostBasisBilled, PeriodSeconds: PeriodDailySeconds, Amount: 1},
		},
		sum: func(SpendWindow) (float64, bool, error) { return 50, true, nil },
	}
	service, err := NewService(ctx, store, WithBilledBudgets(true), WithBilledCacheHits(true))
	require.NoError(t, err)
	service.SetProviderBillable(func(name string) bool { return name != "byok-openai" })
	now := time.Date(2026, time.April, 25, 12, 0, 0, 0, time.UTC)

	var exceeded *ExceededError
	err = service.CheckCacheHit(ctx, Subjects{UserPath: "/team", Provider: "openai-prod"}, now)
	require.ErrorAs(t, err, &exceeded)
	err = service.CheckCacheHit(ctx, Subjects{UserPath: "/team"}, now)
	require.ErrorAs(t, err, &exceeded, "an unknown route is billable")

	store.sumCalls = 0
	require.NoError(t, service.CheckCacheHit(ctx, Subjects{UserPath: "/team", Provider: "byok-openai"}, now))
	assert.Zero(t, store.sumCalls)
}

func TestServiceFailoverWithinBudget(t *testing.T) {
	spent := map[Scope]float64{ScopeProvider: 5, ScopeUserPath: 20, ScopeLabel: 0}
	store := &fakeStore{
		budgets: []Budget{
			{Scope: ScopeProvider, Subject: "openai-prod", PeriodSeconds: PeriodDailySeconds, Amount: 100},
			{Scope: ScopeUserPath, Subject: "/team", PeriodSeconds: PeriodDailySeconds, Amount: 10},
			{Scope: ScopeLabel, Subject: "prod", PeriodSeconds: PeriodDailySeconds, Amount: 10},
		},
		sum: func(window SpendWindow) (float64, bool, error) { return spent[window.Scope], true, nil },
	}
	ctx := core.WithEffectiveUserPath(context.Background(), "/team/app")
	ctx = core.WithRequestLabels(ctx, []string{"prod"})
	service, err := NewService(ctx, store)
	require.NoError(t, err)
	service.SetProviderBillable(func(name string) bool { return name != "byok" && name != "byok-backup" })

	assert.False(t, service.FailoverWithinBudget(ctx, "byok", "openai-prod"), "the user-path budget admission skipped is spent")
	assert.True(t, service.FailoverWithinBudget(ctx, "byok", "byok-backup"), "another non-billable target owes nothing")
	assert.True(t, service.FailoverWithinBudget(ctx, "anthropic", "openai-prod"), "consumer budgets passed at admission after a billable primary")

	spent[ScopeUserPath] = 2
	assert.True(t, service.FailoverWithinBudget(ctx, "byok", "openai-prod"))

	spent[ScopeProvider] = 200
	assert.False(t, service.FailoverWithinBudget(ctx, "byok", "openai-prod"), "the target's provider budget still gates")
	assert.False(t, service.FailoverWithinBudget(ctx, "anthropic", "openai-prod"))

	spent[ScopeProvider] = 5
	store.sum = func(SpendWindow) (float64, bool, error) { return 0, false, errors.New("store down") }
	assert.False(t, service.FailoverWithinBudget(ctx, "byok", "openai-prod"), "a failed spend lookup must not admit the target")
}