package calendartest import ( "context" "errors" "sync" "testing" "time" calendar "amadan.net/oficina/calendar/internal/calendar" ) // hostWithPage is a host with a handle, a published every-day 9–5 page // of 30-minute slots, no buffer and no notice. The store's own zone // for the host is UTC, which keeps the arithmetic legible. func hostWithPage(t *testing.T) (*calendar.App, calendar.Person, calendar.BookingPageView) { t.Helper() app := newApp(t) host := aHost(t, app, "host@example.com") st := app.StoreForTest() ctx := context.Background() if err := st.SaveHandle(ctx, host.ID, "paul"); err != nil { t.Fatal(err) } ws, problems := calendar.ParseAvailability("every day 9-5") if len(problems) != 0 { t.Fatal(problems) } p, err := st.CreateBookingPage(ctx, host.ID, calendar.BookingPageInput{ Title: "Chat", Description: "Half an hour.", Availability: "every day 9-5", DurationMin: 30, BufferMin: 0, NoticeMin: 0, HorizonDays: 30}, ws) if err != nil { t.Fatal(err) } if err := st.PublishBookingPage(ctx, host.ID, p.ID, true); err != nil { t.Fatal(err) } page, err := st.BookingPage(ctx, host.ID, p.ID) if err != nil { t.Fatal(err) } return app, host, page } func slotsFor(t *testing.T, app *calendar.App, page calendar.BookingPageView, host calendar.Person, now time.Time, days int) []calendar.Slot { t.Helper() slots, err := app.StoreForTest().FreeSlots(context.Background(), page, host, now, now, now.AddDate(0, 0, days), 0, 0) if err != nil { t.Fatalf("FreeSlots: %v", err) } if len(slots) == 0 { t.Fatal("no free slots") } return slots } func holdInput(page calendar.BookingPageView, host calendar.Person, s calendar.Slot, address, ip string) calendar.HoldInput { return calendar.HoldInput{Page: page, Host: host, StartUTC: s.StartUTC, EndUTC: s.EndUTC, Name: "Doc", Address: address, Note: "About the thing", BookerZone: "UTC", IPFP: ip} } func TestAHoldIsATentativeEventTitledForTheBooker(t *testing.T) { app, host, page := hostWithPage(t) st := app.StoreForTest() ctx := context.Background() now := time.Now() slot := slotsFor(t, app, page, host, now, 3)[0] b, err := st.HoldBooking(ctx, holdInput(page, host, slot, "doc@tito.io", "ip1"), now) if err != nil { t.Fatalf("hold: %v", err) } ev, err := st.Event(ctx, host.ID, b.EventID) if err != nil { t.Fatal(err) } if ev.Status != calendar.StatusTentative || ev.Title != "Chat — Doc" || ev.Kind != calendar.KindMeeting { t.Errorf("held event = %q %q %q", ev.Status, ev.Title, ev.Kind) } if b.HoldExpiresAt != now.Add(calendar.HoldTTL).Unix() && b.HoldExpiresAt != slot.StartUTC { t.Errorf("hold expires %d, want min(now+30m, start)", b.HoldExpiresAt) } owed := st.UnsentBookingMailForTest(b.ID) if len(owed) != 1 || owed[0].Kind != calendar.MailHold || owed[0].Recipient != calendar.ToBooker { t.Errorf("owed mail = %+v, want one hold to the booker", owed) } v, err := st.BookingByID(ctx, b.ID, true) if err != nil || v.Token == "" || v.Title != "Chat" || v.Name != "Doc" || v.Address != "doc@tito.io" { t.Errorf("opened view = %+v err %v", v, err) } if got, err := st.BookingByToken(ctx, v.Token); err != nil || got.ID != b.ID { t.Errorf("BookingByToken: %v", err) } if _, err := st.BookingByToken(ctx, "0000"); !calendar.NotFound(err) { t.Errorf("wrong token: %v, want ErrNotFound", err) } if _, err := app.DBForTest().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { t.Fatal(err) } for _, needle := range []string{v.Token, "doc@tito.io", "About the thing", "Doc"} { if found, where := app.GrepDatabaseForTest(t, needle); found && needle != "Doc" { t.Errorf("%q is plaintext in %s", needle, where) } } // The same slot is gone for the next stranger. if _, err := st.HoldBooking(ctx, holdInput(page, host, slot, "other@example.com", "ip2"), now); !errors.Is(err, calendar.ErrSlotTaken) { t.Errorf("second hold: %v, want ErrSlotTaken", err) } } func TestTwentyStrangersOneSlotOneWinner(t *testing.T) { app, host, page := hostWithPage(t) st := app.StoreForTest() now := time.Now() slot := slotsFor(t, app, page, host, now, 3)[0] var wg sync.WaitGroup var mu sync.Mutex won, taken := 0, 0 for i := 0; i < 20; i++ { wg.Add(1) go func(i int) { defer wg.Done() _, err := st.HoldBooking(context.Background(), holdInput(page, host, slot, "s@example.com", "ip"+string(rune('a'+i))), now) mu.Lock() defer mu.Unlock() switch { case err == nil: won++ case errors.Is(err, calendar.ErrSlotTaken): taken++ default: t.Errorf("hold %d: %v", i, err) } }(i) } wg.Wait() if won != 1 || taken != 19 { t.Errorf("won %d taken %d, want 1 and 19", won, taken) } } // An ordinary CreateEvent by the host between the response-time slot // check and the transaction: the in-transaction re-check behind the // write lock catches it (B12, r7 f2). func TestAHostEventLandingMidHoldIsCaught(t *testing.T) { app, host, page := hostWithPage(t) st := app.StoreForTest() ctx := context.Background() now := time.Now() slot := slotsFor(t, app, page, host, now, 3)[0] calID, _ := st.DefaultCalendarID(ctx, host.ID) app.SetAfterSlotCheckForTest(func() { _, err := st.CreateEvent(ctx, host.ID, calendar.EventInput{CalendarID: calID, Title: "Squatter", StartLocal: time.Unix(slot.StartUTC, 0).UTC().Format("2006-01-02T15:04"), EndLocal: time.Unix(slot.EndUTC, 0).UTC().Format("2006-01-02T15:04"), TZ: "UTC", Kind: "meeting", Status: "confirmed"}) if err != nil { t.Error(err) } app.SetAfterSlotCheckForTest(nil) }) _, err := st.HoldBooking(ctx, holdInput(page, host, slot, "doc@tito.io", "ip"), now) if !errors.Is(err, calendar.ErrSlotTaken) { t.Fatalf("hold over a squatter: %v, want ErrSlotTaken", err) } var n int64 if err := app.DBForTest().QueryRow(`SELECT COUNT(*) FROM bookings`).Scan(&n); err != nil || n != 0 { t.Errorf("bookings = %d, want none", n) } } func TestConfirmIsAConditionalUpdateWithGuards(t *testing.T) { app, host, page := hostWithPage(t) st := app.StoreForTest() ctx := context.Background() now := time.Now() slot := slotsFor(t, app, page, host, now, 3)[0] b, _ := st.HoldBooking(ctx, holdInput(page, host, slot, "doc@tito.io", "ip"), now) changed, err := st.ConfirmBooking(ctx, b.ID, now) if err != nil || !changed { t.Fatalf("confirm: changed=%v err=%v", changed, err) } if changed, _ := st.ConfirmBooking(ctx, b.ID, now); changed { t.Error("a second confirm changed something") } v, _ := st.BookingByID(ctx, b.ID, false) if v.State != calendar.BookingConfirmed || v.Event.Status != calendar.StatusConfirmed || v.Event.Sequence != 1 { t.Errorf("after confirm: %s / %s / seq %d", v.State, v.Event.Status, v.Event.Sequence) } if att, _ := st.AttendeesFor(ctx, b.EventID); len(att) != 1 || att[0] != "doc@tito.io" { t.Errorf("attendees = %v", att) } // The hold mail still owed is FORGOTTEN by the confirmation, in its // own transaction: what remains is the one true row. owed := st.UnsentBookingMailForTest(b.ID) if len(owed) != 1 || owed[0].Kind != calendar.MailConfirmed || owed[0].Recipient != calendar.ToHost || owed[0].Revision != 1 { t.Errorf("owed = %+v", owed) } if rems, _ := st.RemindersFor(ctx, host.ID, b.EventID); len(rems) != 1 { t.Errorf("default reminder rows = %d, want 1 (host default is 10 minutes)", len(rems)) } // Lapsed: no change, and still held — the sweep moves it. b2, _ := st.HoldBooking(ctx, holdInput(page, host, slotsFor(t, app, page, host, now, 3)[1], "b@example.com", "ip"), now) if changed, _ := st.ConfirmBooking(ctx, b2.ID, now.Add(calendar.HoldTTL+time.Minute)); changed { t.Error("confirm after the hold lapsed changed something") } // Started: no change (B15). The hold is pushed a WEEK out, so the // expiry guard passes and only the start guard can refuse. v2, _ := st.BookingByID(ctx, b2.ID, false) if _, err := app.DBForTest().Exec(`UPDATE bookings SET hold_expires_at = ? WHERE id = ?`, now.AddDate(0, 0, 7).Unix(), b2.ID); err != nil { t.Fatal(err) } startedAt := time.Unix(v2.Event.StartUTC, 0) if changed, _ := st.ConfirmBooking(ctx, b2.ID, startedAt.Add(time.Minute)); changed { t.Error("confirm after the meeting started changed something") } if changed, _ := st.ConfirmBooking(ctx, b2.ID, startedAt.Add(-time.Minute)); !changed { t.Error("confirm a minute before the start was refused — the start guard is wrong") } } func TestRescheduleNamesItsRevisionAndCanReturnHome(t *testing.T) { app, host, page := hostWithPage(t) st := app.StoreForTest() ctx := context.Background() now := time.Now() slots := slotsFor(t, app, page, host, now, 3) b, _ := st.HoldBooking(ctx, holdInput(page, host, slots[0], "doc@tito.io", "ip"), now) st.ConfirmBooking(ctx, b.ID, now) changed, err := st.RescheduleBooking(ctx, b.ID, slots[2].StartUTC, slots[2].EndUTC, 1, now) if err != nil || !changed { t.Fatalf("reschedule: changed=%v err=%v", changed, err) } v, _ := st.BookingByID(ctx, b.ID, false) if v.Event.StartUTC != slots[2].StartUTC || v.Event.Sequence != 2 || v.Reschedules != 1 || v.Event.TZ != host.Zone { t.Errorf("after reschedule: start %d seq %d n %d tz %q", v.Event.StartUTC, v.Event.Sequence, v.Reschedules, v.Event.TZ) } if got, _ := calendar.ToUTC(v.Event.StartLocal, v.Event.TZ, false); got != v.Event.StartUTC { t.Error("start_local, tz and start_utc disagree after the move") } // The same form again, with its stale revision: nothing. if changed, _ := st.RescheduleBooking(ctx, b.ID, slots[3].StartUTC, slots[3].EndUTC, 1, now); changed { t.Error("a resubmitted form with a stale rev moved the meeting") } // Back to the original slot: its own event is not busy against it. if changed, err := st.RescheduleBooking(ctx, b.ID, slots[0].StartUTC, slots[0].EndUTC, 2, now); err != nil || !changed { t.Errorf("moving back: changed=%v err=%v", changed, err) } for i := 0; i < 3; i++ { st.RescheduleBooking(ctx, b.ID, slots[4+i].StartUTC, slots[4+i].EndUTC, 3+i, now) } if _, err := st.RescheduleBooking(ctx, b.ID, slots[8].StartUTC, slots[8].EndUTC, 6, now); !errors.Is(err, calendar.ErrOverCap) { t.Errorf("sixth reschedule: %v, want ErrOverCap", err) } } func TestCancelForgetsOwedMailAndIsConditional(t *testing.T) { app, host, page := hostWithPage(t) st := app.StoreForTest() ctx := context.Background() now := time.Now() slots := slotsFor(t, app, page, host, now, 3) b, _ := st.HoldBooking(ctx, holdInput(page, host, slots[0], "doc@tito.io", "ip"), now) st.ConfirmBooking(ctx, b.ID, now) st.RescheduleBooking(ctx, b.ID, slots[1].StartUTC, slots[1].EndUTC, 1, now) changed, err := st.CancelBooking(ctx, b.ID, calendar.CancelledByBooker, now) if err != nil || !changed { t.Fatalf("cancel: %v %v", changed, err) } v, _ := st.BookingByID(ctx, b.ID, false) if v.State != calendar.BookingCancelled || v.CancelledBy != calendar.CancelledByBooker || v.Event.Status != calendar.StatusCancelled || v.Event.Sequence != 3 { t.Errorf("after cancel: %+v seq %d", v.State, v.Event.Sequence) } owed := st.UnsentBookingMailForTest(b.ID) if len(owed) != 2 || owed[0].Kind != calendar.MailCancelled || owed[1].Kind != calendar.MailCancelled { t.Errorf("owed after cancel = %+v, want only the two cancelled rows", owed) } var n int64 app.DBForTest().QueryRow(`SELECT COUNT(*) FROM reminder_queues WHERE reminder_id IN (SELECT id FROM reminders WHERE event_id = ?)`, b.EventID).Scan(&n) if n != 0 { t.Errorf("reminder queue rows after cancel = %d", n) } if changed, _ := st.CancelBooking(ctx, b.ID, calendar.CancelledByBooker, now); changed { t.Error("a second cancel changed something") } }