dev-pod-api-build/internal/store/usage_test.go
2026-04-16 04:16:36 +00:00

399 lines
12 KiB
Go

package store
import (
"context"
"math"
"testing"
"time"
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
)
func TestRecordPodStart(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
s.CreateUser(ctx, "alice", model.DefaultQuota())
if err := s.RecordPodStart(ctx, "alice", "main"); err != nil {
t.Fatalf("record pod start: %v", err)
}
var count int
if err := testPool.QueryRow(ctx,
`SELECT COUNT(*) FROM usage_records WHERE user_id = 'alice' AND event_type = 'pod_start'`).Scan(&count); err != nil {
t.Fatalf("query: %v", err)
}
if count != 1 {
t.Errorf("expected 1 pod_start record, got %d", count)
}
}
func TestRecordPodStop(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
s.CreateUser(ctx, "alice", model.DefaultQuota())
if err := s.RecordPodStop(ctx, "alice", "main"); err != nil {
t.Fatalf("record pod stop: %v", err)
}
var count int
if err := testPool.QueryRow(ctx,
`SELECT COUNT(*) FROM usage_records WHERE user_id = 'alice' AND event_type = 'pod_stop'`).Scan(&count); err != nil {
t.Fatalf("query: %v", err)
}
if count != 1 {
t.Errorf("expected 1 pod_stop record, got %d", count)
}
}
func TestRecordResourceSample(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
s.CreateUser(ctx, "alice", model.DefaultQuota())
if err := s.RecordResourceSample(ctx, "alice", "main", 2500, 1073741824); err != nil {
t.Fatalf("record resource sample: %v", err)
}
// Should have both cpu and mem records
var cpuCount, memCount int
testPool.QueryRow(ctx,
`SELECT COUNT(*) FROM usage_records WHERE user_id = 'alice' AND event_type = 'cpu_sample'`).Scan(&cpuCount)
testPool.QueryRow(ctx,
`SELECT COUNT(*) FROM usage_records WHERE user_id = 'alice' AND event_type = 'mem_sample'`).Scan(&memCount)
if cpuCount != 1 {
t.Errorf("expected 1 cpu_sample, got %d", cpuCount)
}
if memCount != 1 {
t.Errorf("expected 1 mem_sample, got %d", memCount)
}
// Verify CPU value
var cpuValue float64
testPool.QueryRow(ctx,
`SELECT value FROM usage_records WHERE user_id = 'alice' AND event_type = 'cpu_sample'`).Scan(&cpuValue)
if cpuValue != 2500 {
t.Errorf("expected cpu value 2500, got %f", cpuValue)
}
}
func TestRecordAIRequest(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
s.CreateUser(ctx, "alice", model.DefaultQuota())
for i := 0; i < 5; i++ {
if err := s.RecordAIRequest(ctx, "alice"); err != nil {
t.Fatalf("record ai request %d: %v", i, err)
}
}
var count int
testPool.QueryRow(ctx,
`SELECT COUNT(*) FROM usage_records WHERE user_id = 'alice' AND event_type = 'ai_request'`).Scan(&count)
if count != 5 {
t.Errorf("expected 5 ai_request records, got %d", count)
}
}
func TestGetUsage_Empty(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
s.CreateUser(ctx, "alice", model.DefaultQuota())
usage, err := s.getUsage(ctx, "alice", 2026, time.March, 500,
time.Date(2026, time.March, 15, 12, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("get usage: %v", err)
}
if usage.PodHours != 0 {
t.Errorf("expected 0 pod hours, got %f", usage.PodHours)
}
if usage.CPUHours != 0 {
t.Errorf("expected 0 cpu hours, got %f", usage.CPUHours)
}
if usage.AIRequests != 0 {
t.Errorf("expected 0 ai requests, got %d", usage.AIRequests)
}
if usage.BudgetUsedPct != 0 {
t.Errorf("expected 0 budget used, got %f", usage.BudgetUsedPct)
}
}
func TestGetUsage_PodHours_CompletedSession(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
s.CreateUser(ctx, "alice", model.DefaultQuota())
start := time.Date(2026, time.March, 15, 10, 0, 0, 0, time.UTC)
stop := time.Date(2026, time.March, 15, 12, 0, 0, 0, time.UTC) // 2 hours later
testPool.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ('alice', 'main', 'pod_start', 0, $1)`, start)
testPool.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ('alice', 'main', 'pod_stop', 0, $1)`, stop)
usage, err := s.getUsage(ctx, "alice", 2026, time.March, 500, stop)
if err != nil {
t.Fatalf("get usage: %v", err)
}
if math.Abs(usage.PodHours-2.0) > 0.01 {
t.Errorf("expected ~2.0 pod hours, got %f", usage.PodHours)
}
}
func TestGetUsage_PodHours_RunningPod(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
s.CreateUser(ctx, "alice", model.DefaultQuota())
start := time.Date(2026, time.March, 15, 10, 0, 0, 0, time.UTC)
now := time.Date(2026, time.March, 15, 13, 0, 0, 0, time.UTC) // 3 hours later, no stop
testPool.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ('alice', 'main', 'pod_start', 0, $1)`, start)
usage, err := s.getUsage(ctx, "alice", 2026, time.March, 500, now)
if err != nil {
t.Fatalf("get usage: %v", err)
}
if math.Abs(usage.PodHours-3.0) > 0.01 {
t.Errorf("expected ~3.0 pod hours (running pod), got %f", usage.PodHours)
}
}
func TestGetUsage_PodHours_MultiplePods(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
s.CreateUser(ctx, "alice", model.DefaultQuota())
// Pod 1: 2 hours
testPool.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ('alice', 'pod1', 'pod_start', 0, $1)`,
time.Date(2026, time.March, 10, 8, 0, 0, 0, time.UTC))
testPool.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ('alice', 'pod1', 'pod_stop', 0, $1)`,
time.Date(2026, time.March, 10, 10, 0, 0, 0, time.UTC))
// Pod 2: 5 hours
testPool.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ('alice', 'pod2', 'pod_start', 0, $1)`,
time.Date(2026, time.March, 12, 14, 0, 0, 0, time.UTC))
testPool.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ('alice', 'pod2', 'pod_stop', 0, $1)`,
time.Date(2026, time.March, 12, 19, 0, 0, 0, time.UTC))
now := time.Date(2026, time.March, 20, 0, 0, 0, 0, time.UTC)
usage, err := s.getUsage(ctx, "alice", 2026, time.March, 500, now)
if err != nil {
t.Fatalf("get usage: %v", err)
}
if math.Abs(usage.PodHours-7.0) > 0.01 {
t.Errorf("expected ~7.0 pod hours (2+5), got %f", usage.PodHours)
}
}
func TestGetUsage_CPUHours(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
s.CreateUser(ctx, "alice", model.DefaultQuota())
// 10 samples of 3000 millicores
// CPU-hours = 10 * 3000 / 60000 = 0.5
for i := 0; i < 10; i++ {
ts := time.Date(2026, time.March, 15, 10, i, 0, 0, time.UTC)
testPool.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ('alice', 'main', 'cpu_sample', 3000, $1)`, ts)
}
now := time.Date(2026, time.March, 20, 0, 0, 0, 0, time.UTC)
usage, err := s.getUsage(ctx, "alice", 2026, time.March, 500, now)
if err != nil {
t.Fatalf("get usage: %v", err)
}
if math.Abs(usage.CPUHours-0.5) > 0.001 {
t.Errorf("expected ~0.5 cpu hours, got %f", usage.CPUHours)
}
}
func TestGetUsage_AIRequests(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
s.CreateUser(ctx, "alice", model.DefaultQuota())
for i := 0; i < 42; i++ {
ts := time.Date(2026, time.March, 15, 10, 0, i, 0, time.UTC)
testPool.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ('alice', '', 'ai_request', 1, $1)`, ts)
}
now := time.Date(2026, time.March, 20, 0, 0, 0, 0, time.UTC)
usage, err := s.getUsage(ctx, "alice", 2026, time.March, 500, now)
if err != nil {
t.Fatalf("get usage: %v", err)
}
if usage.AIRequests != 42 {
t.Errorf("expected 42 ai requests, got %d", usage.AIRequests)
}
}
func TestGetUsage_BudgetPercent(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
s.CreateUser(ctx, "alice", model.DefaultQuota())
// 100 hours used, budget = 500 → 20%
start := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
stop := time.Date(2026, time.March, 5, 4, 0, 0, 0, time.UTC) // 100 hours
testPool.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ('alice', 'main', 'pod_start', 0, $1)`, start)
testPool.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ('alice', 'main', 'pod_stop', 0, $1)`, stop)
usage, err := s.getUsage(ctx, "alice", 2026, time.March, 500, stop)
if err != nil {
t.Fatalf("get usage: %v", err)
}
if math.Abs(usage.BudgetUsedPct-20.0) > 0.1 {
t.Errorf("expected ~20%% budget used, got %f", usage.BudgetUsedPct)
}
}
func TestGetUsage_DifferentMonths(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
s.CreateUser(ctx, "alice", model.DefaultQuota())
// March data
testPool.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ('alice', 'main', 'pod_start', 0, $1)`,
time.Date(2026, time.March, 15, 10, 0, 0, 0, time.UTC))
testPool.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ('alice', 'main', 'pod_stop', 0, $1)`,
time.Date(2026, time.March, 15, 12, 0, 0, 0, time.UTC))
// February data should not be counted
testPool.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ('alice', 'other', 'pod_start', 0, $1)`,
time.Date(2026, time.February, 15, 10, 0, 0, 0, time.UTC))
testPool.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ('alice', 'other', 'pod_stop', 0, $1)`,
time.Date(2026, time.February, 15, 20, 0, 0, 0, time.UTC))
now := time.Date(2026, time.March, 20, 0, 0, 0, 0, time.UTC)
usage, err := s.getUsage(ctx, "alice", 2026, time.March, 500, now)
if err != nil {
t.Fatalf("get usage: %v", err)
}
// Should only count March: 2 hours
if math.Abs(usage.PodHours-2.0) > 0.01 {
t.Errorf("expected ~2.0 pod hours (March only), got %f", usage.PodHours)
}
}
func TestGetDailyUsage(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
s.CreateUser(ctx, "alice", model.DefaultQuota())
// Day 1: 5 CPU samples of 2000m + 3 AI requests
for i := 0; i < 5; i++ {
ts := time.Date(2026, time.March, 10, 10, i, 0, 0, time.UTC)
testPool.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ('alice', 'main', 'cpu_sample', 2000, $1)`, ts)
}
for i := 0; i < 3; i++ {
ts := time.Date(2026, time.March, 10, 11, i, 0, 0, time.UTC)
testPool.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ('alice', '', 'ai_request', 1, $1)`, ts)
}
// Day 2: 10 CPU samples of 4000m + 7 AI requests
for i := 0; i < 10; i++ {
ts := time.Date(2026, time.March, 11, 14, i, 0, 0, time.UTC)
testPool.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ('alice', 'main', 'cpu_sample', 4000, $1)`, ts)
}
for i := 0; i < 7; i++ {
ts := time.Date(2026, time.March, 11, 15, i, 0, 0, time.UTC)
testPool.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ('alice', '', 'ai_request', 1, $1)`, ts)
}
daily, err := s.GetDailyUsage(ctx, "alice", 2026, time.March)
if err != nil {
t.Fatalf("get daily usage: %v", err)
}
if len(daily) != 2 {
t.Fatalf("expected 2 days, got %d", len(daily))
}
// Day 1
if daily[0].Date != "2026-03-10" {
t.Errorf("day 1 date: got %q, want 2026-03-10", daily[0].Date)
}
// pod_hours = 5 samples * (1/60) = 5/60
if math.Abs(daily[0].PodHours-5.0/60.0) > 0.001 {
t.Errorf("day 1 pod hours: got %f, want %f", daily[0].PodHours, 5.0/60.0)
}
// cpu_hours = 5 * 2000 / 60000 = 10000/60000
if math.Abs(daily[0].CPUHours-10000.0/60000.0) > 0.001 {
t.Errorf("day 1 cpu hours: got %f, want %f", daily[0].CPUHours, 10000.0/60000.0)
}
if daily[0].AIRequests != 3 {
t.Errorf("day 1 ai requests: got %d, want 3", daily[0].AIRequests)
}
// Day 2
if daily[1].Date != "2026-03-11" {
t.Errorf("day 2 date: got %q, want 2026-03-11", daily[1].Date)
}
if daily[1].AIRequests != 7 {
t.Errorf("day 2 ai requests: got %d, want 7", daily[1].AIRequests)
}
}
func TestGetDailyUsage_Empty(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
s.CreateUser(ctx, "alice", model.DefaultQuota())
daily, err := s.GetDailyUsage(ctx, "alice", 2026, time.March)
if err != nil {
t.Fatalf("get daily usage: %v", err)
}
if len(daily) != 0 {
t.Errorf("expected 0 days, got %d", len(daily))
}
}