1package duebook23import "testing"45const (6 alice = "g1alice"7 bob = "g1bob"8)910// 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 b18}1920// --- construction ---------------------------------------------------------2122func TestNewRejectsBadConfig(t *testing.T) {23 cases := []struct {24 name string25 min, max int6426 maxOpen int27 }{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}4041func 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}5556// --- scheduling -----------------------------------------------------------5758func 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}7576func 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}9192func TestScheduleValidation(t *testing.T) {93 b := newBook(t)94 big := make([]byte, MaxPayloadLen+1)95 cases := []struct {96 name string97 owner string98 payload string99 now int64100 delay int64101 ttl int64102 want error103 }{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}124125func 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}143144func 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}160161// --- the central claim: exactly-once ---------------------------------------162163func TestClaimSucceedsExactlyOnce(t *testing.T) {164 b := newBook(t)165 id := b.MustSchedule(alice, "do-the-thing", 0, 10, 0)166167 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}180181func 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}198199func TestConsumedByAnyPathStaysConsumed(t *testing.T) {200 // Every terminal path must close the ID to all three transitions.201 paths := []struct {202 name string203 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 60212 p.consume(b, id)213214 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}228229// --- the claim window -----------------------------------------------------230231func TestClaimWindowIsHalfOpen(t *testing.T) {232 // Schedule at 0, delay 10, ttl 5 => claimable on [10, 15).233 cases := []struct {234 now int64235 want error236 }{237 {0, ErrNotDue},238 {9, ErrNotDue},239 {10, nil}, // due instant is claimable240 {14, nil},241 {15, ErrExpired}, // expiry instant is NOT claimable242 {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}259260func 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}270271func 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}277278// --- cancellation ---------------------------------------------------------279280func TestCancelOwnerOnly(t *testing.T) {281 b := newBook(t)282 id := b.MustSchedule(alice, "", 0, 10, 0)283284 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}297298func TestCancelWorksAfterDue(t *testing.T) {299 // Cancellation is allowed while OPEN, not only while pending — an owner300 // can still pull a deferral back after it became claimable, as long as301 // 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}308309func 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}317318// --- expiry ---------------------------------------------------------------319320func TestExpireRequiresExpiry(t *testing.T) {321 b := newBook(t)322 // Not yet expired.323 id := b.MustSchedule(alice, "", 0, 10, 5) // expires 15324 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 }330331 // 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}337338func 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}346347func TestExpiredDeferralCannotBeClaimedOrGriefTheCap(t *testing.T) {348 b, _ := New(0, 100, 1)349 id := b.MustSchedule(alice, "", 0, 10, 5) // expires 15350 // 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}363364// --- queries --------------------------------------------------------------365366func TestDueReturnsOnlyClaimableInIDOrder(t *testing.T) {367 b := newBook(t)368 b.MustSchedule(alice, "a", 0, 10, 0) // 1: due 10369 b.MustSchedule(alice, "b", 0, 500, 0) // 2: due 500, not yet370 b.MustSchedule(alice, "c", 0, 10, 5) // 3: due 10, expired at 15371 b.MustSchedule(alice, "d", 0, 20, 0) // 4: due 20372373 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}381382func 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}397398func TestExpirable(t *testing.T) {399 b := newBook(t)400 b.MustSchedule(alice, "", 0, 10, 5) // 1: expires 15401 b.MustSchedule(alice, "", 0, 10, 0) // 2: never expires402 b.MustSchedule(alice, "", 0, 10, 5) // 3: expires 15403404 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}412413func TestIterateOpenIsIDOrdered(t *testing.T) {414 b, _ := New(0, 100, 32)415 // Schedule enough to cross the 1->2 digit boundary, where naive string416 // 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 := 0422 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.ID427 n++428 return false429 })430 if n != 12 {431 t.Errorf("iterated %d, want 12", n)432 }433}434435func 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 = bob441442 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}447448// --- helpers --------------------------------------------------------------449450func 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}466467func 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}480481func 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}493Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.