| 1 | package calendartest |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "testing" |
| 6 | "time" |
| 7 | |
| 8 | calendar "amadan.net/oficina/calendar/internal/calendar" |
| 9 | ) |
| 10 | |
| 11 | // The two queues' identities and the sweep's arithmetic (booking spec |
| 12 | // §2.5, §6, §7.1, §8, B22). |
| 13 | |
| 14 | func TestBookingMailClaimsHaveAnIdentityAndALease(t *testing.T) { |
| 15 | app, host, page := hostWithPage(t) |
| 16 | st := app.StoreForTest() |
| 17 | ctx := context.Background() |
| 18 | now := time.Now() |
| 19 | b, _ := st.HoldBooking(ctx, holdInput(page, host, slotsFor(t, app, page, host, now, 3)[0], "doc@tito.io", "ip"), now) |
| 20 | |
| 21 | row, ok, err := st.ClaimBookingMail(ctx, now.Unix()) |
| 22 | if err != nil || !ok || row.BookingID != b.ID || row.ClaimID == "" { |
| 23 | t.Fatalf("claim: %+v %v %v", row, ok, err) |
| 24 | } |
| 25 | // Claimed: not claimable again inside the lease, and NextDueAt says |
| 26 | // the lease end, not now. |
| 27 | if _, ok, _ := st.ClaimBookingMail(ctx, now.Unix()+10); ok { |
| 28 | t.Error("a claimed row was claimed again inside its lease") |
| 29 | } |
| 30 | if next := st.NextBookingMailDue(ctx); next.Unix() != now.Unix()+300 { |
| 31 | t.Errorf("NextBookingMailDue = %v, want the lease end", next) |
| 32 | } |
| 33 | // A stale claim id cannot acknowledge it. |
| 34 | if ok, err := st.MarkBookingMailSent(ctx, row.ID, "not-the-claim", now.Unix()); err != nil || ok { |
| 35 | t.Fatalf("a stale claim id acknowledged the row: ok=%v err=%v", ok, err) |
| 36 | } |
| 37 | if len(st.UnsentBookingMailForTest(b.ID)) != 1 { |
| 38 | t.Error("a stale claim id acknowledged the row") |
| 39 | } |
| 40 | // Deferral keeps attempts at zero and is claimable again in 30s. |
| 41 | if ok, err := st.DeferBookingMail(ctx, row.ID, row.ClaimID, now.Unix()); err != nil || !ok { |
| 42 | t.Fatalf("defer: ok=%v err=%v", ok, err) |
| 43 | } |
| 44 | if again, ok, _ := st.ClaimBookingMail(ctx, now.Unix()+31); !ok || again.Attempts != 0 { |
| 45 | t.Errorf("after defer: ok=%v attempts=%d", ok, again.Attempts) |
| 46 | } else { |
| 47 | row = again |
| 48 | } |
| 49 | // Failures count, and the fifth gives up. |
| 50 | var gaveUp bool |
| 51 | for i := 0; i < calendar.MaxReminderAttempts; i++ { |
| 52 | gaveUp, err = st.MarkBookingMailFailed(ctx, row.ID, row.ClaimID) |
| 53 | if err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | } |
| 57 | if !gaveUp || len(st.UnsentBookingMailForTest(b.ID)) != 0 { |
| 58 | t.Errorf("after five failures: gaveUp=%v unsent=%d", gaveUp, len(st.UnsentBookingMailForTest(b.ID))) |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | func TestExpiredHoldsFreeTheirSlotAndForgetTheirMail(t *testing.T) { |
| 63 | app, host, page := hostWithPage(t) |
| 64 | st := app.StoreForTest() |
| 65 | ctx := context.Background() |
| 66 | now := time.Now() |
| 67 | // A slot well past the hold's own lapse, so it is still offerable |
| 68 | // once the hold has expired. |
| 69 | slot := slotsFor(t, app, page, host, now, 3)[4] |
| 70 | b, _ := st.HoldBooking(ctx, holdInput(page, host, slot, "doc@tito.io", "ip"), now) |
| 71 | if exp := st.NextHoldExpiry(ctx); exp.Unix() != b.HoldExpiresAt { |
| 72 | t.Errorf("NextHoldExpiry %v, want %d", exp, b.HoldExpiresAt) |
| 73 | } |
| 74 | later := time.Unix(b.HoldExpiresAt, 0).Add(time.Second) |
| 75 | // Lapsed but unswept: already not busy. |
| 76 | free, err := st.FreeSlots(ctx, page, host, later, later, later.AddDate(0, 0, 3), 0, 0) |
| 77 | if err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | if !containsSlot(free, slot) { |
| 81 | t.Errorf("a lapsed, unswept hold still blocks its slot") |
| 82 | } |
| 83 | n, err := st.ExpireHolds(ctx, later, 200) |
| 84 | if err != nil || n != 1 { |
| 85 | t.Fatalf("expire: n=%d err=%v", n, err) |
| 86 | } |
| 87 | if _, err := st.Event(ctx, host.ID, b.EventID); !calendar.NotFound(err) { |
| 88 | t.Error("the held event survived expiry") |
| 89 | } |
| 90 | if len(st.UnsentBookingMailForTest(b.ID)) != 0 { |
| 91 | t.Error("an expired hold still owes mail") |
| 92 | } |
| 93 | if v, _ := st.BookingByID(ctx, b.ID, false); v.State != calendar.BookingExpired { |
| 94 | t.Errorf("state %s, want expired", v.State) |
| 95 | } |
| 96 | if !st.NextHoldExpiry(ctx).IsZero() { |
| 97 | t.Error("NextHoldExpiry after the last hold expired is not zero") |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | func TestCapsCountEveryAttemptButOnlyOpenHolds(t *testing.T) { |
| 102 | app, host, page := hostWithPage(t) |
| 103 | st := app.StoreForTest() |
| 104 | ctx := context.Background() |
| 105 | now := time.Now() |
| 106 | slots := slotsFor(t, app, page, host, now, 5) |
| 107 | for i := 0; i < 3; i++ { |
| 108 | if _, err := st.HoldBooking(ctx, holdInput(page, host, slots[i], "s@example.com", "same-ip"), now); err != nil && i < 2 { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | } |
| 112 | // The third from one address is over the per-address cap of 2. |
| 113 | _, err := st.HoldBooking(ctx, holdInput(page, host, slots[3], "s@example.com", "same-ip"), now) |
| 114 | if err != calendar.ErrOverCap { |
| 115 | t.Errorf("third open hold for one address: %v, want ErrOverCap", err) |
| 116 | } |
| 117 | c, _ := st.HoldCountsFor(ctx, page.ID, "same-ip", "", now) |
| 118 | if c.OpenIP != 2 || c.AttemptsIP != 2 { |
| 119 | t.Errorf("counts = %+v", c) |
| 120 | } |
| 121 | st.ExpireHolds(ctx, now.Add(calendar.HoldTTL+time.Minute), 200) |
| 122 | c, _ = st.HoldCountsFor(ctx, page.ID, "same-ip", "", now.Add(calendar.HoldTTL+time.Minute)) |
| 123 | if c.OpenIP != 0 || c.AttemptsIP != 2 { |
| 124 | t.Errorf("counts after expiry = %+v, want open 0 attempts 2", c) |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | func TestIPFingerprintsAreForgottenOnSchedule(t *testing.T) { |
| 129 | app, host, page := hostWithPage(t) |
| 130 | st := app.StoreForTest() |
| 131 | ctx := context.Background() |
| 132 | now := time.Now() |
| 133 | b, _ := st.HoldBooking(ctx, holdInput(page, host, slotsFor(t, app, page, host, now, 3)[0], "doc@tito.io", "ip"), now) |
| 134 | if at := st.NextIPForget(ctx); at.Unix() != b.IPForgetAt { |
| 135 | t.Errorf("NextIPForget %v, want %d", at, b.IPForgetAt) |
| 136 | } |
| 137 | if n, _ := st.ForgetOldIPs(ctx, now, 500); n != 0 { |
| 138 | t.Error("forgot an IP early") |
| 139 | } |
| 140 | if n, _ := st.ForgetOldIPs(ctx, time.Unix(b.IPForgetAt, 0), 500); n != 1 { |
| 141 | t.Error("did not forget the IP at ip_forget_at") |
| 142 | } |
| 143 | if !st.NextIPForget(ctx).IsZero() { |
| 144 | t.Error("NextIPForget after forgetting is not zero") |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | func TestNextDueAtIsTheEarliestClaimableOfFourSources(t *testing.T) { |
| 149 | app, host, page := hostWithPage(t) |
| 150 | st := app.StoreForTest() |
| 151 | ctx := context.Background() |
| 152 | now := time.Now() |
| 153 | if !st.NextDueAt(ctx).IsZero() { |
| 154 | t.Fatal("an empty instance has work") |
| 155 | } |
| 156 | b, _ := st.HoldBooking(ctx, holdInput(page, host, slotsFor(t, app, page, host, now, 3)[0], "doc@tito.io", "ip"), now) |
| 157 | // Owed mail is due now; that wins. |
| 158 | if d := st.NextDueAt(ctx); d.Unix() != now.Unix() { |
| 159 | t.Errorf("with owed mail NextDueAt = %v, want now", d) |
| 160 | } |
| 161 | row, _, _ := st.ClaimBookingMail(ctx, now.Unix()) |
| 162 | st.MarkBookingMailSent(ctx, row.ID, row.ClaimID, now.Unix()) |
| 163 | // Then the hold's expiry. |
| 164 | if d := st.NextDueAt(ctx); d.Unix() != b.HoldExpiresAt { |
| 165 | t.Errorf("with a hold NextDueAt = %v, want its expiry %d", d, b.HoldExpiresAt) |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | // A reminder's claim is an identity, not a timestamp: a worker whose |
| 170 | // row was rewritten by a reschedule — even if another worker re-claimed |
| 171 | // it in the same second — cannot mark it, fail it or advance it. |
| 172 | func TestAReminderClaimOvertakenByARescheduleCompletesNothing(t *testing.T) { |
| 173 | app, host, page := hostWithPage(t) |
| 174 | st := app.StoreForTest() |
| 175 | ctx := context.Background() |
| 176 | now := time.Now() |
| 177 | slots := slotsFor(t, app, page, host, now, 5) |
| 178 | b, _ := st.HoldBooking(ctx, holdInput(page, host, slots[0], "doc@tito.io", "ip"), now) |
| 179 | st.ConfirmBooking(ctx, b.ID, now) |
| 180 | var due int64 |
| 181 | app.DBForTest().QueryRow(`SELECT due_at FROM reminder_queues WHERE reminder_id IN (SELECT id FROM reminders WHERE event_id = ?)`, b.EventID).Scan(&due) |
| 182 | |
| 183 | old, ok, _ := st.ClaimDueReminder(ctx, due) |
| 184 | if !ok { |
| 185 | t.Fatal("no claim") |
| 186 | } |
| 187 | // The booker moves the meeting: the row is rewritten, the claim cleared. |
| 188 | last := slots[len(slots)-1] |
| 189 | if changed, err := st.RescheduleBooking(ctx, b.ID, last.StartUTC, last.EndUTC, 1, now); err != nil || !changed { |
| 190 | t.Fatalf("reschedule: %v %v", changed, err) |
| 191 | } |
| 192 | // The old worker finishes: nothing happens. |
| 193 | if err := st.CompleteReminder(ctx, old, due); err != nil { |
| 194 | t.Fatal(err) |
| 195 | } |
| 196 | var fired *int64 |
| 197 | var rowDue int64 |
| 198 | app.DBForTest().QueryRow(`SELECT fired_at, due_at FROM reminder_queues WHERE reminder_id = ?`, old.Queue.ReminderID).Scan(&fired, &rowDue) |
| 199 | if fired != nil { |
| 200 | t.Error("an overtaken claim marked the replacement reminder fired") |
| 201 | } |
| 202 | if rowDue == due { |
| 203 | t.Error("the queue row still points at the old time") |
| 204 | } |
| 205 | // A deferral leaves due_at alone and the meeting time intact. |
| 206 | // Drain the reschedule's own mail so the reminder is the only work. |
| 207 | for { |
| 208 | m, ok, _ := st.ClaimBookingMail(ctx, now.Unix()) |
| 209 | if !ok { |
| 210 | break |
| 211 | } |
| 212 | st.MarkBookingMailSent(ctx, m.ID, m.ClaimID, now.Unix()) |
| 213 | } |
| 214 | // And the IP fingerprint, which is otherwise the earlier source. |
| 215 | st.ForgetOldIPs(ctx, time.Unix(b.IPForgetAt, 0), 500) |
| 216 | nu, ok, _ := st.ClaimDueReminder(ctx, rowDue) |
| 217 | if !ok { |
| 218 | t.Fatal("the rebuilt row is not claimable at its due time") |
| 219 | } |
| 220 | st.DeferReminder(ctx, nu.Queue.ID, nu.ClaimID, rowDue) |
| 221 | var after int64 |
| 222 | app.DBForTest().QueryRow(`SELECT due_at FROM reminder_queues WHERE id = ?`, nu.Queue.ID).Scan(&after) |
| 223 | if after != rowDue { |
| 224 | t.Error("a deferral rewrote due_at") |
| 225 | } |
| 226 | if next := st.NextDueAt(ctx); next.Unix() != rowDue+30 { |
| 227 | t.Errorf("NextDueAt after a deferral = %v, want retry_at", next) |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | func containsSlot(slots []calendar.Slot, want calendar.Slot) bool { |
| 232 | for _, s := range slots { |
| 233 | if s.StartUTC == want.StartUTC { |
| 234 | return true |
| 235 | } |
| 236 | } |
| 237 | return false |
| 238 | } |
| 239 | |