Path: blob/main/components/usage/pkg/apiv1/usage_test.go
2499 views
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.1// Licensed under the GNU Affero General Public License (AGPL).2// See License.AGPL.txt in the project root for license information.34package apiv156import (7"context"8"database/sql"9"fmt"10"testing"11"time"1213"github.com/gitpod-io/gitpod/common-go/baseserver"14db "github.com/gitpod-io/gitpod/components/gitpod-db/go"15"github.com/gitpod-io/gitpod/components/gitpod-db/go/dbtest"16v1 "github.com/gitpod-io/gitpod/usage-api/v1"17"github.com/google/uuid"18"github.com/stretchr/testify/require"19"google.golang.org/grpc"20"google.golang.org/grpc/credentials/insecure"21"google.golang.org/protobuf/types/known/timestamppb"22"gorm.io/gorm"23)2425func TestUsageService_ReconcileUsage(t *testing.T) {26dbconn := dbtest.ConnectForTests(t)27from := time.Date(2022, 05, 1, 0, 00, 00, 00, time.UTC)28to := time.Date(2022, 05, 1, 1, 00, 00, 00, time.UTC)29attributionID := db.NewTeamAttributionID(uuid.New().String())3031t.Cleanup(func() {32require.NoError(t, dbconn.Where("attributionId = ?", attributionID).Delete(&db.Usage{}).Error)33})3435// stopped instances36instance := dbtest.NewWorkspaceInstance(t, db.WorkspaceInstance{37UsageAttributionID: attributionID,38StartedTime: db.NewVarCharTime(from),39StoppingTime: db.NewVarCharTime(to.Add(-1 * time.Minute)),40})41dbtest.CreateWorkspaceInstances(t, dbconn, instance)4243// running instances44dbtest.CreateWorkspaceInstances(t, dbconn, dbtest.NewWorkspaceInstance(t, db.WorkspaceInstance{45StartedTime: db.NewVarCharTime(to.Add(-1 * time.Minute)),46UsageAttributionID: attributionID,47}))4849// usage drafts50dbtest.CreateUsageRecords(t, dbconn, dbtest.NewUsage(t, db.Usage{51ID: uuid.New(),52AttributionID: attributionID,53WorkspaceInstanceID: &instance.ID,54Kind: db.WorkspaceInstanceUsageKind,55Draft: true,56}))5758client := newUsageService(t, dbconn)5960_, err := client.ReconcileUsage(context.Background(), &v1.ReconcileUsageRequest{61From: timestamppb.New(from),62To: timestamppb.New(to),63})64require.NoError(t, err)6566usage, err := db.FindUsage(context.Background(), dbconn, &db.FindUsageParams{67AttributionId: attributionID,68From: from,69To: to,70ExcludeDrafts: false,71})72require.NoError(t, err)73require.Len(t, usage, 1)74}7576func newUsageService(t *testing.T, dbconn *gorm.DB) v1.UsageServiceClient {77srv := baseserver.NewForTests(t,78baseserver.WithGRPC(baseserver.MustUseRandomLocalAddress(t)),79)8081costCenterManager := db.NewCostCenterManager(dbconn, db.DefaultSpendingLimit{82ForTeams: 0,83ForUsers: 500,84MinForUsersOnStripe: 1000,85})8687usageService, err := NewUsageService(dbconn, DefaultWorkspacePricer, costCenterManager, "1m")88if err != nil {89t.Fatal(err)90}91v1.RegisterUsageServiceServer(srv.GRPC(), usageService)92baseserver.StartServerForTests(t, srv)9394conn, err := grpc.Dial(srv.GRPCAddress(), grpc.WithTransportCredentials(insecure.NewCredentials()))95require.NoError(t, err)9697client := v1.NewUsageServiceClient(conn)98return client99}100101func TestReconcile(t *testing.T) {102now := time.Date(2022, 9, 1, 10, 0, 0, 0, time.UTC)103pricer, err := NewWorkspacePricer(map[string]float64{104"default": 0.1666666667,105"g1-standard": 0.1666666667,106"g1-standard-pvc": 0.1666666667,107"g1-large": 0.3333333333,108"g1-large-pvc": 0.3333333333,109"gitpodio-internal-xl": 0.3333333333,110})111require.NoError(t, err)112113t.Run("no action with no instances and no drafts", func(t *testing.T) {114inserts, updates, err := reconcileUsage(nil, nil, pricer, now)115require.NoError(t, err)116require.Len(t, inserts, 0)117require.Len(t, updates, 0)118})119120t.Run("no action with no instances but existing drafts", func(t *testing.T) {121drafts := []db.Usage{dbtest.NewUsage(t, db.Usage{})}122inserts, updates, err := reconcileUsage(nil, drafts, pricer, now)123require.NoError(t, err)124require.Len(t, inserts, 0)125require.Len(t, updates, 0)126})127128t.Run("creates a new usage record when no draft exists, removing duplicates", func(t *testing.T) {129instance := db.WorkspaceInstanceForUsage{130ID: uuid.New(),131WorkspaceID: dbtest.GenerateWorkspaceID(),132OwnerID: uuid.New(),133ProjectID: sql.NullString{134String: "my-project",135Valid: true,136},137WorkspaceClass: db.WorkspaceClass_Default,138Type: db.WorkspaceType_Regular,139UsageAttributionID: db.NewTeamAttributionID(uuid.New().String()),140StartedTime: db.NewVarCharTime(now.Add(1 * time.Minute)),141}142143inserts, updates, err := reconcileUsage([]db.WorkspaceInstanceForUsage{instance, instance}, nil, pricer, now)144require.NoError(t, err)145require.Len(t, inserts, 1)146require.Len(t, updates, 0)147expectedUsage := db.Usage{148ID: inserts[0].ID,149AttributionID: instance.UsageAttributionID,150Description: usageDescriptionFromController,151CreditCents: db.NewCreditCents(pricer.CreditsUsedByInstance(&instance, now)),152EffectiveTime: db.NewVarCharTime(now),153Kind: db.WorkspaceInstanceUsageKind,154WorkspaceInstanceID: &instance.ID,155Draft: true,156Metadata: nil,157}158require.NoError(t, expectedUsage.SetMetadataWithWorkspaceInstance(db.WorkspaceInstanceUsageData{159WorkspaceId: instance.WorkspaceID,160WorkspaceType: instance.Type,161WorkspaceClass: instance.WorkspaceClass,162ContextURL: instance.ContextURL,163StartTime: db.TimeToISO8601(instance.StartedTime.Time()),164EndTime: "",165UserName: instance.UserName,166UserAvatarURL: instance.UserAvatarURL,167}))168require.EqualValues(t, expectedUsage, inserts[0])169})170171t.Run("updates a usage record when a draft exists", func(t *testing.T) {172instance := db.WorkspaceInstanceForUsage{173ID: uuid.New(),174WorkspaceID: dbtest.GenerateWorkspaceID(),175OwnerID: uuid.New(),176ProjectID: sql.NullString{177String: "my-project",178Valid: true,179},180WorkspaceClass: db.WorkspaceClass_Default,181Type: db.WorkspaceType_Regular,182UsageAttributionID: db.NewTeamAttributionID(uuid.New().String()),183StartedTime: db.NewVarCharTime(now.Add(1 * time.Minute)),184}185186// the fields in the usage record deliberately do not match the instance, except for the Instance ID.187// we do this to test that the fields in the usage records get updated to reflect the true values from the source of truth - instances.188draft := dbtest.NewUsage(t, db.Usage{189ID: uuid.New(),190AttributionID: db.NewTeamAttributionID(uuid.New().String()),191Description: "Some description",192CreditCents: 1,193EffectiveTime: db.VarcharTime{},194Kind: db.WorkspaceInstanceUsageKind,195WorkspaceInstanceID: &instance.ID,196Draft: true,197Metadata: nil,198})199200inserts, updates, err := reconcileUsage([]db.WorkspaceInstanceForUsage{instance}, []db.Usage{draft}, pricer, now)201require.NoError(t, err)202require.Len(t, inserts, 0)203require.Len(t, updates, 1)204205expectedUsage := db.Usage{206ID: draft.ID,207AttributionID: instance.UsageAttributionID,208Description: usageDescriptionFromController,209CreditCents: db.NewCreditCents(pricer.CreditsUsedByInstance(&instance, now)),210EffectiveTime: db.NewVarCharTime(now),211Kind: db.WorkspaceInstanceUsageKind,212WorkspaceInstanceID: &instance.ID,213Draft: true,214Metadata: nil,215}216require.NoError(t, expectedUsage.SetMetadataWithWorkspaceInstance(db.WorkspaceInstanceUsageData{217WorkspaceId: instance.WorkspaceID,218WorkspaceType: instance.Type,219WorkspaceClass: instance.WorkspaceClass,220ContextURL: instance.ContextURL,221StartTime: db.TimeToISO8601(instance.StartedTime.Time()),222EndTime: "",223UserName: instance.UserName,224UserAvatarURL: instance.UserAvatarURL,225}))226require.EqualValues(t, expectedUsage, updates[0])227})228229t.Run("handles instances without stopping but stopped time", func(t *testing.T) {230instance := db.WorkspaceInstanceForUsage{231ID: uuid.New(),232WorkspaceID: dbtest.GenerateWorkspaceID(),233OwnerID: uuid.New(),234ProjectID: sql.NullString{235String: "my-project",236Valid: true,237},238WorkspaceClass: db.WorkspaceClass_Default,239Type: db.WorkspaceType_Regular,240UsageAttributionID: db.NewTeamAttributionID(uuid.New().String()),241StartedTime: db.NewVarCharTime(now.Add(1 * time.Minute)),242StoppedTime: db.NewVarCharTime(now.Add(2 * time.Minute)),243}244245inserts, updates, err := reconcileUsage([]db.WorkspaceInstanceForUsage{instance}, []db.Usage{}, pricer, now)246require.NoError(t, err)247require.Len(t, inserts, 1)248require.Len(t, updates, 0)249250require.EqualValues(t, db.NewCreditCents(0.17), inserts[0].CreditCents)251require.EqualValues(t, instance.StoppedTime, inserts[0].EffectiveTime)252})253}254255func TestGetAndSetCostCenter(t *testing.T) {256conn := dbtest.ConnectForTests(t)257costCenterUpdates := []*v1.CostCenter{258{259AttributionId: string(db.NewTeamAttributionID(uuid.New().String())),260SpendingLimit: 8000,261BillingStrategy: v1.CostCenter_BILLING_STRATEGY_STRIPE,262},263{264AttributionId: string(db.NewTeamAttributionID(uuid.New().String())),265SpendingLimit: 500,266BillingStrategy: v1.CostCenter_BILLING_STRATEGY_OTHER,267},268{269AttributionId: string(db.NewTeamAttributionID(uuid.New().String())),270SpendingLimit: 8000,271BillingStrategy: v1.CostCenter_BILLING_STRATEGY_STRIPE,272},273{274AttributionId: string(db.NewTeamAttributionID(uuid.New().String())),275SpendingLimit: 0,276BillingStrategy: v1.CostCenter_BILLING_STRATEGY_OTHER,277},278}279280service := newUsageService(t, conn)281282for _, costCenter := range costCenterUpdates {283retrieved, err := service.SetCostCenter(context.Background(), &v1.SetCostCenterRequest{284CostCenter: costCenter,285})286require.NoError(t, err)287288require.Equal(t, costCenter.SpendingLimit, retrieved.CostCenter.SpendingLimit)289require.Equal(t, costCenter.BillingStrategy, retrieved.CostCenter.BillingStrategy)290}291}292293func TestListUsage(t *testing.T) {294295start := time.Date(2022, 7, 1, 0, 0, 0, 0, time.UTC)296end := time.Date(2022, 8, 1, 0, 0, 0, 0, time.UTC)297298attributionID := db.NewTeamAttributionID(uuid.New().String())299300draftBefore := dbtest.NewUsage(t, db.Usage{301AttributionID: attributionID,302EffectiveTime: db.NewVarCharTime(start.Add(-1 * 23 * time.Hour)),303CreditCents: 100,304Draft: true,305})306307nondraftBefore := dbtest.NewUsage(t, db.Usage{308AttributionID: attributionID,309EffectiveTime: db.NewVarCharTime(start.Add(-1 * 23 * time.Hour)),310CreditCents: 200,311Draft: false,312})313314draftInside := dbtest.NewUsage(t, db.Usage{315AttributionID: attributionID,316EffectiveTime: db.NewVarCharTime(start.Add(2 * time.Hour)),317CreditCents: 300,318Draft: true,319})320nonDraftInside := dbtest.NewUsage(t, db.Usage{321AttributionID: attributionID,322EffectiveTime: db.NewVarCharTime(start.Add(2 * time.Hour)),323CreditCents: 400,324Draft: false,325})326327nonDraftAfter := dbtest.NewUsage(t, db.Usage{328AttributionID: attributionID,329EffectiveTime: db.NewVarCharTime(end.Add(2 * time.Hour)),330CreditCents: 1000,331})332333tests := []struct {334start, end time.Time335// expectations336creditsUsed float64337recordsInRange int64338}{339{start, end, 7, 2},340{end, end, 0, 0},341{start, start, 0, 0},342{start.Add(-200 * 24 * time.Hour), end, 10, 4},343{start.Add(-200 * 24 * time.Hour), end.Add(10 * 24 * time.Hour), 20, 5},344}345346for i, test := range tests {347t.Run(fmt.Sprintf("test no %d", i+1), func(t *testing.T) {348conn := dbtest.ConnectForTests(t)349dbtest.CreateUsageRecords(t, conn, draftBefore, nondraftBefore, draftInside, nonDraftInside, nonDraftAfter)350351usageService := newUsageService(t, conn)352353metaData, err := usageService.ListUsage(context.Background(), &v1.ListUsageRequest{354AttributionId: string(attributionID),355From: timestamppb.New(test.start),356To: timestamppb.New(test.end),357Order: v1.ListUsageRequest_ORDERING_DESCENDING,358Pagination: &v1.PaginatedRequest{359PerPage: 1,360Page: 1,361},362})363require.NoError(t, err)364365require.Equal(t, test.creditsUsed, metaData.CreditsUsed)366require.Equal(t, test.recordsInRange, metaData.Pagination.Total)367})368}369370}371372func TestAddUSageCreditNote(t *testing.T) {373tests := []struct {374credits int32375userId string376description string377// expectations378expectedError bool379}{380{300, uuid.New().String(), "Something", false},381{300, "bad-userid", "Something", true},382{300, uuid.New().String(), " " /* no note */, true},383{-300, uuid.New().String(), "Negative Balance", false},384}385386for i, test := range tests {387t.Run(fmt.Sprintf("test no %d", i+1), func(t *testing.T) {388attributionID := db.NewTeamAttributionID(uuid.New().String())389conn := dbtest.ConnectForTests(t)390usageService := newUsageService(t, conn)391392_, err := usageService.AddUsageCreditNote(context.Background(), &v1.AddUsageCreditNoteRequest{393AttributionId: string(attributionID),394Credits: test.credits,395Description: test.description,396UserId: test.userId,397})398if test.expectedError {399require.Error(t, err)400} else {401require.NoError(t, err)402balance, err := db.GetBalance(context.Background(), conn, attributionID)403require.NoError(t, err)404require.Equal(t, int32(balance.ToCredits()), test.credits*-1)405}406require.NoError(t, conn.Where("attributionId = ?", attributionID).Delete(&db.Usage{}).Error)407})408}409410}411412413