399 lines
12 KiB
Go
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))
|
|
}
|
|
}
|