Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/usage/pkg/apiv1/usage_test.go
2499 views
1
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
2
// Licensed under the GNU Affero General Public License (AGPL).
3
// See License.AGPL.txt in the project root for license information.
4
5
package apiv1
6
7
import (
8
"context"
9
"database/sql"
10
"fmt"
11
"testing"
12
"time"
13
14
"github.com/gitpod-io/gitpod/common-go/baseserver"
15
db "github.com/gitpod-io/gitpod/components/gitpod-db/go"
16
"github.com/gitpod-io/gitpod/components/gitpod-db/go/dbtest"
17
v1 "github.com/gitpod-io/gitpod/usage-api/v1"
18
"github.com/google/uuid"
19
"github.com/stretchr/testify/require"
20
"google.golang.org/grpc"
21
"google.golang.org/grpc/credentials/insecure"
22
"google.golang.org/protobuf/types/known/timestamppb"
23
"gorm.io/gorm"
24
)
25
26
func TestUsageService_ReconcileUsage(t *testing.T) {
27
dbconn := dbtest.ConnectForTests(t)
28
from := time.Date(2022, 05, 1, 0, 00, 00, 00, time.UTC)
29
to := time.Date(2022, 05, 1, 1, 00, 00, 00, time.UTC)
30
attributionID := db.NewTeamAttributionID(uuid.New().String())
31
32
t.Cleanup(func() {
33
require.NoError(t, dbconn.Where("attributionId = ?", attributionID).Delete(&db.Usage{}).Error)
34
})
35
36
// stopped instances
37
instance := dbtest.NewWorkspaceInstance(t, db.WorkspaceInstance{
38
UsageAttributionID: attributionID,
39
StartedTime: db.NewVarCharTime(from),
40
StoppingTime: db.NewVarCharTime(to.Add(-1 * time.Minute)),
41
})
42
dbtest.CreateWorkspaceInstances(t, dbconn, instance)
43
44
// running instances
45
dbtest.CreateWorkspaceInstances(t, dbconn, dbtest.NewWorkspaceInstance(t, db.WorkspaceInstance{
46
StartedTime: db.NewVarCharTime(to.Add(-1 * time.Minute)),
47
UsageAttributionID: attributionID,
48
}))
49
50
// usage drafts
51
dbtest.CreateUsageRecords(t, dbconn, dbtest.NewUsage(t, db.Usage{
52
ID: uuid.New(),
53
AttributionID: attributionID,
54
WorkspaceInstanceID: &instance.ID,
55
Kind: db.WorkspaceInstanceUsageKind,
56
Draft: true,
57
}))
58
59
client := newUsageService(t, dbconn)
60
61
_, err := client.ReconcileUsage(context.Background(), &v1.ReconcileUsageRequest{
62
From: timestamppb.New(from),
63
To: timestamppb.New(to),
64
})
65
require.NoError(t, err)
66
67
usage, err := db.FindUsage(context.Background(), dbconn, &db.FindUsageParams{
68
AttributionId: attributionID,
69
From: from,
70
To: to,
71
ExcludeDrafts: false,
72
})
73
require.NoError(t, err)
74
require.Len(t, usage, 1)
75
}
76
77
func newUsageService(t *testing.T, dbconn *gorm.DB) v1.UsageServiceClient {
78
srv := baseserver.NewForTests(t,
79
baseserver.WithGRPC(baseserver.MustUseRandomLocalAddress(t)),
80
)
81
82
costCenterManager := db.NewCostCenterManager(dbconn, db.DefaultSpendingLimit{
83
ForTeams: 0,
84
ForUsers: 500,
85
MinForUsersOnStripe: 1000,
86
})
87
88
usageService, err := NewUsageService(dbconn, DefaultWorkspacePricer, costCenterManager, "1m")
89
if err != nil {
90
t.Fatal(err)
91
}
92
v1.RegisterUsageServiceServer(srv.GRPC(), usageService)
93
baseserver.StartServerForTests(t, srv)
94
95
conn, err := grpc.Dial(srv.GRPCAddress(), grpc.WithTransportCredentials(insecure.NewCredentials()))
96
require.NoError(t, err)
97
98
client := v1.NewUsageServiceClient(conn)
99
return client
100
}
101
102
func TestReconcile(t *testing.T) {
103
now := time.Date(2022, 9, 1, 10, 0, 0, 0, time.UTC)
104
pricer, err := NewWorkspacePricer(map[string]float64{
105
"default": 0.1666666667,
106
"g1-standard": 0.1666666667,
107
"g1-standard-pvc": 0.1666666667,
108
"g1-large": 0.3333333333,
109
"g1-large-pvc": 0.3333333333,
110
"gitpodio-internal-xl": 0.3333333333,
111
})
112
require.NoError(t, err)
113
114
t.Run("no action with no instances and no drafts", func(t *testing.T) {
115
inserts, updates, err := reconcileUsage(nil, nil, pricer, now)
116
require.NoError(t, err)
117
require.Len(t, inserts, 0)
118
require.Len(t, updates, 0)
119
})
120
121
t.Run("no action with no instances but existing drafts", func(t *testing.T) {
122
drafts := []db.Usage{dbtest.NewUsage(t, db.Usage{})}
123
inserts, updates, err := reconcileUsage(nil, drafts, pricer, now)
124
require.NoError(t, err)
125
require.Len(t, inserts, 0)
126
require.Len(t, updates, 0)
127
})
128
129
t.Run("creates a new usage record when no draft exists, removing duplicates", func(t *testing.T) {
130
instance := db.WorkspaceInstanceForUsage{
131
ID: uuid.New(),
132
WorkspaceID: dbtest.GenerateWorkspaceID(),
133
OwnerID: uuid.New(),
134
ProjectID: sql.NullString{
135
String: "my-project",
136
Valid: true,
137
},
138
WorkspaceClass: db.WorkspaceClass_Default,
139
Type: db.WorkspaceType_Regular,
140
UsageAttributionID: db.NewTeamAttributionID(uuid.New().String()),
141
StartedTime: db.NewVarCharTime(now.Add(1 * time.Minute)),
142
}
143
144
inserts, updates, err := reconcileUsage([]db.WorkspaceInstanceForUsage{instance, instance}, nil, pricer, now)
145
require.NoError(t, err)
146
require.Len(t, inserts, 1)
147
require.Len(t, updates, 0)
148
expectedUsage := db.Usage{
149
ID: inserts[0].ID,
150
AttributionID: instance.UsageAttributionID,
151
Description: usageDescriptionFromController,
152
CreditCents: db.NewCreditCents(pricer.CreditsUsedByInstance(&instance, now)),
153
EffectiveTime: db.NewVarCharTime(now),
154
Kind: db.WorkspaceInstanceUsageKind,
155
WorkspaceInstanceID: &instance.ID,
156
Draft: true,
157
Metadata: nil,
158
}
159
require.NoError(t, expectedUsage.SetMetadataWithWorkspaceInstance(db.WorkspaceInstanceUsageData{
160
WorkspaceId: instance.WorkspaceID,
161
WorkspaceType: instance.Type,
162
WorkspaceClass: instance.WorkspaceClass,
163
ContextURL: instance.ContextURL,
164
StartTime: db.TimeToISO8601(instance.StartedTime.Time()),
165
EndTime: "",
166
UserName: instance.UserName,
167
UserAvatarURL: instance.UserAvatarURL,
168
}))
169
require.EqualValues(t, expectedUsage, inserts[0])
170
})
171
172
t.Run("updates a usage record when a draft exists", func(t *testing.T) {
173
instance := db.WorkspaceInstanceForUsage{
174
ID: uuid.New(),
175
WorkspaceID: dbtest.GenerateWorkspaceID(),
176
OwnerID: uuid.New(),
177
ProjectID: sql.NullString{
178
String: "my-project",
179
Valid: true,
180
},
181
WorkspaceClass: db.WorkspaceClass_Default,
182
Type: db.WorkspaceType_Regular,
183
UsageAttributionID: db.NewTeamAttributionID(uuid.New().String()),
184
StartedTime: db.NewVarCharTime(now.Add(1 * time.Minute)),
185
}
186
187
// the fields in the usage record deliberately do not match the instance, except for the Instance ID.
188
// 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.
189
draft := dbtest.NewUsage(t, db.Usage{
190
ID: uuid.New(),
191
AttributionID: db.NewTeamAttributionID(uuid.New().String()),
192
Description: "Some description",
193
CreditCents: 1,
194
EffectiveTime: db.VarcharTime{},
195
Kind: db.WorkspaceInstanceUsageKind,
196
WorkspaceInstanceID: &instance.ID,
197
Draft: true,
198
Metadata: nil,
199
})
200
201
inserts, updates, err := reconcileUsage([]db.WorkspaceInstanceForUsage{instance}, []db.Usage{draft}, pricer, now)
202
require.NoError(t, err)
203
require.Len(t, inserts, 0)
204
require.Len(t, updates, 1)
205
206
expectedUsage := db.Usage{
207
ID: draft.ID,
208
AttributionID: instance.UsageAttributionID,
209
Description: usageDescriptionFromController,
210
CreditCents: db.NewCreditCents(pricer.CreditsUsedByInstance(&instance, now)),
211
EffectiveTime: db.NewVarCharTime(now),
212
Kind: db.WorkspaceInstanceUsageKind,
213
WorkspaceInstanceID: &instance.ID,
214
Draft: true,
215
Metadata: nil,
216
}
217
require.NoError(t, expectedUsage.SetMetadataWithWorkspaceInstance(db.WorkspaceInstanceUsageData{
218
WorkspaceId: instance.WorkspaceID,
219
WorkspaceType: instance.Type,
220
WorkspaceClass: instance.WorkspaceClass,
221
ContextURL: instance.ContextURL,
222
StartTime: db.TimeToISO8601(instance.StartedTime.Time()),
223
EndTime: "",
224
UserName: instance.UserName,
225
UserAvatarURL: instance.UserAvatarURL,
226
}))
227
require.EqualValues(t, expectedUsage, updates[0])
228
})
229
230
t.Run("handles instances without stopping but stopped time", func(t *testing.T) {
231
instance := db.WorkspaceInstanceForUsage{
232
ID: uuid.New(),
233
WorkspaceID: dbtest.GenerateWorkspaceID(),
234
OwnerID: uuid.New(),
235
ProjectID: sql.NullString{
236
String: "my-project",
237
Valid: true,
238
},
239
WorkspaceClass: db.WorkspaceClass_Default,
240
Type: db.WorkspaceType_Regular,
241
UsageAttributionID: db.NewTeamAttributionID(uuid.New().String()),
242
StartedTime: db.NewVarCharTime(now.Add(1 * time.Minute)),
243
StoppedTime: db.NewVarCharTime(now.Add(2 * time.Minute)),
244
}
245
246
inserts, updates, err := reconcileUsage([]db.WorkspaceInstanceForUsage{instance}, []db.Usage{}, pricer, now)
247
require.NoError(t, err)
248
require.Len(t, inserts, 1)
249
require.Len(t, updates, 0)
250
251
require.EqualValues(t, db.NewCreditCents(0.17), inserts[0].CreditCents)
252
require.EqualValues(t, instance.StoppedTime, inserts[0].EffectiveTime)
253
})
254
}
255
256
func TestGetAndSetCostCenter(t *testing.T) {
257
conn := dbtest.ConnectForTests(t)
258
costCenterUpdates := []*v1.CostCenter{
259
{
260
AttributionId: string(db.NewTeamAttributionID(uuid.New().String())),
261
SpendingLimit: 8000,
262
BillingStrategy: v1.CostCenter_BILLING_STRATEGY_STRIPE,
263
},
264
{
265
AttributionId: string(db.NewTeamAttributionID(uuid.New().String())),
266
SpendingLimit: 500,
267
BillingStrategy: v1.CostCenter_BILLING_STRATEGY_OTHER,
268
},
269
{
270
AttributionId: string(db.NewTeamAttributionID(uuid.New().String())),
271
SpendingLimit: 8000,
272
BillingStrategy: v1.CostCenter_BILLING_STRATEGY_STRIPE,
273
},
274
{
275
AttributionId: string(db.NewTeamAttributionID(uuid.New().String())),
276
SpendingLimit: 0,
277
BillingStrategy: v1.CostCenter_BILLING_STRATEGY_OTHER,
278
},
279
}
280
281
service := newUsageService(t, conn)
282
283
for _, costCenter := range costCenterUpdates {
284
retrieved, err := service.SetCostCenter(context.Background(), &v1.SetCostCenterRequest{
285
CostCenter: costCenter,
286
})
287
require.NoError(t, err)
288
289
require.Equal(t, costCenter.SpendingLimit, retrieved.CostCenter.SpendingLimit)
290
require.Equal(t, costCenter.BillingStrategy, retrieved.CostCenter.BillingStrategy)
291
}
292
}
293
294
func TestListUsage(t *testing.T) {
295
296
start := time.Date(2022, 7, 1, 0, 0, 0, 0, time.UTC)
297
end := time.Date(2022, 8, 1, 0, 0, 0, 0, time.UTC)
298
299
attributionID := db.NewTeamAttributionID(uuid.New().String())
300
301
draftBefore := dbtest.NewUsage(t, db.Usage{
302
AttributionID: attributionID,
303
EffectiveTime: db.NewVarCharTime(start.Add(-1 * 23 * time.Hour)),
304
CreditCents: 100,
305
Draft: true,
306
})
307
308
nondraftBefore := dbtest.NewUsage(t, db.Usage{
309
AttributionID: attributionID,
310
EffectiveTime: db.NewVarCharTime(start.Add(-1 * 23 * time.Hour)),
311
CreditCents: 200,
312
Draft: false,
313
})
314
315
draftInside := dbtest.NewUsage(t, db.Usage{
316
AttributionID: attributionID,
317
EffectiveTime: db.NewVarCharTime(start.Add(2 * time.Hour)),
318
CreditCents: 300,
319
Draft: true,
320
})
321
nonDraftInside := dbtest.NewUsage(t, db.Usage{
322
AttributionID: attributionID,
323
EffectiveTime: db.NewVarCharTime(start.Add(2 * time.Hour)),
324
CreditCents: 400,
325
Draft: false,
326
})
327
328
nonDraftAfter := dbtest.NewUsage(t, db.Usage{
329
AttributionID: attributionID,
330
EffectiveTime: db.NewVarCharTime(end.Add(2 * time.Hour)),
331
CreditCents: 1000,
332
})
333
334
tests := []struct {
335
start, end time.Time
336
// expectations
337
creditsUsed float64
338
recordsInRange int64
339
}{
340
{start, end, 7, 2},
341
{end, end, 0, 0},
342
{start, start, 0, 0},
343
{start.Add(-200 * 24 * time.Hour), end, 10, 4},
344
{start.Add(-200 * 24 * time.Hour), end.Add(10 * 24 * time.Hour), 20, 5},
345
}
346
347
for i, test := range tests {
348
t.Run(fmt.Sprintf("test no %d", i+1), func(t *testing.T) {
349
conn := dbtest.ConnectForTests(t)
350
dbtest.CreateUsageRecords(t, conn, draftBefore, nondraftBefore, draftInside, nonDraftInside, nonDraftAfter)
351
352
usageService := newUsageService(t, conn)
353
354
metaData, err := usageService.ListUsage(context.Background(), &v1.ListUsageRequest{
355
AttributionId: string(attributionID),
356
From: timestamppb.New(test.start),
357
To: timestamppb.New(test.end),
358
Order: v1.ListUsageRequest_ORDERING_DESCENDING,
359
Pagination: &v1.PaginatedRequest{
360
PerPage: 1,
361
Page: 1,
362
},
363
})
364
require.NoError(t, err)
365
366
require.Equal(t, test.creditsUsed, metaData.CreditsUsed)
367
require.Equal(t, test.recordsInRange, metaData.Pagination.Total)
368
})
369
}
370
371
}
372
373
func TestAddUSageCreditNote(t *testing.T) {
374
tests := []struct {
375
credits int32
376
userId string
377
description string
378
// expectations
379
expectedError bool
380
}{
381
{300, uuid.New().String(), "Something", false},
382
{300, "bad-userid", "Something", true},
383
{300, uuid.New().String(), " " /* no note */, true},
384
{-300, uuid.New().String(), "Negative Balance", false},
385
}
386
387
for i, test := range tests {
388
t.Run(fmt.Sprintf("test no %d", i+1), func(t *testing.T) {
389
attributionID := db.NewTeamAttributionID(uuid.New().String())
390
conn := dbtest.ConnectForTests(t)
391
usageService := newUsageService(t, conn)
392
393
_, err := usageService.AddUsageCreditNote(context.Background(), &v1.AddUsageCreditNoteRequest{
394
AttributionId: string(attributionID),
395
Credits: test.credits,
396
Description: test.description,
397
UserId: test.userId,
398
})
399
if test.expectedError {
400
require.Error(t, err)
401
} else {
402
require.NoError(t, err)
403
balance, err := db.GetBalance(context.Background(), conn, attributionID)
404
require.NoError(t, err)
405
require.Equal(t, int32(balance.ToCredits()), test.credits*-1)
406
}
407
require.NoError(t, conn.Where("attributionId = ?", attributionID).Delete(&db.Usage{}).Error)
408
})
409
}
410
411
}
412
413