package calendartest import ( "context" "errors" "testing" "time" calendar "amadan.net/oficina/calendar/internal/calendar" ) // The host's authority over a booked event (booking spec ยง5.5, B18): the // form may change what the booker never sees; everything that schedules // the meeting belongs to the booking flow. func confirmedBooking(t *testing.T) (*calendar.App, calendar.Person, calendar.Booking, calendar.EventInput) { t.Helper() 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", "ip"), now) if err != nil { t.Fatal(err) } if _, err := st.ConfirmBooking(ctx, b.ID, now); err != nil { t.Fatal(err) } ev, _ := st.Event(ctx, host.ID, b.EventID) // What a faithful re-submit of the host's form carries. in := calendar.EventInput{CalendarID: ev.CalendarID, Title: ev.Title, Location: ev.Location, Notes: ev.Notes, AllDay: ev.AllDay, StartLocal: ev.StartLocal, EndLocal: ev.EndLocal, TZ: ev.TZ, RRule: ev.RRule, Kind: ev.Kind, Status: ev.Status} return app, host, b, in } func TestTheHostMayChangeWhatTheBookerNeverSees(t *testing.T) { app, host, b, in := confirmedBooking(t) st := app.StoreForTest() ctx := context.Background() in.Title, in.Notes, in.Location = "Renamed", "private", "Room 9" if err := st.UpdateEvent(ctx, host.ID, b.EventID, in); err != nil { t.Fatalf("title-only edit refused: %v", err) } v, _ := st.BookingByID(ctx, b.ID, false) if v.Title != "Chat" { t.Errorf("the booking's snapshot changed with the event title: %q", v.Title) } } func TestTheHostMayNotMoveKindOrRepeatABookedMeeting(t *testing.T) { app, host, b, in := confirmedBooking(t) st := app.StoreForTest() ctx := context.Background() cases := []struct { name string mod func(*calendar.EventInput) }{ {"start", func(i *calendar.EventInput) { i.StartLocal = i.StartLocal[:11] + "23:00" }}, {"end", func(i *calendar.EventInput) { i.EndLocal = i.EndLocal[:11] + "23:30" }}, {"tz", func(i *calendar.EventInput) { i.TZ = "Asia/Tokyo" }}, {"kind", func(i *calendar.EventInput) { i.Kind = calendar.KindNote }}, {"repeat", func(i *calendar.EventInput) { i.RRule = "FREQ=WEEKLY" }}, {"tentative", func(i *calendar.EventInput) { i.Status = calendar.StatusTentative }}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { edit := in c.mod(&edit) err := st.UpdateEvent(ctx, host.ID, b.EventID, edit) if _, ok := calendar.Invalid(err); !ok { t.Errorf("edit to %s: err = %v, want a ValidationError", c.name, err) } }) } // All-day needs date-form strings with an exclusive NEXT-day end to // pass the ordinary validator, so the refusal below is the booking // rule's and not the validator's. edit := in nextDay, _ := time.Parse("2006-01-02", in.EndLocal[:10]) edit.AllDay, edit.StartLocal, edit.EndLocal = true, in.StartLocal[:10], nextDay.AddDate(0, 0, 1).Format("2006-01-02") if _, ok := calendar.Invalid(st.UpdateEvent(ctx, host.ID, b.EventID, edit)); !ok { t.Error("all-day edit of a booked meeting was accepted") } } func TestTheHostCancellingTellsTheBookerAndCannotRevive(t *testing.T) { app, host, b, in := confirmedBooking(t) st := app.StoreForTest() ctx := context.Background() in.Status = calendar.StatusCancelled if err := st.UpdateEvent(ctx, host.ID, b.EventID, in); err != nil { t.Fatalf("host cancel: %v", err) } v, _ := st.BookingByID(ctx, b.ID, false) if v.State != calendar.BookingCancelled || v.CancelledBy != calendar.CancelledByHost { t.Errorf("after host cancel: %s by %q", v.State, v.CancelledBy) } owed := st.UnsentBookingMailForTest(b.ID) if len(owed) != 1 || owed[0].Kind != calendar.MailCancelled || owed[0].Recipient != calendar.ToBooker { t.Errorf("owed = %+v, want one cancelled to the booker", owed) } in.Status = calendar.StatusConfirmed if _, ok := calendar.Invalid(st.UpdateEvent(ctx, host.ID, b.EventID, in)); !ok { t.Error("reviving a cancelled booking was accepted") } } func TestTheHostDeletingBumpsTheRevisionAndTellsTheBooker(t *testing.T) { app, host, b, _ := confirmedBooking(t) st := app.StoreForTest() ctx := context.Background() if err := st.DeleteEvent(ctx, host.ID, b.EventID); err != nil { t.Fatal(err) } v, err := st.BookingByID(ctx, b.ID, false) if err != nil { t.Fatal(err) } if v.State != calendar.BookingCancelled || v.Event.DeletedAt == nil || v.Event.Sequence != 2 || !v.Cancelled() { t.Errorf("after delete: %s deleted=%v seq %d", v.State, v.Event.DeletedAt != nil, v.Event.Sequence) } // A HELD booking on a deleted event just expires, telling nobody. _, _, page := hostWithPage(t) _ = page } func TestAHeldBookingKeepsItsTentativeStatusUnderTheHost(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) ev, _ := st.Event(ctx, host.ID, b.EventID) in := calendar.EventInput{CalendarID: ev.CalendarID, Title: "Renamed", AllDay: ev.AllDay, StartLocal: ev.StartLocal, EndLocal: ev.EndLocal, TZ: ev.TZ, Kind: ev.Kind, Status: ev.Status} if err := st.UpdateEvent(ctx, host.ID, b.EventID, in); err != nil { t.Errorf("title-only edit of a held event: %v", err) } in.Status = calendar.StatusConfirmed if _, ok := calendar.Invalid(st.UpdateEvent(ctx, host.ID, b.EventID, in)); !ok { t.Error("the host promoted a hold to confirmed") } if err := st.DeleteEvent(ctx, host.ID, b.EventID); err != nil { t.Fatal(err) } v, _ := st.BookingByID(ctx, b.ID, false) if v.State != calendar.BookingExpired || len(st.UnsentBookingMailForTest(b.ID)) != 0 { t.Errorf("held booking after host delete: %s, owed %d", v.State, len(st.UnsentBookingMailForTest(b.ID))) } } func TestAReminderThatFiredIsRebuiltByAReschedule(t *testing.T) { app, host, page := hostWithPage(t) st := app.StoreForTest() ctx := context.Background() now := time.Now() slots := slotsFor(t, app, page, host, now, 5) b, _ := st.HoldBooking(ctx, holdInput(page, host, slots[0], "doc@tito.io", "ip"), now) st.ConfirmBooking(ctx, b.ID, now) // Fire the default reminder by hand: claim it at its due time and // complete it. Its queue row is then GONE (single event). var due int64 app.DBForTest().QueryRow(`SELECT due_at FROM reminder_queues WHERE reminder_id IN (SELECT id FROM reminders WHERE event_id = ?)`, b.EventID).Scan(&due) d, ok, err := st.ClaimDueReminder(ctx, due) if err != nil || !ok { t.Fatalf("claim: %v %v", ok, err) } if err := st.CompleteReminder(ctx, d, due); err != nil { t.Fatal(err) } var n int64 app.DBForTest().QueryRow(`SELECT COUNT(*) FROM reminder_queues WHERE reminder_id = ?`, d.Queue.ReminderID).Scan(&n) if n != 0 { t.Fatalf("queue rows after firing a single event's reminder = %d, want 0", n) } // Move the meeting to a later slot: the RULE gets a new row (B26). last := slots[len(slots)-1] if changed, err := st.RescheduleBooking(ctx, b.ID, last.StartUTC, last.EndUTC, 1, now); err != nil || !changed { t.Fatalf("reschedule: %v %v", changed, err) } app.DBForTest().QueryRow(`SELECT COUNT(*) FROM reminder_queues WHERE reminder_id = ? AND fired_at IS NULL`, d.Queue.ReminderID).Scan(&n) if n != 1 { t.Errorf("queue rows after reschedule = %d, want the rule's row back", n) } _ = errors.Is }