duebook_test.gno
13.65 Kb · 492 lines
1package duebook
2
3import "testing"
4
5const (
6 alice = "g1alice"
7 bob = "g1bob"
8)
9
10// newBook is the default fixture: delay in [10, 1000], 8 open slots.
11func newBook(t *testing.T) *Book {
12 t.Helper()
13 b, err := New(10, 1000, 8)
14 if err != nil {
15 t.Fatalf("New: %v", err)
16 }
17 return b
18}
19
20// --- construction ---------------------------------------------------------
21
22func TestNewRejectsBadConfig(t *testing.T) {
23 cases := []struct {
24 name string
25 min, max int64
26 maxOpen int
27 }{
28 {"negative min", -1, 100, 4},
29 {"max below min", 100, 99, 4},
30 {"zero maxOpen", 0, 100, 0},
31 {"negative maxOpen", 0, 100, -1},
32 {"maxOpen over limit", 0, 100, MaxOpenLimit + 1},
33 }
34 for _, c := range cases {
35 if _, err := New(c.min, c.max, c.maxOpen); err != ErrInvalidConfig {
36 t.Errorf("%s: got %v, want ErrInvalidConfig", c.name, err)
37 }
38 }
39}
40
41func TestNewAcceptsZeroMinDelay(t *testing.T) {
42 b, err := New(0, 0, 1)
43 if err != nil {
44 t.Fatalf("New(0,0,1): %v", err)
45 }
46 id, err := b.Schedule(alice, "", 100, 0, 0)
47 if err != nil {
48 t.Fatalf("Schedule: %v", err)
49 }
50 // min == max == 0 means due immediately.
51 if _, err := b.Claim(id, 100); err != nil {
52 t.Fatalf("Claim at due instant: %v", err)
53 }
54}
55
56// --- scheduling -----------------------------------------------------------
57
58func TestScheduleComputesWindow(t *testing.T) {
59 b := newBook(t)
60 id, err := b.Schedule(alice, "payload", 1000, 50, 200)
61 if err != nil {
62 t.Fatalf("Schedule: %v", err)
63 }
64 d, ok := b.Get(id)
65 if !ok {
66 t.Fatal("Get: not found")
67 }
68 if d.CreatedAt != 1000 || d.DueAt != 1050 || d.ExpiresAt != 1250 {
69 t.Errorf("window = (%d,%d,%d), want (1000,1050,1250)", d.CreatedAt, d.DueAt, d.ExpiresAt)
70 }
71 if d.Owner != alice || d.Payload != "payload" {
72 t.Errorf("owner/payload = %q/%q", d.Owner, d.Payload)
73 }
74}
75
76func TestScheduleZeroTTLNeverExpires(t *testing.T) {
77 b := newBook(t)
78 id := b.MustSchedule(alice, "", 0, 10, 0)
79 d, _ := b.Get(id)
80 if d.ExpiresAt != 0 {
81 t.Fatalf("ExpiresAt = %d, want 0", d.ExpiresAt)
82 }
83 if d.IsExpired(1 << 62) {
84 t.Error("ttl==0 deferral reported expired")
85 }
86 // Still claimable in the far future.
87 if _, err := b.Claim(id, 1<<62); err != nil {
88 t.Fatalf("Claim far future: %v", err)
89 }
90}
91
92func TestScheduleValidation(t *testing.T) {
93 b := newBook(t)
94 big := make([]byte, MaxPayloadLen+1)
95 cases := []struct {
96 name string
97 owner string
98 payload string
99 now int64
100 delay int64
101 ttl int64
102 want error
103 }{
104 {"empty owner", "", "", 0, 10, 0, ErrEmptyOwner},
105 {"payload too big", alice, string(big), 0, 10, 0, ErrPayloadTooBig},
106 {"negative now", alice, "", -1, 10, 0, ErrInvalidNow},
107 {"delay below min", alice, "", 0, 9, 0, ErrInvalidDelay},
108 {"delay above max", alice, "", 0, 1001, 0, ErrInvalidDelay},
109 {"negative delay", alice, "", 0, -5, 0, ErrInvalidDelay},
110 {"negative ttl", alice, "", 0, 10, -1, ErrInvalidTTL},
111 }
112 for _, c := range cases {
113 if _, err := b.Schedule(c.owner, c.payload, c.now, c.delay, c.ttl); err != c.want {
114 t.Errorf("%s: got %v, want %v", c.name, err, c.want)
115 }
116 }
117 if b.OpenCount() != 0 {
118 t.Errorf("failed Schedules mutated the book: OpenCount = %d", b.OpenCount())
119 }
120 if b.NextID() != 1 {
121 t.Errorf("failed Schedules consumed IDs: NextID = %d", b.NextID())
122 }
123}
124
125func TestScheduleOverflowGuard(t *testing.T) {
126 b, err := New(0, 1<<62, 4)
127 if err != nil {
128 t.Fatalf("New: %v", err)
129 }
130 const huge = int64(1<<63 - 1)
131 // now + delay overflows.
132 if _, err := b.Schedule(alice, "", huge-10, 1<<62, 0); err != ErrOverflow {
133 t.Errorf("dueAt overflow: got %v, want ErrOverflow", err)
134 }
135 // dueAt + ttl overflows.
136 if _, err := b.Schedule(alice, "", huge-1<<62, 1<<62, huge); err != ErrOverflow {
137 t.Errorf("expiresAt overflow: got %v, want ErrOverflow", err)
138 }
139 if b.OpenCount() != 0 {
140 t.Errorf("overflow left state behind: OpenCount = %d", b.OpenCount())
141 }
142}
143
144func TestScheduleRespectsCap(t *testing.T) {
145 b, _ := New(0, 100, 2)
146 b.MustSchedule(alice, "", 0, 10, 0)
147 b.MustSchedule(alice, "", 0, 10, 0)
148 if _, err := b.Schedule(alice, "", 0, 10, 0); err != ErrBookFull {
149 t.Fatalf("third Schedule: got %v, want ErrBookFull", err)
150 }
151 // Consuming one frees exactly one slot.
152 b.MustClaim(1, 10)
153 if _, err := b.Schedule(alice, "", 0, 10, 0); err != nil {
154 t.Fatalf("Schedule after Claim: %v", err)
155 }
156 if _, err := b.Schedule(alice, "", 0, 10, 0); err != ErrBookFull {
157 t.Fatalf("cap not re-applied: got %v, want ErrBookFull", err)
158 }
159}
160
161// --- the central claim: exactly-once ---------------------------------------
162
163func TestClaimSucceedsExactlyOnce(t *testing.T) {
164 b := newBook(t)
165 id := b.MustSchedule(alice, "do-the-thing", 0, 10, 0)
166
167 d, err := b.Claim(id, 10)
168 if err != nil {
169 t.Fatalf("first Claim: %v", err)
170 }
171 if d.Payload != "do-the-thing" {
172 t.Errorf("payload = %q", d.Payload)
173 }
174 for i := 0; i < 5; i++ {
175 if _, err := b.Claim(id, 10); err != ErrNotFound {
176 t.Fatalf("replay %d: got %v, want ErrNotFound", i, err)
177 }
178 }
179}
180
181func TestIDsAreNeverReused(t *testing.T) {
182 b := newBook(t)
183 id1 := b.MustSchedule(alice, "", 0, 10, 0)
184 b.MustClaim(id1, 10)
185 if b.OpenCount() != 0 {
186 t.Fatalf("OpenCount = %d after claim", b.OpenCount())
187 }
188 // The book is empty again, but the next ID must still move forward.
189 id2 := b.MustSchedule(alice, "", 0, 10, 0)
190 if id2 == id1 {
191 t.Fatalf("ID %d reused after the book emptied", id2)
192 }
193 // So the old ID stays permanently unclaimable.
194 if _, err := b.Claim(id1, 10); err != ErrNotFound {
195 t.Errorf("old ID claimable again: %v", err)
196 }
197}
198
199func TestConsumedByAnyPathStaysConsumed(t *testing.T) {
200 // Every terminal path must close the ID to all three transitions.
201 paths := []struct {
202 name string
203 consume func(b *Book, id uint64)
204 }{
205 {"claim", func(b *Book, id uint64) { b.MustClaim(id, 10) }},
206 {"cancel", func(b *Book, id uint64) { b.MustCancel(id, alice) }},
207 {"expire", func(b *Book, id uint64) { b.MustExpire(id, 100) }},
208 }
209 for _, p := range paths {
210 b := newBook(t)
211 id := b.MustSchedule(alice, "", 0, 10, 50) // due 10, expires 60
212 p.consume(b, id)
213
214 if _, err := b.Claim(id, 10); err != ErrNotFound {
215 t.Errorf("%s then Claim: got %v, want ErrNotFound", p.name, err)
216 }
217 if _, err := b.Cancel(id, alice); err != ErrNotFound {
218 t.Errorf("%s then Cancel: got %v, want ErrNotFound", p.name, err)
219 }
220 if _, err := b.Expire(id, 100); err != ErrNotFound {
221 t.Errorf("%s then Expire: got %v, want ErrNotFound", p.name, err)
222 }
223 if _, ok := b.Get(id); ok {
224 t.Errorf("%s: deferral still visible", p.name)
225 }
226 }
227}
228
229// --- the claim window -----------------------------------------------------
230
231func TestClaimWindowIsHalfOpen(t *testing.T) {
232 // Schedule at 0, delay 10, ttl 5 => claimable on [10, 15).
233 cases := []struct {
234 now int64
235 want error
236 }{
237 {0, ErrNotDue},
238 {9, ErrNotDue},
239 {10, nil}, // due instant is claimable
240 {14, nil},
241 {15, ErrExpired}, // expiry instant is NOT claimable
242 {16, ErrExpired},
243 }
244 for _, c := range cases {
245 b := newBook(t)
246 id := b.MustSchedule(alice, "", 0, 10, 5)
247 _, err := b.Claim(id, c.now)
248 if err != c.want {
249 t.Errorf("Claim at now=%d: got %v, want %v", c.now, err, c.want)
250 }
251 // A refused Claim must not consume the deferral.
252 if c.want != nil {
253 if _, ok := b.Get(id); !ok {
254 t.Errorf("refused Claim at now=%d consumed the deferral", c.now)
255 }
256 }
257 }
258}
259
260func TestClaimRejectsNegativeNow(t *testing.T) {
261 b := newBook(t)
262 id := b.MustSchedule(alice, "", 0, 10, 0)
263 if _, err := b.Claim(id, -1); err != ErrInvalidNow {
264 t.Fatalf("got %v, want ErrInvalidNow", err)
265 }
266 if _, ok := b.Get(id); !ok {
267 t.Error("negative now consumed the deferral")
268 }
269}
270
271func TestClaimUnknownID(t *testing.T) {
272 b := newBook(t)
273 if _, err := b.Claim(999, 10); err != ErrNotFound {
274 t.Fatalf("got %v, want ErrNotFound", err)
275 }
276}
277
278// --- cancellation ---------------------------------------------------------
279
280func TestCancelOwnerOnly(t *testing.T) {
281 b := newBook(t)
282 id := b.MustSchedule(alice, "", 0, 10, 0)
283
284 if _, err := b.Cancel(id, bob); err != ErrNotOwner {
285 t.Fatalf("bob cancelling alice's deferral: got %v, want ErrNotOwner", err)
286 }
287 if _, ok := b.Get(id); !ok {
288 t.Fatal("refused Cancel consumed the deferral")
289 }
290 if _, err := b.Cancel(id, ""); err != ErrEmptyOwner {
291 t.Errorf("empty owner: got %v, want ErrEmptyOwner", err)
292 }
293 if _, err := b.Cancel(id, alice); err != nil {
294 t.Fatalf("owner cancel: %v", err)
295 }
296}
297
298func TestCancelWorksAfterDue(t *testing.T) {
299 // Cancellation is allowed while OPEN, not only while pending — an owner
300 // can still pull a deferral back after it became claimable, as long as
301 // nobody claimed it first.
302 b := newBook(t)
303 id := b.MustSchedule(alice, "", 0, 10, 0)
304 if _, err := b.Cancel(id, alice); err != nil {
305 t.Fatalf("cancel after due: %v", err)
306 }
307}
308
309func TestCancelLosesRaceToClaim(t *testing.T) {
310 b := newBook(t)
311 id := b.MustSchedule(alice, "", 0, 10, 0)
312 b.MustClaim(id, 10)
313 if _, err := b.Cancel(id, alice); err != ErrNotFound {
314 t.Fatalf("cancel after claim: got %v, want ErrNotFound", err)
315 }
316}
317
318// --- expiry ---------------------------------------------------------------
319
320func TestExpireRequiresExpiry(t *testing.T) {
321 b := newBook(t)
322 // Not yet expired.
323 id := b.MustSchedule(alice, "", 0, 10, 5) // expires 15
324 if _, err := b.Expire(id, 14); err != ErrNotExpired {
325 t.Errorf("at 14: got %v, want ErrNotExpired", err)
326 }
327 if _, err := b.Expire(id, 15); err != nil {
328 t.Errorf("at 15: %v", err)
329 }
330
331 // ttl == 0 can never be expired away.
332 never := b.MustSchedule(alice, "", 0, 10, 0)
333 if _, err := b.Expire(never, 1<<62); err != ErrNotExpired {
334 t.Errorf("no-expiry deferral: got %v, want ErrNotExpired", err)
335 }
336}
337
338func TestExpireIsPermissionless(t *testing.T) {
339 b := newBook(t)
340 id := b.MustSchedule(alice, "", 0, 10, 5)
341 // No owner argument at all — anyone may clear a dead entry.
342 if _, err := b.Expire(id, 20); err != nil {
343 t.Fatalf("Expire: %v", err)
344 }
345}
346
347func TestExpiredDeferralCannotBeClaimedOrGriefTheCap(t *testing.T) {
348 b, _ := New(0, 100, 1)
349 id := b.MustSchedule(alice, "", 0, 10, 5) // expires 15
350 // Cap is full and the entry is dead.
351 if _, err := b.Schedule(bob, "", 20, 10, 0); err != ErrBookFull {
352 t.Fatalf("got %v, want ErrBookFull", err)
353 }
354 if _, err := b.Claim(id, 20); err != ErrExpired {
355 t.Fatalf("got %v, want ErrExpired", err)
356 }
357 // Anyone can unstick it.
358 b.MustExpire(id, 20)
359 if _, err := b.Schedule(bob, "", 20, 10, 0); err != nil {
360 t.Fatalf("Schedule after Expire: %v", err)
361 }
362}
363
364// --- queries --------------------------------------------------------------
365
366func TestDueReturnsOnlyClaimableInIDOrder(t *testing.T) {
367 b := newBook(t)
368 b.MustSchedule(alice, "a", 0, 10, 0) // 1: due 10
369 b.MustSchedule(alice, "b", 0, 500, 0) // 2: due 500, not yet
370 b.MustSchedule(alice, "c", 0, 10, 5) // 3: due 10, expired at 15
371 b.MustSchedule(alice, "d", 0, 20, 0) // 4: due 20
372
373 got := b.Due(20, 10)
374 if len(got) != 2 {
375 t.Fatalf("Due returned %d entries, want 2", len(got))
376 }
377 if got[0].ID != 1 || got[1].ID != 4 {
378 t.Errorf("IDs = %d,%d; want 1,4", got[0].ID, got[1].ID)
379 }
380}
381
382func TestDueRespectsLimit(t *testing.T) {
383 b := newBook(t)
384 for i := 0; i < 5; i++ {
385 b.MustSchedule(alice, "", 0, 10, 0)
386 }
387 if got := b.Due(10, 2); len(got) != 2 {
388 t.Errorf("limit 2 returned %d", len(got))
389 }
390 if got := b.Due(10, 0); len(got) != 0 {
391 t.Errorf("limit 0 returned %d", len(got))
392 }
393 if got := b.Due(10, -1); len(got) != 0 {
394 t.Errorf("limit -1 returned %d", len(got))
395 }
396}
397
398func TestExpirable(t *testing.T) {
399 b := newBook(t)
400 b.MustSchedule(alice, "", 0, 10, 5) // 1: expires 15
401 b.MustSchedule(alice, "", 0, 10, 0) // 2: never expires
402 b.MustSchedule(alice, "", 0, 10, 5) // 3: expires 15
403
404 got := b.Expirable(15, 10)
405 if len(got) != 2 || got[0].ID != 1 || got[1].ID != 3 {
406 t.Fatalf("Expirable = %v", got)
407 }
408 if len(b.Expirable(14, 10)) != 0 {
409 t.Error("Expirable before expiry returned entries")
410 }
411}
412
413func TestIterateOpenIsIDOrdered(t *testing.T) {
414 b, _ := New(0, 100, 32)
415 // Schedule enough to cross the 1->2 digit boundary, where naive string
416 // keys would order "10" before "9".
417 for i := 0; i < 12; i++ {
418 b.MustSchedule(alice, "", 0, 1, 0)
419 }
420 prev := uint64(0)
421 n := 0
422 b.IterateOpen(func(d Deferral) bool {
423 if d.ID <= prev {
424 t.Fatalf("out of order: %d after %d", d.ID, prev)
425 }
426 prev = d.ID
427 n++
428 return false
429 })
430 if n != 12 {
431 t.Errorf("iterated %d, want 12", n)
432 }
433}
434
435func TestGetReturnsACopy(t *testing.T) {
436 b := newBook(t)
437 id := b.MustSchedule(alice, "original", 0, 10, 0)
438 d, _ := b.Get(id)
439 d.Payload = "tampered"
440 d.Owner = bob
441
442 again, _ := b.Get(id)
443 if again.Payload != "original" || again.Owner != alice {
444 t.Fatalf("mutating a returned Deferral changed the book: %+v", again)
445 }
446}
447
448// --- helpers --------------------------------------------------------------
449
450func TestIDKeyOrdering(t *testing.T) {
451 if idKey(9) >= idKey(10) {
452 t.Errorf("idKey(9)=%q not < idKey(10)=%q", idKey(9), idKey(10))
453 }
454 if len(idKey(1)) != 20 {
455 t.Errorf("idKey(1) width = %d, want 20", len(idKey(1)))
456 }
457 // Largest uint64 is exactly 20 digits and must not be truncated.
458 const maxU64 = ^uint64(0)
459 if got := idKey(maxU64); got != "18446744073709551615" {
460 t.Errorf("idKey(max) = %q", got)
461 }
462 if idKey(maxU64-1) >= idKey(maxU64) {
463 t.Error("ordering breaks at the top of the range")
464 }
465}
466
467func TestCheckedAdd(t *testing.T) {
468 const max = int64(1<<63 - 1)
469 const min = int64(-1 << 63)
470 if _, ok := checkedAdd(max, 1); ok {
471 t.Error("max+1 reported ok")
472 }
473 if _, ok := checkedAdd(min, -1); ok {
474 t.Error("min-1 reported ok")
475 }
476 if v, ok := checkedAdd(5, 7); !ok || v != 12 {
477 t.Errorf("5+7 = %d,%v", v, ok)
478 }
479}
480
481func TestDeferralPredicates(t *testing.T) {
482 d := Deferral{DueAt: 10, ExpiresAt: 20}
483 if d.IsDue(9) || !d.IsDue(10) {
484 t.Error("IsDue boundary")
485 }
486 if d.IsExpired(19) || !d.IsExpired(20) {
487 t.Error("IsExpired boundary")
488 }
489 if d.IsClaimable(9) || !d.IsClaimable(10) || !d.IsClaimable(19) || d.IsClaimable(20) {
490 t.Error("IsClaimable window")
491 }
492}