oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendartest
2
3import (
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
14func 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
62func 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
101func 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
128func 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
148func 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.
172func 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
231func 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