| 1 | package calendartest |
| 2 | |
| 3 | import ( |
| 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. |
| 16 | func 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 | |
| 45 | func 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 | |
| 57 | func 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 | |
| 62 | func 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 | |
| 111 | func 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). |
| 145 | func 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 | |
| 172 | func 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 | |
| 224 | func 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 | |
| 260 | func 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 | |