Repository navigation
Expand file tree
/
Copy pathallocation_test.go
More file actions
408 lines (364 loc) · 14.6 KB
/
Copy pathallocation_test.go
File metadata and controls
408 lines (364 loc) · 14.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//go:build integration
package postgres
import (
"context"
"errors"
"math/rand/v2"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/CRTOsp3ck/elms/server/internal/app"
"github.com/CRTOsp3ck/elms/server/internal/domain"
"github.com/CRTOsp3ck/elms/server/internal/pgtest"
)
// errRollback ends a transaction without committing, so a test can inspect a
// claim without consuming the locker.
var errRollback = errors.New("rollback: test done")
// lockerSpec is a locker to build: a label, a cell size named from the catalog,
// and the status it starts in.
type lockerSpec struct {
label string
typeName string
status app.LockerStatus
}
func typed(label, typeName string, status app.LockerStatus) lockerSpec {
return lockerSpec{label: label, typeName: typeName, status: status}
}
// stationOfTypes creates a committed station whose lockers are built straight
// from the catalog by type name, including the starting status — which
// CreateLocker cannot set, since a new locker is always available.
func stationOfTypes(t *testing.T, pool *pgxpool.Pool, name string, lockers ...lockerSpec) uuid.UUID {
t.Helper()
var stationID uuid.UUID
err := pool.QueryRow(t.Context(),
`INSERT INTO stations (name, address) VALUES ($1, '1 Test Road') RETURNING id`,
name).Scan(&stationID)
pgtest.RequireNoError(t, err, "create station "+name)
for _, l := range lockers {
status := l.status
if status == "" {
status = app.LockerAvailable
}
_, err := pool.Exec(t.Context(),
`INSERT INTO lockers (station_id, label, status, locker_type_id)
SELECT $1, $2, $3::locker_status, t.id
FROM locker_types t
WHERE t.name = $4`,
stationID, l.label, status, l.typeName)
pgtest.RequireNoError(t, err, "create locker "+l.label)
}
return stationID
}
// The packages these tests allocate for, each named by what it needs rather
// than by a size word. Which cells each one fits is the whole subject.
var (
shoebox = domain.Dimensions{LengthMM: 330, WidthMM: 250, HeightMM: 180}
mediumParcel = domain.Dimensions{LengthMM: 400, WidthMM: 300, HeightMM: 250}
bulkyParcel = domain.Dimensions{LengthMM: 700, WidthMM: 500, HeightMM: 500}
pole = domain.Dimensions{LengthMM: 1400, WidthMM: 100, HeightMM: 100}
skiBox = domain.Dimensions{LengthMM: 2000, WidthMM: 200, HeightMM: 200}
)
func TestClaimPicksTheSmallestFittingLocker(t *testing.T) {
pool := freshPool(t)
tr := NewTxRunner(pool)
stationID := stationOfTypes(t, pool, "smallest-fit",
typed("L-01", "Large", app.LockerAvailable),
typed("T-01", "Tower XL", app.LockerAvailable),
typed("M-01", "Medium", app.LockerAvailable),
typed("S-01", "Small", app.LockerAvailable),
)
tests := []struct {
name string
pkg domain.Dimensions
want string
}{
{"shoebox", shoebox, "S-01"},
// A medium parcel also fits the tower, which is 240 L against the
// Medium's 108 L — smallest fit is about litres, not about the names.
{"medium parcel", mediumParcel, "M-01"},
{"bulky parcel", bulkyParcel, "L-01"},
// 1400 mm clears nothing but the tower, whatever the volumes say.
{"pole", pole, "T-01"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Each claim rolls back, so every subtest sees the full inventory.
err := tr.WithTx(t.Context(), func(r app.Repo) error {
got, err := r.ClaimSmallestFittingLocker(t.Context(), stationID, tc.pkg)
if err != nil {
return err
}
if got.Label != tc.want {
t.Errorf("claimed %s, want %s", got.Label, tc.want)
}
return errRollback
})
if !errors.Is(err, errRollback) {
t.Fatalf("WithTx err = %v", err)
}
})
}
}
func TestClaimBreaksTiesByLabel(t *testing.T) {
pool := freshPool(t)
tr := NewTxRunner(pool)
stationID := stationOfTypes(t, pool, "tie-break",
typed("M-11", "Medium", app.LockerAvailable),
typed("M-02", "Medium", app.LockerAvailable),
typed("M-07", "Medium", app.LockerAvailable),
)
err := tr.WithTx(t.Context(), func(r app.Repo) error {
got, err := r.ClaimSmallestFittingLocker(t.Context(), stationID, mediumParcel)
if err != nil {
return err
}
if got.Label != "M-02" {
t.Errorf("claimed %s, want the lowest label M-02", got.Label)
}
return nil
})
pgtest.RequireNoError(t, err, "WithTx")
}
func TestClaimCarriesTheLockerType(t *testing.T) {
pool := freshPool(t)
tr := NewTxRunner(pool)
stationID := stationOfTypes(t, pool, "claim-carries-type",
typed("M-01", "Medium", app.LockerAvailable))
err := tr.WithTx(t.Context(), func(r app.Repo) error {
got, err := r.ClaimSmallestFittingLocker(t.Context(), stationID, mediumParcel)
if err != nil {
return err
}
if got.Type.Name != "Medium" {
t.Errorf("claimed locker reports type %q, want Medium", got.Type.Name)
}
// The interior travels with the locker, so the caller can state what it
// put the package into without a second query.
if !domain.FitsIn(mediumParcel, got.Type.Inner()) {
t.Errorf("the claimed locker's interior %v does not fit the package it was claimed for", got.Type.Inner())
}
if got.ForAllocation().Inner != got.Type.Inner() {
t.Error("ForAllocation drops the interior the domain rule works in")
}
return errRollback
})
if !errors.Is(err, errRollback) {
t.Fatalf("WithTx err = %v", err)
}
}
func TestClaimIgnoresUnusableLockers(t *testing.T) {
pool := freshPool(t)
tr := NewTxRunner(pool)
stationOfTypes(t, pool, "other-station", typed("S-01", "Small", app.LockerAvailable))
stationID := stationOfTypes(t, pool, "unusable",
typed("S-01", "Small", app.LockerOccupied),
typed("S-02", "Small", app.LockerOutOfService),
typed("M-01", "Medium", app.LockerAvailable),
)
err := tr.WithTx(t.Context(), func(r app.Repo) error {
// The two small lockers are unusable — occupied and out of service
// (FR-4) — so a shoebox takes the medium one, and never a locker
// belonging to another station.
got, err := r.ClaimSmallestFittingLocker(t.Context(), stationID, shoebox)
if err != nil {
return err
}
if got.Label != "M-01" || got.StationID != stationID {
t.Errorf("claimed %s at station %s", got.Label, got.StationID)
}
return nil
})
pgtest.RequireNoError(t, err, "WithTx")
}
func TestClaimReportsNoLockerAvailable(t *testing.T) {
pool := freshPool(t)
tr := NewTxRunner(pool)
stationID := stationOfTypes(t, pool, "too-small",
typed("S-01", "Small", app.LockerAvailable),
typed("M-01", "Medium", app.LockerAvailable),
typed("L-01", "Large", app.LockerAvailable),
typed("T-01", "Tower XL", app.LockerAvailable),
)
err := tr.WithTx(t.Context(), func(r app.Repo) error {
// Two metres of ski box beats every cell in the estate on its longest
// side. A full station and an unservable package are the same answer to
// the caller — 409, not 404.
_, err := r.ClaimSmallestFittingLocker(t.Context(), stationID, skiBox)
if !errors.Is(err, domain.ErrNoLockerAvailable) {
t.Errorf("err = %v, want ErrNoLockerAvailable", err)
}
return errRollback
})
if !errors.Is(err, errRollback) {
t.Fatalf("WithTx err = %v", err)
}
}
func TestClaimRejectsAnUnmeasuredPackage(t *testing.T) {
pool := freshPool(t)
tr := NewTxRunner(pool)
stationID := stationOfTypes(t, pool, "unmeasured", typed("L-01", "Large", app.LockerAvailable))
err := tr.WithTx(t.Context(), func(r app.Repo) error {
// A zero-sided package would clear every comparison in the query.
_, err := r.ClaimSmallestFittingLocker(t.Context(), stationID, domain.Dimensions{})
if !errors.Is(err, domain.ErrInvalidDimensions) {
t.Errorf("err = %v, want ErrInvalidDimensions", err)
}
return errRollback
})
if !errors.Is(err, errRollback) {
t.Fatalf("WithTx err = %v", err)
}
}
func TestClaimSkipsLockedRows(t *testing.T) {
pool := freshPool(t)
stationID := stationOfTypes(t, pool, "skip-locked",
typed("M-01", "Medium", app.LockerAvailable),
typed("M-02", "Medium", app.LockerAvailable),
)
first, err := pool.Begin(t.Context())
pgtest.RequireNoError(t, err, "begin first")
defer func() { _ = first.Rollback(context.WithoutCancel(t.Context())) }()
claimed, err := claim(t.Context(), New(first), stationID, mediumParcel)
pgtest.RequireNoError(t, err, "first claim")
if claimed.Locker.Label != "M-01" {
t.Fatalf("first claim took %s, want M-01", claimed.Locker.Label)
}
// Second transaction, first still open. A bounded context turns "blocked
// on a row lock" into a test failure instead of a hang.
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
second, err := pool.Begin(ctx)
pgtest.RequireNoError(t, err, "begin second")
defer func() { _ = second.Rollback(context.WithoutCancel(t.Context())) }()
skipped, err := claim(ctx, New(second), stationID, mediumParcel)
pgtest.RequireNoError(t, err, "second claim")
if skipped.Locker.Label != "M-02" {
t.Errorf("second claim took %s, want M-02 — it should skip the locked row", skipped.Locker.Label)
}
// Nothing left: the third claimer is rejected at once, not queued.
third, err := pool.Begin(ctx)
pgtest.RequireNoError(t, err, "begin third")
defer func() { _ = third.Rollback(context.WithoutCancel(t.Context())) }()
start := time.Now()
if _, err := claim(ctx, New(third), stationID, mediumParcel); err == nil {
t.Fatal("third claim succeeded — two transactions hold both lockers")
}
if waited := time.Since(start); waited > time.Second {
t.Errorf("third claim waited %s — it should skip locked rows, not block", waited)
}
}
// TestClaimDoesNotLockTheLockerTypeRow is the FOR UPDATE **OF l** proof, stated
// at the query where the clause lives (TDD §7).
//
// The allocation query joins locker_types. A plain FOR UPDATE would lock the
// matched type row as well, and every locker of a type shares one type row — so
// under SKIP LOCKED a second transaction would skip *every locker of that type*
// because one of them is mid-claim. That is a correctness bug wearing the
// costume of a performance detail, and it is invisible in any test whose
// lockers are of mixed types.
//
// Three transactions, three lockers, one type: all three must succeed.
func TestClaimDoesNotLockTheLockerTypeRow(t *testing.T) {
pool := freshPool(t)
stationID := stationOfTypes(t, pool, "type-row-not-locked",
typed("M-01", "Medium", app.LockerAvailable),
typed("M-02", "Medium", app.LockerAvailable),
typed("M-03", "Medium", app.LockerAvailable),
)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
// All three stay open to the end: the whole point is that transaction 2 and
// transaction 3 claim while transaction 1 still holds its row.
var open []pgx.Tx
defer func() {
for _, tx := range open {
_ = tx.Rollback(context.WithoutCancel(t.Context()))
}
}()
claimedLabels := make(map[string]bool, 3)
for i := range 3 {
tx, err := pool.Begin(ctx)
pgtest.RequireNoError(t, err, "begin")
open = append(open, tx)
got, err := claim(ctx, New(tx), stationID, mediumParcel)
if err != nil {
t.Fatalf("claim %d of 3 failed: %v — every locker of the type was skipped, so the type row is being locked too (FOR UPDATE, not FOR UPDATE OF l)", i+1, err)
}
if claimedLabels[got.Locker.Label] {
t.Fatalf("locker %s was claimed twice", got.Locker.Label)
}
claimedLabels[got.Locker.Label] = true
}
if len(claimedLabels) != 3 {
t.Errorf("claimed %v, want all three lockers", claimedLabels)
}
}
// claim runs the generated allocation query with the sorted, clearance-added
// triple the domain computes, which is what the repo does around it.
func claim(ctx context.Context, q *Queries, stationID uuid.UUID, pkg domain.Dimensions) (ClaimSmallestFittingLockerRow, error) {
need := pkg.RequiredInteriorMM()
return q.ClaimSmallestFittingLocker(ctx, ClaimSmallestFittingLockerParams{
StationID: stationID,
PkgMax: int32(need[0]), //nolint:gosec // bounded by MaxDimensionMM + clearance
PkgMid: int32(need[1]), //nolint:gosec // bounded by MaxDimensionMM + clearance
PkgMin: int32(need[2]), //nolint:gosec // bounded by MaxDimensionMM + clearance
})
}
// TestClaimAgreesWithTheDomainRule cross-checks the SQL against the Go
// statement of the same rule (TDD §4.1) over random inventories. Two
// expressions of one rule drift silently; this is what stops that.
//
// Both sides now speak millimetres, so the check is direct: no mapping between
// vocabularies stands between the query and the rule it implements.
func TestClaimAgreesWithTheDomainRule(t *testing.T) {
pool := freshPool(t)
tr := NewTxRunner(pool)
rng := rand.New(rand.NewPCG(7, 11)) //nolint:gosec // deterministic test input
typeNames := []string{"Small", "Medium", "Large", "Tower XL"}
statuses := []app.LockerStatus{app.LockerAvailable, app.LockerAvailable, app.LockerOccupied, app.LockerOutOfService}
labels := []string{"A-01", "A-02", "B-01", "B-02", "C-01"}
packages := []domain.Dimensions{shoebox, mediumParcel, bulkyParcel, pole, skiBox}
// The interiors the catalog holds, so the domain side sees the same estate
// the query does without reading it back.
interiors := map[string]domain.Dimensions{
"Small": {LengthMM: 300, WidthMM: 300, HeightMM: 450},
"Medium": {LengthMM: 450, WidthMM: 400, HeightMM: 600},
"Large": {LengthMM: 600, WidthMM: 600, HeightMM: 900},
"Tower XL": {LengthMM: 400, WidthMM: 400, HeightMM: 1500},
}
for round := range 40 {
inventory := make([]lockerSpec, 0, len(labels))
for _, label := range labels {
inventory = append(inventory,
typed(label, typeNames[rng.IntN(len(typeNames))], statuses[rng.IntN(len(statuses))]))
}
stationID := stationOfTypes(t, pool, "cross-check-"+string(rune('a'+round)), inventory...)
pkg := packages[rng.IntN(len(packages))]
// The domain rule sees exactly what the query considers: the free,
// in-service lockers at this station.
var candidates []domain.Locker
for _, l := range inventory {
if l.status == app.LockerAvailable {
candidates = append(candidates, domain.Locker{Label: l.label, Inner: interiors[l.typeName]})
}
}
wantLocker, wantErr := domain.SelectLocker(pkg, candidates)
err := tr.WithTx(t.Context(), func(r app.Repo) error {
got, err := r.ClaimSmallestFittingLocker(t.Context(), stationID, pkg)
switch {
case wantErr != nil && !errors.Is(err, domain.ErrNoLockerAvailable):
t.Errorf("round %d: domain rejected, SQL returned %s (%v)", round, got.Label, err)
case wantErr == nil && err != nil:
t.Errorf("round %d: domain chose %s, SQL errored: %v", round, wantLocker.Label, err)
case wantErr == nil && got.Label != wantLocker.Label:
t.Errorf("round %d: SQL chose %s, domain chose %s", round, got.Label, wantLocker.Label)
}
return errRollback
})
if !errors.Is(err, errRollback) {
t.Fatalf("round %d: WithTx err = %v", round, err)
}
}
}