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 "errors"
6 "sync"
7 "testing"
8 "time"
9
10 calendar "amadan.net/oficina/calendar/internal/calendar"
11)
12
13// hostWithPage is a host with a handle, a published every-day 9–5 page
14// of 30-minute slots, no buffer and no notice. The store's own zone
15// for the host is UTC, which keeps the arithmetic legible.
16func hostWithPage(t *testing.T) (*calendar.App, calendar.Person, calendar.BookingPageView) {
17 t.Helper()
18 app := newApp(t)
19 host := aHost(t, app, "host@example.com")
20 st := app.StoreForTest()
21 ctx := context.Background()
22 if err := st.SaveHandle(ctx, host.ID, "paul"); err != nil {
23 t.Fatal(err)
24 }
25 ws, problems := calendar.ParseAvailability("every day 9-5")
26 if len(problems) != 0 {
27 t.Fatal(problems)
28 }
29 p, err := st.CreateBookingPage(ctx, host.ID, calendar.BookingPageInput{
30 Title: "Chat", Description: "Half an hour.", Availability: "every day 9-5",
31 DurationMin: 30, BufferMin: 0, NoticeMin: 0, HorizonDays: 30}, ws)
32 if err != nil {
33 t.Fatal(err)
34 }
35 if err := st.PublishBookingPage(ctx, host.ID, p.ID, true); err != nil {
36 t.Fatal(err)
37 }
38 page, err := st.BookingPage(ctx, host.ID, p.ID)
39 if err != nil {
40 t.Fatal(err)
41 }
42 return app, host, page
43}
44
45func slotsFor(t *testing.T, app *calendar.App, page calendar.BookingPageView, host calendar.Person, now time.Time, days int) []calendar.Slot {
46 t.Helper()
47 slots, err := app.StoreForTest().FreeSlots(context.Background(), page, host, now, now, now.AddDate(0, 0, days), 0, 0)
48 if err != nil {
49 t.Fatalf("FreeSlots: %v", err)
50 }
51 if len(slots) == 0 {
52 t.Fatal("no free slots")
53 }
54 return slots
55}
56
57func holdInput(page calendar.BookingPageView, host calendar.Person, s calendar.Slot, address, ip string) calendar.HoldInput {
58 return calendar.HoldInput{Page: page, Host: host, StartUTC: s.StartUTC, EndUTC: s.EndUTC,
59 Name: "Doc", Address: address, Note: "About the thing", BookerZone: "UTC", IPFP: ip}
60}
61
62func TestAHoldIsATentativeEventTitledForTheBooker(t *testing.T) {
63 app, host, page := hostWithPage(t)
64 st := app.StoreForTest()
65 ctx := context.Background()
66 now := time.Now()
67 slot := slotsFor(t, app, page, host, now, 3)[0]
68
69 b, err := st.HoldBooking(ctx, holdInput(page, host, slot, "doc@tito.io", "ip1"), now)
70 if err != nil {
71 t.Fatalf("hold: %v", err)
72 }
73 ev, err := st.Event(ctx, host.ID, b.EventID)
74 if err != nil {
75 t.Fatal(err)
76 }
77 if ev.Status != calendar.StatusTentative || ev.Title != "Chat — Doc" || ev.Kind != calendar.KindMeeting {
78 t.Errorf("held event = %q %q %q", ev.Status, ev.Title, ev.Kind)
79 }
80 if b.HoldExpiresAt != now.Add(calendar.HoldTTL).Unix() && b.HoldExpiresAt != slot.StartUTC {
81 t.Errorf("hold expires %d, want min(now+30m, start)", b.HoldExpiresAt)
82 }
83 owed := st.UnsentBookingMailForTest(b.ID)
84 if len(owed) != 1 || owed[0].Kind != calendar.MailHold || owed[0].Recipient != calendar.ToBooker {
85 t.Errorf("owed mail = %+v, want one hold to the booker", owed)
86 }
87 v, err := st.BookingByID(ctx, b.ID, true)
88 if err != nil || v.Token == "" || v.Title != "Chat" || v.Name != "Doc" || v.Address != "doc@tito.io" {
89 t.Errorf("opened view = %+v err %v", v, err)
90 }
91 if got, err := st.BookingByToken(ctx, v.Token); err != nil || got.ID != b.ID {
92 t.Errorf("BookingByToken: %v", err)
93 }
94 if _, err := st.BookingByToken(ctx, "0000"); !calendar.NotFound(err) {
95 t.Errorf("wrong token: %v, want ErrNotFound", err)
96 }
97 if _, err := app.DBForTest().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
98 t.Fatal(err)
99 }
100 for _, needle := range []string{v.Token, "doc@tito.io", "About the thing", "Doc"} {
101 if found, where := app.GrepDatabaseForTest(t, needle); found && needle != "Doc" {
102 t.Errorf("%q is plaintext in %s", needle, where)
103 }
104 }
105 // The same slot is gone for the next stranger.
106 if _, err := st.HoldBooking(ctx, holdInput(page, host, slot, "other@example.com", "ip2"), now); !errors.Is(err, calendar.ErrSlotTaken) {
107 t.Errorf("second hold: %v, want ErrSlotTaken", err)
108 }
109}
110
111func TestTwentyStrangersOneSlotOneWinner(t *testing.T) {
112 app, host, page := hostWithPage(t)
113 st := app.StoreForTest()
114 now := time.Now()
115 slot := slotsFor(t, app, page, host, now, 3)[0]
116 var wg sync.WaitGroup
117 var mu sync.Mutex
118 won, taken := 0, 0
119 for i := 0; i < 20; i++ {
120 wg.Add(1)
121 go func(i int) {
122 defer wg.Done()
123 _, err := st.HoldBooking(context.Background(), holdInput(page, host, slot, "s@example.com", "ip"+string(rune('a'+i))), now)
124 mu.Lock()
125 defer mu.Unlock()
126 switch {
127 case err == nil:
128 won++
129 case errors.Is(err, calendar.ErrSlotTaken):
130 taken++
131 default:
132 t.Errorf("hold %d: %v", i, err)
133 }
134 }(i)
135 }
136 wg.Wait()
137 if won != 1 || taken != 19 {
138 t.Errorf("won %d taken %d, want 1 and 19", won, taken)
139 }
140}
141
142// An ordinary CreateEvent by the host between the response-time slot
143// check and the transaction: the in-transaction re-check behind the
144// write lock catches it (B12, r7 f2).
145func TestAHostEventLandingMidHoldIsCaught(t *testing.T) {
146 app, host, page := hostWithPage(t)
147 st := app.StoreForTest()
148 ctx := context.Background()
149 now := time.Now()
150 slot := slotsFor(t, app, page, host, now, 3)[0]
151 calID, _ := st.DefaultCalendarID(ctx, host.ID)
152 app.SetAfterSlotCheckForTest(func() {
153 _, err := st.CreateEvent(ctx, host.ID, calendar.EventInput{CalendarID: calID, Title: "Squatter",
154 StartLocal: time.Unix(slot.StartUTC, 0).UTC().Format("2006-01-02T15:04"),
155 EndLocal: time.Unix(slot.EndUTC, 0).UTC().Format("2006-01-02T15:04"),
156 TZ: "UTC", Kind: "meeting", Status: "confirmed"})
157 if err != nil {
158 t.Error(err)
159 }
160 app.SetAfterSlotCheckForTest(nil)
161 })
162 _, err := st.HoldBooking(ctx, holdInput(page, host, slot, "doc@tito.io", "ip"), now)
163 if !errors.Is(err, calendar.ErrSlotTaken) {
164 t.Fatalf("hold over a squatter: %v, want ErrSlotTaken", err)
165 }
166 var n int64
167 if err := app.DBForTest().QueryRow(`SELECT COUNT(*) FROM bookings`).Scan(&n); err != nil || n != 0 {
168 t.Errorf("bookings = %d, want none", n)
169 }
170}
171
172func TestConfirmIsAConditionalUpdateWithGuards(t *testing.T) {
173 app, host, page := hostWithPage(t)
174 st := app.StoreForTest()
175 ctx := context.Background()
176 now := time.Now()
177 slot := slotsFor(t, app, page, host, now, 3)[0]
178 b, _ := st.HoldBooking(ctx, holdInput(page, host, slot, "doc@tito.io", "ip"), now)
179
180 changed, err := st.ConfirmBooking(ctx, b.ID, now)
181 if err != nil || !changed {
182 t.Fatalf("confirm: changed=%v err=%v", changed, err)
183 }
184 if changed, _ := st.ConfirmBooking(ctx, b.ID, now); changed {
185 t.Error("a second confirm changed something")
186 }
187 v, _ := st.BookingByID(ctx, b.ID, false)
188 if v.State != calendar.BookingConfirmed || v.Event.Status != calendar.StatusConfirmed || v.Event.Sequence != 1 {
189 t.Errorf("after confirm: %s / %s / seq %d", v.State, v.Event.Status, v.Event.Sequence)
190 }
191 if att, _ := st.AttendeesFor(ctx, b.EventID); len(att) != 1 || att[0] != "doc@tito.io" {
192 t.Errorf("attendees = %v", att)
193 }
194 // The hold mail still owed is FORGOTTEN by the confirmation, in its
195 // own transaction: what remains is the one true row.
196 owed := st.UnsentBookingMailForTest(b.ID)
197 if len(owed) != 1 || owed[0].Kind != calendar.MailConfirmed || owed[0].Recipient != calendar.ToHost || owed[0].Revision != 1 {
198 t.Errorf("owed = %+v", owed)
199 }
200 if rems, _ := st.RemindersFor(ctx, host.ID, b.EventID); len(rems) != 1 {
201 t.Errorf("default reminder rows = %d, want 1 (host default is 10 minutes)", len(rems))
202 }
203
204 // Lapsed: no change, and still held — the sweep moves it.
205 b2, _ := st.HoldBooking(ctx, holdInput(page, host, slotsFor(t, app, page, host, now, 3)[1], "b@example.com", "ip"), now)
206 if changed, _ := st.ConfirmBooking(ctx, b2.ID, now.Add(calendar.HoldTTL+time.Minute)); changed {
207 t.Error("confirm after the hold lapsed changed something")
208 }
209 // Started: no change (B15). The hold is pushed a WEEK out, so the
210 // expiry guard passes and only the start guard can refuse.
211 v2, _ := st.BookingByID(ctx, b2.ID, false)
212 if _, err := app.DBForTest().Exec(`UPDATE bookings SET hold_expires_at = ? WHERE id = ?`, now.AddDate(0, 0, 7).Unix(), b2.ID); err != nil {
213 t.Fatal(err)
214 }
215 startedAt := time.Unix(v2.Event.StartUTC, 0)
216 if changed, _ := st.ConfirmBooking(ctx, b2.ID, startedAt.Add(time.Minute)); changed {
217 t.Error("confirm after the meeting started changed something")
218 }
219 if changed, _ := st.ConfirmBooking(ctx, b2.ID, startedAt.Add(-time.Minute)); !changed {
220 t.Error("confirm a minute before the start was refused — the start guard is wrong")
221 }
222}
223
224func TestRescheduleNamesItsRevisionAndCanReturnHome(t *testing.T) {
225 app, host, page := hostWithPage(t)
226 st := app.StoreForTest()
227 ctx := context.Background()
228 now := time.Now()
229 slots := slotsFor(t, app, page, host, now, 3)
230 b, _ := st.HoldBooking(ctx, holdInput(page, host, slots[0], "doc@tito.io", "ip"), now)
231 st.ConfirmBooking(ctx, b.ID, now)
232
233 changed, err := st.RescheduleBooking(ctx, b.ID, slots[2].StartUTC, slots[2].EndUTC, 1, now)
234 if err != nil || !changed {
235 t.Fatalf("reschedule: changed=%v err=%v", changed, err)
236 }
237 v, _ := st.BookingByID(ctx, b.ID, false)
238 if v.Event.StartUTC != slots[2].StartUTC || v.Event.Sequence != 2 || v.Reschedules != 1 || v.Event.TZ != host.Zone {
239 t.Errorf("after reschedule: start %d seq %d n %d tz %q", v.Event.StartUTC, v.Event.Sequence, v.Reschedules, v.Event.TZ)
240 }
241 if got, _ := calendar.ToUTC(v.Event.StartLocal, v.Event.TZ, false); got != v.Event.StartUTC {
242 t.Error("start_local, tz and start_utc disagree after the move")
243 }
244 // The same form again, with its stale revision: nothing.
245 if changed, _ := st.RescheduleBooking(ctx, b.ID, slots[3].StartUTC, slots[3].EndUTC, 1, now); changed {
246 t.Error("a resubmitted form with a stale rev moved the meeting")
247 }
248 // Back to the original slot: its own event is not busy against it.
249 if changed, err := st.RescheduleBooking(ctx, b.ID, slots[0].StartUTC, slots[0].EndUTC, 2, now); err != nil || !changed {
250 t.Errorf("moving back: changed=%v err=%v", changed, err)
251 }
252 for i := 0; i < 3; i++ {
253 st.RescheduleBooking(ctx, b.ID, slots[4+i].StartUTC, slots[4+i].EndUTC, 3+i, now)
254 }
255 if _, err := st.RescheduleBooking(ctx, b.ID, slots[8].StartUTC, slots[8].EndUTC, 6, now); !errors.Is(err, calendar.ErrOverCap) {
256 t.Errorf("sixth reschedule: %v, want ErrOverCap", err)
257 }
258}
259
260func TestCancelForgetsOwedMailAndIsConditional(t *testing.T) {
261 app, host, page := hostWithPage(t)
262 st := app.StoreForTest()
263 ctx := context.Background()
264 now := time.Now()
265 slots := slotsFor(t, app, page, host, now, 3)
266 b, _ := st.HoldBooking(ctx, holdInput(page, host, slots[0], "doc@tito.io", "ip"), now)
267 st.ConfirmBooking(ctx, b.ID, now)
268 st.RescheduleBooking(ctx, b.ID, slots[1].StartUTC, slots[1].EndUTC, 1, now)
269
270 changed, err := st.CancelBooking(ctx, b.ID, calendar.CancelledByBooker, now)
271 if err != nil || !changed {
272 t.Fatalf("cancel: %v %v", changed, err)
273 }
274 v, _ := st.BookingByID(ctx, b.ID, false)
275 if v.State != calendar.BookingCancelled || v.CancelledBy != calendar.CancelledByBooker || v.Event.Status != calendar.StatusCancelled || v.Event.Sequence != 3 {
276 t.Errorf("after cancel: %+v seq %d", v.State, v.Event.Sequence)
277 }
278 owed := st.UnsentBookingMailForTest(b.ID)
279 if len(owed) != 2 || owed[0].Kind != calendar.MailCancelled || owed[1].Kind != calendar.MailCancelled {
280 t.Errorf("owed after cancel = %+v, want only the two cancelled rows", owed)
281 }
282 var n int64
283 app.DBForTest().QueryRow(`SELECT COUNT(*) FROM reminder_queues WHERE reminder_id IN (SELECT id FROM reminders WHERE event_id = ?)`, b.EventID).Scan(&n)
284 if n != 0 {
285 t.Errorf("reminder queue rows after cancel = %d", n)
286 }
287 if changed, _ := st.CancelBooking(ctx, b.ID, calendar.CancelledByBooker, now); changed {
288 t.Error("a second cancel changed something")
289 }
290}
291