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 "testing"
7 "time"
8
9 calendar "amadan.net/oficina/calendar/internal/calendar"
10)
11
12// The host's authority over a booked event (booking spec §5.5, B18): the
13// form may change what the booker never sees; everything that schedules
14// the meeting belongs to the booking flow.
15
16func confirmedBooking(t *testing.T) (*calendar.App, calendar.Person, calendar.Booking, calendar.EventInput) {
17 t.Helper()
18 app, host, page := hostWithPage(t)
19 st := app.StoreForTest()
20 ctx := context.Background()
21 now := time.Now()
22 slot := slotsFor(t, app, page, host, now, 3)[0]
23 b, err := st.HoldBooking(ctx, holdInput(page, host, slot, "doc@tito.io", "ip"), now)
24 if err != nil {
25 t.Fatal(err)
26 }
27 if _, err := st.ConfirmBooking(ctx, b.ID, now); err != nil {
28 t.Fatal(err)
29 }
30 ev, _ := st.Event(ctx, host.ID, b.EventID)
31 // What a faithful re-submit of the host's form carries.
32 in := calendar.EventInput{CalendarID: ev.CalendarID, Title: ev.Title, Location: ev.Location, Notes: ev.Notes,
33 AllDay: ev.AllDay, StartLocal: ev.StartLocal, EndLocal: ev.EndLocal, TZ: ev.TZ,
34 RRule: ev.RRule, Kind: ev.Kind, Status: ev.Status}
35 return app, host, b, in
36}
37
38func TestTheHostMayChangeWhatTheBookerNeverSees(t *testing.T) {
39 app, host, b, in := confirmedBooking(t)
40 st := app.StoreForTest()
41 ctx := context.Background()
42 in.Title, in.Notes, in.Location = "Renamed", "private", "Room 9"
43 if err := st.UpdateEvent(ctx, host.ID, b.EventID, in); err != nil {
44 t.Fatalf("title-only edit refused: %v", err)
45 }
46 v, _ := st.BookingByID(ctx, b.ID, false)
47 if v.Title != "Chat" {
48 t.Errorf("the booking's snapshot changed with the event title: %q", v.Title)
49 }
50}
51
52func TestTheHostMayNotMoveKindOrRepeatABookedMeeting(t *testing.T) {
53 app, host, b, in := confirmedBooking(t)
54 st := app.StoreForTest()
55 ctx := context.Background()
56 cases := []struct {
57 name string
58 mod func(*calendar.EventInput)
59 }{
60 {"start", func(i *calendar.EventInput) { i.StartLocal = i.StartLocal[:11] + "23:00" }},
61 {"end", func(i *calendar.EventInput) { i.EndLocal = i.EndLocal[:11] + "23:30" }},
62 {"tz", func(i *calendar.EventInput) { i.TZ = "Asia/Tokyo" }},
63 {"kind", func(i *calendar.EventInput) { i.Kind = calendar.KindNote }},
64 {"repeat", func(i *calendar.EventInput) { i.RRule = "FREQ=WEEKLY" }},
65 {"tentative", func(i *calendar.EventInput) { i.Status = calendar.StatusTentative }},
66 }
67 for _, c := range cases {
68 t.Run(c.name, func(t *testing.T) {
69 edit := in
70 c.mod(&edit)
71 err := st.UpdateEvent(ctx, host.ID, b.EventID, edit)
72 if _, ok := calendar.Invalid(err); !ok {
73 t.Errorf("edit to %s: err = %v, want a ValidationError", c.name, err)
74 }
75 })
76 }
77 // All-day needs date-form strings with an exclusive NEXT-day end to
78 // pass the ordinary validator, so the refusal below is the booking
79 // rule's and not the validator's.
80 edit := in
81 nextDay, _ := time.Parse("2006-01-02", in.EndLocal[:10])
82 edit.AllDay, edit.StartLocal, edit.EndLocal = true, in.StartLocal[:10], nextDay.AddDate(0, 0, 1).Format("2006-01-02")
83 if _, ok := calendar.Invalid(st.UpdateEvent(ctx, host.ID, b.EventID, edit)); !ok {
84 t.Error("all-day edit of a booked meeting was accepted")
85 }
86}
87
88func TestTheHostCancellingTellsTheBookerAndCannotRevive(t *testing.T) {
89 app, host, b, in := confirmedBooking(t)
90 st := app.StoreForTest()
91 ctx := context.Background()
92 in.Status = calendar.StatusCancelled
93 if err := st.UpdateEvent(ctx, host.ID, b.EventID, in); err != nil {
94 t.Fatalf("host cancel: %v", err)
95 }
96 v, _ := st.BookingByID(ctx, b.ID, false)
97 if v.State != calendar.BookingCancelled || v.CancelledBy != calendar.CancelledByHost {
98 t.Errorf("after host cancel: %s by %q", v.State, v.CancelledBy)
99 }
100 owed := st.UnsentBookingMailForTest(b.ID)
101 if len(owed) != 1 || owed[0].Kind != calendar.MailCancelled || owed[0].Recipient != calendar.ToBooker {
102 t.Errorf("owed = %+v, want one cancelled to the booker", owed)
103 }
104 in.Status = calendar.StatusConfirmed
105 if _, ok := calendar.Invalid(st.UpdateEvent(ctx, host.ID, b.EventID, in)); !ok {
106 t.Error("reviving a cancelled booking was accepted")
107 }
108}
109
110func TestTheHostDeletingBumpsTheRevisionAndTellsTheBooker(t *testing.T) {
111 app, host, b, _ := confirmedBooking(t)
112 st := app.StoreForTest()
113 ctx := context.Background()
114 if err := st.DeleteEvent(ctx, host.ID, b.EventID); err != nil {
115 t.Fatal(err)
116 }
117 v, err := st.BookingByID(ctx, b.ID, false)
118 if err != nil {
119 t.Fatal(err)
120 }
121 if v.State != calendar.BookingCancelled || v.Event.DeletedAt == nil || v.Event.Sequence != 2 || !v.Cancelled() {
122 t.Errorf("after delete: %s deleted=%v seq %d", v.State, v.Event.DeletedAt != nil, v.Event.Sequence)
123 }
124 // A HELD booking on a deleted event just expires, telling nobody.
125 _, _, page := hostWithPage(t)
126 _ = page
127}
128
129func TestAHeldBookingKeepsItsTentativeStatusUnderTheHost(t *testing.T) {
130 app, host, page := hostWithPage(t)
131 st := app.StoreForTest()
132 ctx := context.Background()
133 now := time.Now()
134 slot := slotsFor(t, app, page, host, now, 3)[0]
135 b, _ := st.HoldBooking(ctx, holdInput(page, host, slot, "doc@tito.io", "ip"), now)
136 ev, _ := st.Event(ctx, host.ID, b.EventID)
137 in := calendar.EventInput{CalendarID: ev.CalendarID, Title: "Renamed", AllDay: ev.AllDay,
138 StartLocal: ev.StartLocal, EndLocal: ev.EndLocal, TZ: ev.TZ, Kind: ev.Kind, Status: ev.Status}
139 if err := st.UpdateEvent(ctx, host.ID, b.EventID, in); err != nil {
140 t.Errorf("title-only edit of a held event: %v", err)
141 }
142 in.Status = calendar.StatusConfirmed
143 if _, ok := calendar.Invalid(st.UpdateEvent(ctx, host.ID, b.EventID, in)); !ok {
144 t.Error("the host promoted a hold to confirmed")
145 }
146 if err := st.DeleteEvent(ctx, host.ID, b.EventID); err != nil {
147 t.Fatal(err)
148 }
149 v, _ := st.BookingByID(ctx, b.ID, false)
150 if v.State != calendar.BookingExpired || len(st.UnsentBookingMailForTest(b.ID)) != 0 {
151 t.Errorf("held booking after host delete: %s, owed %d", v.State, len(st.UnsentBookingMailForTest(b.ID)))
152 }
153}
154
155func TestAReminderThatFiredIsRebuiltByAReschedule(t *testing.T) {
156 app, host, page := hostWithPage(t)
157 st := app.StoreForTest()
158 ctx := context.Background()
159 now := time.Now()
160 slots := slotsFor(t, app, page, host, now, 5)
161 b, _ := st.HoldBooking(ctx, holdInput(page, host, slots[0], "doc@tito.io", "ip"), now)
162 st.ConfirmBooking(ctx, b.ID, now)
163 // Fire the default reminder by hand: claim it at its due time and
164 // complete it. Its queue row is then GONE (single event).
165 var due int64
166 app.DBForTest().QueryRow(`SELECT due_at FROM reminder_queues WHERE reminder_id IN (SELECT id FROM reminders WHERE event_id = ?)`, b.EventID).Scan(&due)
167 d, ok, err := st.ClaimDueReminder(ctx, due)
168 if err != nil || !ok {
169 t.Fatalf("claim: %v %v", ok, err)
170 }
171 if err := st.CompleteReminder(ctx, d, due); err != nil {
172 t.Fatal(err)
173 }
174 var n int64
175 app.DBForTest().QueryRow(`SELECT COUNT(*) FROM reminder_queues WHERE reminder_id = ?`, d.Queue.ReminderID).Scan(&n)
176 if n != 0 {
177 t.Fatalf("queue rows after firing a single event's reminder = %d, want 0", n)
178 }
179 // Move the meeting to a later slot: the RULE gets a new row (B26).
180 last := slots[len(slots)-1]
181 if changed, err := st.RescheduleBooking(ctx, b.ID, last.StartUTC, last.EndUTC, 1, now); err != nil || !changed {
182 t.Fatalf("reschedule: %v %v", changed, err)
183 }
184 app.DBForTest().QueryRow(`SELECT COUNT(*) FROM reminder_queues WHERE reminder_id = ? AND fired_at IS NULL`, d.Queue.ReminderID).Scan(&n)
185 if n != 1 {
186 t.Errorf("queue rows after reschedule = %d, want the rule's row back", n)
187 }
188 _ = errors.Is
189}
190