package api import ( "context" "net/http" "sync" "testing" "time" "github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model" ) // mockUsageStore implements UsageStore for testing. type mockUsageStore struct { mu sync.Mutex starts []usageEvent stops []usageEvent usageResult *model.UsageSummary dailyResult []model.DailyUsage } type usageEvent struct { userID string podName string } func newMockUsageStore() *mockUsageStore { return &mockUsageStore{ usageResult: &model.UsageSummary{}, } } func (m *mockUsageStore) RecordPodStart(_ context.Context, userID, podName string) error { m.mu.Lock() defer m.mu.Unlock() m.starts = append(m.starts, usageEvent{userID, podName}) return nil } func (m *mockUsageStore) RecordPodStop(_ context.Context, userID, podName string) error { m.mu.Lock() defer m.mu.Unlock() m.stops = append(m.stops, usageEvent{userID, podName}) return nil } func (m *mockUsageStore) RecordResourceSample(_ context.Context, _, _ string, _, _ float64) error { return nil } func (m *mockUsageStore) RecordAIRequest(_ context.Context, _ string) error { return nil } func (m *mockUsageStore) GetUsage(_ context.Context, _ string, _ int, _ time.Month, _ int) (*model.UsageSummary, error) { m.mu.Lock() defer m.mu.Unlock() return m.usageResult, nil } func (m *mockUsageStore) GetDailyUsage(_ context.Context, _ string, _ int, _ time.Month) ([]model.DailyUsage, error) { m.mu.Lock() defer m.mu.Unlock() return m.dailyResult, nil } // --- GET /api/v1/users/{user}/usage --- func TestGetUserUsage_Success(t *testing.T) { kv, _, us, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) us.addUser(user) rr := doRequest(router, http.MethodGet, "/api/v1/users/alice/usage", "", "dpk_test") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var resp map[string]model.UsageSummary decodeJSON(t, rr, &resp) if _, ok := resp["current_month"]; !ok { t.Fatal("response should contain 'current_month' key") } } func TestGetUserUsage_AdminCanViewOther(t *testing.T) { kv, _, us, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} alice := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) us.addUser(admin) us.addUser(alice) rr := doRequest(router, http.MethodGet, "/api/v1/users/alice/usage", "", "dpk_admin") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } } func TestGetUserUsage_Forbidden(t *testing.T) { kv, _, _, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) rr := doRequest(router, http.MethodGet, "/api/v1/users/bob/usage", "", "dpk_test") if rr.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String()) } } func TestGetUserUsage_NotFound(t *testing.T) { kv, _, _, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) // Don't add alice to user store rr := doRequest(router, http.MethodGet, "/api/v1/users/alice/usage", "", "dpk_test") if rr.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String()) } } // --- GET /api/v1/billing/summary --- func TestBillingSummary_AdminSuccess(t *testing.T) { kv, _, us, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} alice := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) us.addUser(admin) us.addUser(alice) rr := doRequest(router, http.MethodGet, "/api/v1/billing/summary", "", "dpk_admin") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var summaries []model.UserUsageSummary decodeJSON(t, rr, &summaries) if len(summaries) != 2 { t.Fatalf("expected 2 user summaries, got %d", len(summaries)) } } func TestBillingSummary_Forbidden(t *testing.T) { kv, _, _, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) rr := doRequest(router, http.MethodGet, "/api/v1/billing/summary", "", "dpk_test") if rr.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String()) } } // --- GET /api/v1/billing/{user}/history --- func TestBillingHistory_Success(t *testing.T) { kv, _, us, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) us.addUser(user) rr := doRequest(router, http.MethodGet, "/api/v1/billing/alice/history", "", "dpk_test") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var daily []model.DailyUsage decodeJSON(t, rr, &daily) // Empty history returns empty array if daily == nil { t.Fatal("expected non-nil array") } } func TestBillingHistory_AdminCanView(t *testing.T) { kv, _, us, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} alice := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) us.addUser(admin) us.addUser(alice) rr := doRequest(router, http.MethodGet, "/api/v1/billing/alice/history", "", "dpk_admin") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } } func TestBillingHistory_Forbidden(t *testing.T) { kv, _, _, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) rr := doRequest(router, http.MethodGet, "/api/v1/billing/bob/history", "", "dpk_test") if rr.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String()) } } // --- Auth integration --- func TestBillingRoutes_NoAuth(t *testing.T) { _, _, _, router := newPodTestRouter() routes := []struct { method string path string }{ {http.MethodGet, "/api/v1/users/alice/usage"}, {http.MethodGet, "/api/v1/billing/summary"}, {http.MethodGet, "/api/v1/billing/alice/history"}, } for _, rt := range routes { t.Run(rt.method+" "+rt.path, func(t *testing.T) { rr := doRequest(router, rt.method, rt.path, "", "") if rr.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", rr.Code) } }) } }