Ctrl K
ComunidadGuías…

internal/budget/store_sql_test.go

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

Archivo original ↗Preguntar a KiBot
package budget

import (
	"context"
	"crypto/sha256"
	"database/sql"
	"encoding/hex"
	"testing"
	"time"

	_ "modernc.org/sqlite"

	"github.com/enterpilot/gomodel/internal/storage/sqlx"
	"github.com/enterpilot/gomodel/internal/storage/sqlx/sqlxtest"
	"github.com/enterpilot/gomodel/internal/usage"
	"github.com/stretchr/testify/require"
)

func runSQLStoreTest(t *testing.T, body func(t *testing.T, store *SQLStore)) {
	t.Helper()
	sqlxtest.Run(t, func(t *testing.T, db sqlx.DB) {
		store, err := NewSQLStore(context.Background(), db)
		require.NoError(t, err)

		body(t, store)
	})
}

func TestSQLStoreRoundTripsPerChild(t *testing.T) {
	runSQLStoreTest(t, func(t *testing.T, store *SQLStore) {
		ctx := context.Background()
		err := store.UpsertBudgets(ctx, []Budget{{
			Scope: ScopeUserPath, Subject: "/users", PerChild: true,
			PeriodSeconds: PeriodDailySeconds, Amount: 10, Source: SourceManual,
		}})
		require.NoError(t, err)

		budgets, err := store.ListBudgets(ctx)
		require.NoError(t, err)
		require.Len(t, budgets, 1)
		require.True(t, budgets[0].PerChild)
	})
}

func TestSQLStoreMigratesPrePerChildRows(t *testing.T) {
	sqlxtest.Run(t, func(t *testing.T, db sqlx.DB) {
		ctx := context.Background()
		legacySchema := `CREATE TABLE budgets (
			scope TEXT NOT NULL DEFAULT 'user_path',
			subject TEXT NOT NULL,
			period_seconds ` + sqlx.TypeInt64 + ` NOT NULL,
			amount ` + sqlx.TypeFloat + ` NOT NULL,
			source TEXT NOT NULL DEFAULT '',
			last_reset_at ` + sqlx.TypeInt64 + `,
			created_at ` + sqlx.TypeInt64 + ` NOT NULL,
			updated_at ` + sqlx.TypeInt64 + ` NOT NULL,
			PRIMARY KEY (scope, subject, period_seconds)
		)`
		err := db.Schema(ctx, legacySchema)
		require.NoError(t, err)
		_, err = db.Exec(ctx, `
			INSERT INTO budgets (scope, subject, period_seconds, amount, source, created_at, updated_at)
			VALUES (?, ?, ?, ?, ?, ?, ?)
		`, ScopeUserPath, "/legacy", PeriodDailySeconds, 10.0, SourceManual, int64(1), int64(1))
		require.NoError(t, err)

		store, err := NewSQLStore(ctx, db)
		require.NoError(t, err)

		budgets, err := store.ListBudgets(ctx)
		require.NoError(t, err)
		require.Len(t, budgets, 1)
		require.False(t, budgets[0].PerChild)
	})
}

func TestSQLStoreReplaceConfigBudgetsRemovesStaleConfigRowsOnly(t *testing.T) {
	runSQLStoreTest(t, func(t *testing.T, store *SQLStore) {
		ctx := context.Background()
		resetAt := time.Date(2026, time.April, 25, 9, 0, 0, 0, time.UTC)
		err := store.UpsertBudgets(ctx, []Budget{
			{Scope: ScopeUserPath, Subject: "/team", PerChild: true, PeriodSeconds: PeriodDailySeconds, Amount: 10, Source: SourceConfig},
			{Scope: ScopeUserPath, Subject: "/team", PeriodSeconds: PeriodWeeklySeconds, Amount: 50, Source: SourceConfig, LastResetAt: &resetAt},
			{Scope: ScopeUserPath, Subject: "/manual", PeriodSeconds: PeriodDailySeconds, Amount: 5, Source: SourceManual},
			{Scope: ScopeLabel, Subject: "prod", PeriodSeconds: PeriodDailySeconds, Amount: 20, Source: SourceConfig},
		})
		require.NoError(t, err)
		err = store.ReplaceConfigBudgets(ctx, []Budget{
			{Scope: ScopeUserPath, Subject: "/team", PeriodSeconds: PeriodWeeklySeconds, Amount: 75},
		})
		require.NoError(t, err)

		got, err := store.ListBudgets(ctx)
		require.NoError(t, err)
		require.Len(t, got, 2)

		byKey := make(map[string]Budget, len(got))
		for _, budget := range got {
			byKey[budgetKey(budget.Scope, budget.Subject, budget.PeriodSeconds)] = budget
		}
		_, ok := byKey[budgetKey(ScopeUserPath, "/team", PeriodDailySeconds)]
		require.False(t, ok)
		_, ok = byKey[budgetKey(ScopeLabel, "prod", PeriodDailySeconds)]
		require.False(t, ok)

		weekly := byKey[budgetKey(ScopeUserPath, "/team", PeriodWeeklySeconds)]
		require.Equal(t, float64(75), weekly.Amount)
		require.Equal(t, SourceConfig, weekly.Source)
		require.NotNil(t, weekly.LastResetAt)
		require.True(t, weekly.LastResetAt.Equal(resetAt), "weekly last_reset_at = %v, want %s", weekly.LastResetAt, resetAt)
		_, ok = byKey[budgetKey(ScopeUserPath, "/manual", PeriodDailySeconds)]
		require.True(t, ok)
	})
}

func TestSQLStoreReplaceConfigBudgetsPreservesManualCollision(t *testing.T) {
	runSQLStoreTest(t, func(t *testing.T, store *SQLStore) {
		ctx := context.Background()
		err := store.UpsertBudgets(ctx, []Budget{
			{Scope: ScopeUserPath, Subject: "/team", PeriodSeconds: PeriodDailySeconds, Amount: 10, Source: SourceManual},
		})
		require.NoError(t, err)
		err = store.ReplaceConfigBudgets(ctx, []Budget{
			{Scope: ScopeUserPath, Subject: "/team", PeriodSeconds: PeriodDailySeconds, Amount: 99},
		})
		require.NoError(t, err)

		got, err := store.ListBudgets(ctx)
		require.NoError(t, err)
		require.Len(t, got, 1)
		require.Equal(t, SourceManual, got[0].Source)
		require.Equal(t, float64(10), got[0].Amount, "manual budget = %+v, want manual amount preserved", got[0])
	})
}

// A label and a user path can spell the same subject; the scope must keep them
// apart in storage.
func TestSQLStoreScopeSeparatesIdenticalSubjects(t *testing.T) {
	runSQLStoreTest(t, func(t *testing.T, store *SQLStore) {
		ctx := context.Background()
		err := store.UpsertBudgets(ctx, []Budget{
			{Scope: ScopeUserPath, Subject: "/prod", PeriodSeconds: PeriodDailySeconds, Amount: 10},
			{Scope: ScopeLabel, Subject: "/prod", PeriodSeconds: PeriodDailySeconds, Amount: 20},
		})
		require.NoError(t, err)

		got, err := store.ListBudgets(ctx)
		require.NoError(t, err)
		require.Len(t, got, 2)
		err = store.DeleteBudget(ctx, ScopeLabel, "/prod", PeriodDailySeconds)
		require.NoError(t, err)

		got, err = store.ListBudgets(ctx)
		require.NoError(t, err)
		require.Len(t, got, 1)
		require.Equal(t, ScopeUserPath, got[0].Scope)
	})
}

// A budgets table written before scopes existed must survive the upgrade with
// its rows intact, including one predating the source and last_reset_at
// columns.
func TestSQLStoreMigratesPreScopeTable(t *testing.T) {
	sqlxtest.Run(t, func(t *testing.T, db sqlx.DB) {
		ctx := context.Background()
		err := db.Schema(ctx, `CREATE TABLE budgets (
			user_path TEXT NOT NULL,
			period_seconds `+sqlx.TypeInt64+` NOT NULL,
			amount `+sqlx.TypeFloat+` NOT NULL,
			created_at `+sqlx.TypeInt64+` NOT NULL,
			updated_at `+sqlx.TypeInt64+` NOT NULL,
			PRIMARY KEY (user_path, period_seconds)
		)`)
		require.NoError(t, err)
		_, err = db.Exec(ctx,
			`INSERT INTO budgets (user_path, period_seconds, amount, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`,
			"/team", PeriodDailySeconds, 10.0, int64(1), int64(2))
		require.NoError(t, err)

		store, err := NewSQLStore(ctx, db)
		require.NoError(t, err)

		got, err := store.ListBudgets(ctx)
		require.NoError(t, err)
		require.Len(t, got, 1)
		require.Equal(t, ScopeUserPath, got[0].Scope)
		require.Equal(t, "/team", got[0].Subject)
		require.Equal(t, float64(10), got[0].Amount, "migrated budget = %+v, want user_path /team amount 10", got[0])
		require.Nil(t, got[0].LastResetAt)
		// A label budget must be storable afterwards: the old primary key would
		// have rejected a second row with the same subject and period.
		err = store.UpsertBudgets(ctx, []Budget{
			{Scope: ScopeLabel, Subject: "/team", PeriodSeconds: PeriodDailySeconds, Amount: 1},
		})
		require.NoError(t, err)
	})
}

// TestSQLStoreSumSpendHonorsSubjectBoundaryAndCacheType covers both dialects:
// the label predicate is the one part of SumSpend written twice
// (json_each on SQLite, jsonb_exists on PostgreSQL), so testing only one of
// them would leave the other free to silently match nothing.
func TestSQLStoreSumSpendHonorsSubjectBoundaryAndCacheType(t *testing.T) {
	sqlxtest.Run(t, func(t *testing.T, db sqlx.DB) {
		usageStore, err := usage.NewSQLStore(context.Background(), db, 0)
		require.NoError(t, err)

		assertSumSpendMatchesSubjects(t, usageStore, db)
	})
}

// usageWriter is the slice of a usage store this test needs.
type usageWriter interface {
	WriteBatch(ctx context.Context, entries []*usage.UsageEntry) error
}

func assertSumSpendMatchesSubjects(t *testing.T, usageStore usageWriter, db sqlx.DB) {
	t.Helper()
	ctx := context.Background()
	store, err := NewSQLStore(ctx, db)
	require.NoError(t, err)

	now := time.Date(2026, time.April, 25, 12, 0, 0, 0, time.UTC)
	entries := []*usage.UsageEntry{
		usageEntryWithCost("team-root", "/team", "", now, 0.25, "prod"),
		usageEntryWithCost("team-child", "/team/app", "", now, 0.75, "prod", "iOS"),
		usageEntryWithCost("sibling", "/team-alpha", "", now, 5, "iOS"),
		// Differs from /team only in case: user paths are case-sensitive.
		usageEntryWithCost("upper", "/TEAM/app", "", now, 100),
		usageEntryWithCost("cached", "/team/cache", usage.CacheTypeExact, now, 10, "prod"),
		usageEntryWithCost("outside-window", "/team/app", "", now.Add(-48*time.Hour), 7, "prod"),
		usageEntryWithCost("unlabelled", "/team/plain", "", now, 0.5),
		usageEntryForProvider("openai-prod", "openai", "OpenAI-Prod", now, 2),
		usageEntryForProvider("openai-free", "openai", "openai-free", now, 4),
		// Written before provider_name existed: only the provider type is set.
		usageEntryForProvider("legacy-openai", "openai", "", now, 3),
		// Served by a billable: false provider: only its provider budget sees it.
		usageEntryNonBillable("byok", "/team/app", now, 50, "prod", "iOS"),
	}
	err = usageStore.WriteBatch(ctx, entries)
	require.NoError(t, err)

	start, end := now.Add(-time.Hour), now.Add(time.Hour)
	windows := []SpendWindow{
		{Scope: ScopeUserPath, Subject: "/team", Start: start, End: end},
		{Scope: ScopeUserPath, Subject: "/missing", Start: start, End: end},
		{Scope: ScopeUserPath, Subject: "/TEAM", Start: start, End: end},
		{Scope: ScopeLabel, Subject: "prod", Start: start, End: end},
		{Scope: ScopeLabel, Subject: "iOS", Start: start, End: end},
		{Scope: ScopeLabel, Subject: "absent", Start: start, End: end},
		// Same label, but a window that excludes every entry.
		{Scope: ScopeLabel, Subject: "prod", Start: now.Add(-72 * time.Hour), End: now.Add(-60 * time.Hour)},
		{Scope: ScopeProvider, Subject: "openai-prod", Start: start, End: end},
		{Scope: ScopeProvider, Subject: "openai", Start: start, End: end},
		{Scope: ScopeProvider, Subject: "test", Start: start, End: end},
		{Scope: ScopeProvider, Subject: "anthropic", Start: start, End: end},
		{Scope: ScopeProvider, Subject: "byok-openai", Start: start, End: end},
	}
	want := []Spend{
		{Total: 1.5, HasUsage: true},   // /team subtree, uncached, minus the sibling and /TEAM
		{},                             // no such path
		{Total: 100, HasUsage: true},   // /TEAM subtree only
		{Total: 1.0, HasUsage: true},   // prod: team-root + team-child, cached excluded
		{Total: 5.75, HasUsage: true},  // iOS: team-child + sibling
		{},                             // no such label
		{},                             // window with no entries
		{Total: 2, HasUsage: true},     // openai-prod, matched case-insensitively
		{Total: 3, HasUsage: true},     // openai: only the row without a provider name
		{Total: 106.5, HasUsage: true}, // test: every uncached in-window user-path row
		{},                             // no such provider
		{Total: 50, HasUsage: true},    // byok-openai: the non-billable row, skipped above
	}

	got, err := store.SumSpend(ctx, windows)
	require.NoError(t, err)
	require.Len(t, got, len(want))

	for i := range want {
		require.Equal(t, want[i].HasUsage, got[i].HasUsage)
		require.Equal(t, want[i].Total, got[i].Total, "spend[%d] (%s %s) = %+v, want %+v", i, windows[i].Scope, windows[i].Subject, got[i], want[i])
	}
}

// usageEntryUUID maps a readable entry name to a stable UUID.
func usageEntryUUID(name string) string {
	sum := sha256.Sum256([]byte(name))
	hex := hex.EncodeToString(sum[:16])
	return hex[0:8] + "-" + hex[8:12] + "-" + hex[12:16] + "-" + hex[16:20] + "-" + hex[20:32]
}

// The chunking that keeps a batch inside the SQLite parameter limit must not
// change the results or their order.
func TestSQLStoreSumSpendChunksLargeBatches(t *testing.T) {
	ctx := context.Background()
	db, err := sql.Open("sqlite", ":memory:")
	require.NoError(t, err)

	defer db.Close()

	wrapped, err := sqlx.NewSQLite(db)
	require.NoError(t, err)

	usageStore, err := usage.NewSQLStore(ctx, wrapped, 0)
	require.NoError(t, err)

	store, err := NewSQLStore(ctx, wrapped)
	require.NoError(t, err)

	now := time.Date(2026, time.April, 25, 12, 0, 0, 0, time.UTC)
	err = usageStore.WriteBatch(ctx, []*usage.UsageEntry{
		usageEntryWithCost("only", "/team", "", now, 3, "prod"),
	})
	require.NoError(t, err)

	start, end := now.Add(-time.Hour), now.Add(time.Hour)
	total := spendChunkSize*2 + 3
	windows := make([]SpendWindow, 0, total)
	for i := range total {
		if i == total-1 {
			windows = append(windows, SpendWindow{Scope: ScopeLabel, Subject: "prod", Start: start, End: end})
			continue
		}
		windows = append(windows, SpendWindow{Scope: ScopeLabel, Subject: "absent", Start: start, End: end})
	}

	got, err := store.SumSpend(ctx, windows)
	require.NoError(t, err)
	require.Len(t, got, total)

	for i, spend := range got[:total-1] {
		require.False(t, spend.HasUsage, "spend[%d] = %+v, want no usage", i, spend)
	}
	last := got[total-1]
	require.True(t, last.HasUsage)
	require.Equal(t, float64(3), last.Total, "last spend = %+v, want 3 with usage — chunk boundaries must preserve order", last)
}

// usageEntryForProvider builds one priced usage row routed to a provider,
// outside every user path and label the subject test budgets by.
func usageEntryForProvider(name, providerType, providerName string, ts time.Time, cost float64) *usage.UsageEntry {
	entry := usageEntryWithCost(name, "/providers", "", ts, cost)
	entry.Provider = providerType
	entry.ProviderName = providerName
	return entry
}

// usageEntryNonBillable builds one priced row served by a billable: false
// provider.
func usageEntryNonBillable(name, userPath string, ts time.Time, cost float64, labels ...string) *usage.UsageEntry {
	entry := usageEntryWithCost(name, userPath, "", ts, cost, labels...)
	entry.Provider = "openai"
	entry.ProviderName = "byok-openai"
	entry.MarkNonBillable()
	return entry
}

// usageEntryWithCost builds one priced usage row. PostgreSQL types usage.id as
// a UUID, so the readable name only labels the request and a fixed UUID keyed
// off it fills the primary key — no randomness, so runs stay reproducible.
func usageEntryWithCost(name, userPath, cacheType string, ts time.Time, cost float64, labels ...string) *usage.UsageEntry {
	inputCost := cost / 2
	outputCost := cost / 2
	totalCost := cost
	return &usage.UsageEntry{
		ID:           usageEntryUUID(name),
		RequestID:    name,
		ProviderID:   name,
		Timestamp:    ts,
		Model:        "gpt-4",
		Provider:     "test",
		ProviderName: "test",
		Endpoint:     "/v1/chat/completions",
		UserPath:     userPath,
		CacheType:    cacheType,
		Labels:       labels,
		InputTokens:  1,
		OutputTokens: 1,
		TotalTokens:  2,
		InputCost:    &inputCost,
		OutputCost:   &outputCost,
		TotalCost:    &totalCost,
	}
}