package calendartest import ( "context" "testing" "time" calendar "amadan.net/oficina/calendar/internal/calendar" ) // The two queues' identities and the sweep's arithmetic (booking spec // §2.5, §6, §7.1, §8, B22). func TestBookingMailClaimsHaveAnIdentityAndALease(t *testing.T) { app, host, page := hostWithPage(t) st := app.StoreForTest() ctx := context.Background() now := time.Now() b, _ := st.HoldBooking(ctx, holdInput(page, host, slotsFor(t, app, page, host, now, 3)[0], "doc@tito.io", "ip"), now) row, ok, err := st.ClaimBookingMail(ctx, now.Unix()) if err != nil || !ok || row.BookingID != b.ID || row.ClaimID == "" { t.Fatalf("claim: %+v %v %v", row, ok, err) } // Claimed: not claimable again inside the lease, and NextDueAt says // the lease end, not now. if _, ok, _ := st.ClaimBookingMail(ctx, now.Unix()+10); ok { t.Error("a claimed row was claimed again inside its lease") } if next := st.NextBookingMailDue(ctx); next.Unix() != now.Unix()+300 { t.Errorf("NextBookingMailDue = %v, want the lease end", next) } // A stale claim id cannot acknowledge it. if ok, err := st.MarkBookingMailSent(ctx, row.ID, "not-the-claim", now.Unix()); err != nil || ok { t.Fatalf("a stale claim id acknowledged the row: ok=%v err=%v", ok, err) } if len(st.UnsentBookingMailForTest(b.ID)) != 1 { t.Error("a stale claim id acknowledged the row") } // Deferral keeps attempts at zero and is claimable again in 30s. if ok, err := st.DeferBookingMail(ctx, row.ID, row.ClaimID, now.Unix()); err != nil || !ok { t.Fatalf("defer: ok=%v err=%v", ok, err) } if again, ok, _ := st.ClaimBookingMail(ctx, now.Unix()+31); !ok || again.Attempts != 0 { t.Errorf("after defer: ok=%v attempts=%d", ok, again.Attempts) } else { row = again } // Failures count, and the fifth gives up. var gaveUp bool for i := 0; i < calendar.MaxReminderAttempts; i++ { gaveUp, err = st.MarkBookingMailFailed(ctx, row.ID, row.ClaimID) if err != nil { t.Fatal(err) } } if !gaveUp || len(st.UnsentBookingMailForTest(b.ID)) != 0 { t.Errorf("after five failures: gaveUp=%v unsent=%d", gaveUp, len(st.UnsentBookingMailForTest(b.ID))) } } func TestExpiredHoldsFreeTheirSlotAndForgetTheirMail(t *testing.T) { app, host, page := hostWithPage(t) st := app.StoreForTest() ctx := context.Background() now := time.Now() // A slot well past the hold's own lapse, so it is still offerable // once the hold has expired. slot := slotsFor(t, app, page, host, now, 3)[4] b, _ := st.HoldBooking(ctx, holdInput(page, host, slot, "doc@tito.io", "ip"), now) if exp := st.NextHoldExpiry(ctx); exp.Unix() != b.HoldExpiresAt { t.Errorf("NextHoldExpiry %v, want %d", exp, b.HoldExpiresAt) } later := time.Unix(b.HoldExpiresAt, 0).Add(time.Second) // Lapsed but unswept: already not busy. free, err := st.FreeSlots(ctx, page, host, later, later, later.AddDate(0, 0, 3), 0, 0) if err != nil { t.Fatal(err) } if !containsSlot(free, slot) { t.Errorf("a lapsed, unswept hold still blocks its slot") } n, err := st.ExpireHolds(ctx, later, 200) if err != nil || n != 1 { t.Fatalf("expire: n=%d err=%v", n, err) } if _, err := st.Event(ctx, host.ID, b.EventID); !calendar.NotFound(err) { t.Error("the held event survived expiry") } if len(st.UnsentBookingMailForTest(b.ID)) != 0 { t.Error("an expired hold still owes mail") } if v, _ := st.BookingByID(ctx, b.ID, false); v.State != calendar.BookingExpired { t.Errorf("state %s, want expired", v.State) } if !st.NextHoldExpiry(ctx).IsZero() { t.Error("NextHoldExpiry after the last hold expired is not zero") } } func TestCapsCountEveryAttemptButOnlyOpenHolds(t *testing.T) { app, host, page := hostWithPage(t) st := app.StoreForTest() ctx := context.Background() now := time.Now() slots := slotsFor(t, app, page, host, now, 5) for i := 0; i < 3; i++ { if _, err := st.HoldBooking(ctx, holdInput(page, host, slots[i], "s@example.com", "same-ip"), now); err != nil && i < 2 { t.Fatal(err) } } // The third from one address is over the per-address cap of 2. _, err := st.HoldBooking(ctx, holdInput(page, host, slots[3], "s@example.com", "same-ip"), now) if err != calendar.ErrOverCap { t.Errorf("third open hold for one address: %v, want ErrOverCap", err) } c, _ := st.HoldCountsFor(ctx, page.ID, "same-ip", "", now) if c.OpenIP != 2 || c.AttemptsIP != 2 { t.Errorf("counts = %+v", c) } st.ExpireHolds(ctx, now.Add(calendar.HoldTTL+time.Minute), 200) c, _ = st.HoldCountsFor(ctx, page.ID, "same-ip", "", now.Add(calendar.HoldTTL+time.Minute)) if c.OpenIP != 0 || c.AttemptsIP != 2 { t.Errorf("counts after expiry = %+v, want open 0 attempts 2", c) } } func TestIPFingerprintsAreForgottenOnSchedule(t *testing.T) { app, host, page := hostWithPage(t) st := app.StoreForTest() ctx := context.Background() now := time.Now() b, _ := st.HoldBooking(ctx, holdInput(page, host, slotsFor(t, app, page, host, now, 3)[0], "doc@tito.io", "ip"), now) if at := st.NextIPForget(ctx); at.Unix() != b.IPForgetAt { t.Errorf("NextIPForget %v, want %d", at, b.IPForgetAt) } if n, _ := st.ForgetOldIPs(ctx, now, 500); n != 0 { t.Error("forgot an IP early") } if n, _ := st.ForgetOldIPs(ctx, time.Unix(b.IPForgetAt, 0), 500); n != 1 { t.Error("did not forget the IP at ip_forget_at") } if !st.NextIPForget(ctx).IsZero() { t.Error("NextIPForget after forgetting is not zero") } } func TestNextDueAtIsTheEarliestClaimableOfFourSources(t *testing.T) { app, host, page := hostWithPage(t) st := app.StoreForTest() ctx := context.Background() now := time.Now() if !st.NextDueAt(ctx).IsZero() { t.Fatal("an empty instance has work") } b, _ := st.HoldBooking(ctx, holdInput(page, host, slotsFor(t, app, page, host, now, 3)[0], "doc@tito.io", "ip"), now) // Owed mail is due now; that wins. if d := st.NextDueAt(ctx); d.Unix() != now.Unix() { t.Errorf("with owed mail NextDueAt = %v, want now", d) } row, _, _ := st.ClaimBookingMail(ctx, now.Unix()) st.MarkBookingMailSent(ctx, row.ID, row.ClaimID, now.Unix()) // Then the hold's expiry. if d := st.NextDueAt(ctx); d.Unix() != b.HoldExpiresAt { t.Errorf("with a hold NextDueAt = %v, want its expiry %d", d, b.HoldExpiresAt) } } // A reminder's claim is an identity, not a timestamp: a worker whose // row was rewritten by a reschedule — even if another worker re-claimed // it in the same second — cannot mark it, fail it or advance it. func TestAReminderClaimOvertakenByARescheduleCompletesNothing(t *testing.T) { app, host, page := hostWithPage(t) st := app.StoreForTest() ctx := context.Background() now := time.Now() slots := slotsFor(t, app, page, host, now, 5) b, _ := st.HoldBooking(ctx, holdInput(page, host, slots[0], "doc@tito.io", "ip"), now) st.ConfirmBooking(ctx, b.ID, now) var due int64 app.DBForTest().QueryRow(`SELECT due_at FROM reminder_queues WHERE reminder_id IN (SELECT id FROM reminders WHERE event_id = ?)`, b.EventID).Scan(&due) old, ok, _ := st.ClaimDueReminder(ctx, due) if !ok { t.Fatal("no claim") } // The booker moves the meeting: the row is rewritten, the claim cleared. last := slots[len(slots)-1] if changed, err := st.RescheduleBooking(ctx, b.ID, last.StartUTC, last.EndUTC, 1, now); err != nil || !changed { t.Fatalf("reschedule: %v %v", changed, err) } // The old worker finishes: nothing happens. if err := st.CompleteReminder(ctx, old, due); err != nil { t.Fatal(err) } var fired *int64 var rowDue int64 app.DBForTest().QueryRow(`SELECT fired_at, due_at FROM reminder_queues WHERE reminder_id = ?`, old.Queue.ReminderID).Scan(&fired, &rowDue) if fired != nil { t.Error("an overtaken claim marked the replacement reminder fired") } if rowDue == due { t.Error("the queue row still points at the old time") } // A deferral leaves due_at alone and the meeting time intact. // Drain the reschedule's own mail so the reminder is the only work. for { m, ok, _ := st.ClaimBookingMail(ctx, now.Unix()) if !ok { break } st.MarkBookingMailSent(ctx, m.ID, m.ClaimID, now.Unix()) } // And the IP fingerprint, which is otherwise the earlier source. st.ForgetOldIPs(ctx, time.Unix(b.IPForgetAt, 0), 500) nu, ok, _ := st.ClaimDueReminder(ctx, rowDue) if !ok { t.Fatal("the rebuilt row is not claimable at its due time") } st.DeferReminder(ctx, nu.Queue.ID, nu.ClaimID, rowDue) var after int64 app.DBForTest().QueryRow(`SELECT due_at FROM reminder_queues WHERE id = ?`, nu.Queue.ID).Scan(&after) if after != rowDue { t.Error("a deferral rewrote due_at") } if next := st.NextDueAt(ctx); next.Unix() != rowDue+30 { t.Errorf("NextDueAt after a deferral = %v, want retry_at", next) } } func containsSlot(slots []calendar.Slot, want calendar.Slot) bool { for _, s := range slots { if s.StartUTC == want.StartUTC { return true } } return false }