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 "net/http"
6 "net/http/httptest"
7 "net/url"
8 "regexp"
9 "strings"
10 "testing"
11 "time"
12
13 calendar "amadan.net/oficina/calendar/internal/calendar"
14)
15
16// A stranger's whole journey, through the router, with no session and
17// no script (booking spec §5).
18
19// publishedPage is a host with a handle and a published every-day 9–5
20// page, seen through a picky mailer so the tests can read what went out.
21func publishedPage(t *testing.T) (*calendar.App, *pickyMailer, string, calendar.Person) {
22 t.Helper()
23 return publishedPageTitled(t, "Quick chat", "Half an hour.", "every day 9-5")
24}
25
26func publishedPageTitled(t *testing.T, title, desc, avail string) (*calendar.App, *pickyMailer, string, calendar.Person) {
27 t.Helper()
28 mailer := &pickyMailer{}
29 app := newAppWithMailer(t, mailer)
30 session := app.SignInForTest(t, "host@example.com")
31 h := app.Handler()
32 postAs(t, h, session, "/settings", url.Values{"zone": {"UTC"}, "week_start": {"1"},
33 "default_reminder_min": {"10"}, "default_all_day_min": {"0"}, "handle": {"paul"}})
34 rec := postAs(t, h, session, "/settings/booking", url.Values{"title": {title}, "description": {desc},
35 "availability": {avail}, "duration_min": {"30"}, "buffer_min": {"0"}, "notice_min": {"0"}, "horizon_days": {"30"}})
36 if rec.Code != http.StatusSeeOther {
37 t.Fatalf("create page: %d\n%s", rec.Code, rec.Body.String())
38 }
39 postAs(t, h, session, "/settings/booking/1/publish", url.Values{"publish": {"1"}})
40 body := getAs(t, h, session, "/settings/booking").Body.String()
41 m := regexp.MustCompile(`/book/paul/[a-z0-9]{8}`).FindString(body)
42 if m == "" {
43 t.Fatalf("no public URL on the list:\n%s", body)
44 }
45 mailer.reset()
46 host := app.PersonByAddressForTest(t, "host@example.com")
47 return app, mailer, m, host
48}
49
50func strangerGet(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder {
51 t.Helper()
52 rec := httptest.NewRecorder()
53 h.ServeHTTP(rec, calendar.RequestForTest(nil, http.MethodGet, target, nil))
54 return rec
55}
56
57func strangerPost(t *testing.T, h http.Handler, target string, form url.Values) *httptest.ResponseRecorder {
58 t.Helper()
59 rec := httptest.NewRecorder()
60 h.ServeHTTP(rec, calendar.RequestForTest(nil, http.MethodPost, target, form))
61 return rec
62}
63
64var (
65 reStart = regexp.MustCompile(`name="start" value="(\d+)"`)
66 reManage = regexp.MustCompile(`/book/m/([0-9a-f]{64})`)
67 reDay = regexp.MustCompile(`\?day=(\d{4}-\d{2}-\d{2})`)
68)
69
70// futureDaySlots uses a whole future day so a late test run still has
71// enough slots for the hold-limit and rescheduling tests.
72func futureDaySlots(t *testing.T, h http.Handler, path string) (string, []string) {
73 t.Helper()
74 page := strangerGet(t, h, path)
75 if page.Code != 200 {
76 t.Fatalf("page: %d\n%s", page.Code, page.Body.String())
77 }
78 today := time.Now().UTC().Format("2006-01-02")
79 selected := ""
80 for _, match := range reDay.FindAllStringSubmatch(page.Body.String(), -1) {
81 if match[1] > today {
82 selected = match[1]
83 break
84 }
85 }
86 if selected == "" {
87 t.Fatalf("no future day links:\n%s", page.Body.String())
88 }
89 dayPath := path + "?day=" + selected + "&zone=UTC"
90 day := strangerGet(t, h, dayPath)
91 var starts []string
92 for _, s := range reStart.FindAllStringSubmatch(day.Body.String(), -1) {
93 starts = append(starts, s[1])
94 }
95 if len(starts) == 0 {
96 t.Fatalf("no slots on the day page:\n%s", day.Body.String())
97 }
98 return dayPath, starts
99}
100
101func holdForm(start, name, address string) url.Values {
102 return url.Values{"start": {start}, "zone": {"UTC"}, "name": {name}, "address": {address}, "note": {"About the thing"}}
103}
104
105func TestAStrangerHoldsAndOnlyTheButtonConfirms(t *testing.T) {
106 app, mailer, path, _ := publishedPage(t)
107 h := app.Handler()
108 _, starts := futureDaySlots(t, h, path)
109
110 // Step one: the slot pick, no name yet.
111 pick := strangerPost(t, h, path, url.Values{"start": {starts[0]}, "zone": {"UTC"}})
112 if pick.Code != 200 || !strings.Contains(pick.Body.String(), `name="name"`) {
113 t.Fatalf("slot pick: %d\n%s", pick.Code, pick.Body.String())
114 }
115 // Step two: the hold.
116 rec := strangerPost(t, h, path, holdForm(starts[0], "Doc", "doc@tito.io"))
117 if rec.Code != 200 || !strings.Contains(rec.Body.String(), "doc@tito.io") {
118 t.Fatalf("hold: %d\n%s", rec.Code, rec.Body.String())
119 }
120 if n := mailer.count(); n != 1 || mailer.to(0) != "doc@tito.io" {
121 t.Fatalf("mails after hold = %d to %q, want one to the booker", n, mailer.to(0))
122 }
123 link := reManage.FindString(mailer.body(0))
124 if link == "" {
125 t.Fatalf("no manage link in:\n%s", mailer.body(0))
126 }
127 // The link's GET changes nothing.
128 page := strangerGet(t, h, link)
129 if page.Code != 200 || !strings.Contains(page.Body.String(), "Confirm this booking") {
130 t.Fatalf("link GET: %d\n%s", page.Code, page.Body.String())
131 }
132 if mailer.count() != 1 {
133 t.Error("a GET of the link mailed somebody")
134 }
135 // The button confirms, once.
136 man := strangerPost(t, h, link+"/confirm", nil)
137 if man.Code != 200 || !strings.Contains(man.Body.String(), "Reschedule") {
138 t.Fatalf("confirm: %d\n%s", man.Code, man.Body.String())
139 }
140 if mailer.count() != 2 || mailer.to(1) != "host@example.com" || !strings.Contains(mailer.body(1), "Doc") {
141 t.Errorf("host mail after confirm: count %d to %q", mailer.count(), mailer.to(1))
142 }
143 strangerPost(t, h, link+"/confirm", nil)
144 if mailer.count() != 2 {
145 t.Error("confirming twice mailed the host twice")
146 }
147 if page.Header().Get("Cache-Control") != "no-store" || page.Header().Get("Referrer-Policy") != "no-referrer" {
148 t.Error("public responses are missing no-store / no-referrer")
149 }
150}
151
152func TestTheICSIsARequestWithNoManagementLinkAndNoHostPrivateFields(t *testing.T) {
153 app, mailer, path, host := publishedPage(t)
154 h := app.Handler()
155 st := app.StoreForTest()
156 ctx := context.Background()
157 _, starts := futureDaySlots(t, h, path)
158 strangerPost(t, h, path, holdForm(starts[0], "Doe, Jane \"JJ\": Support", "doc@tito.io"))
159 link := reManage.FindString(mailer.body(0))
160 strangerPost(t, h, link+"/confirm", nil)
161
162 // The host edits everything private.
163 v, _ := st.BookingByToken(ctx, strings.TrimPrefix(link, "/book/m/"))
164 ev, _ := st.Event(ctx, host.ID, v.EventID)
165 in := calendar.EventInput{CalendarID: ev.CalendarID, Title: ev.Title, Location: "Room 9", Notes: "private",
166 AllDay: ev.AllDay, StartLocal: ev.StartLocal, EndLocal: ev.EndLocal, TZ: ev.TZ, Kind: ev.Kind, Status: ev.Status}
167 if err := st.UpdateEvent(ctx, host.ID, v.EventID, in); err != nil {
168 t.Fatal(err)
169 }
170 st.SetAttendees(ctx, v.EventID, []string{"doc@tito.io", "boss@example.com"})
171
172 ics := strangerGet(t, h, link+"/calendar.ics")
173 body := ics.Body.String()
174 for _, want := range []string{"METHOD:REQUEST", "SUMMARY:Quick chat", "ATTENDEE;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:doc@tito.io",
175 "ORGANIZER:mailto:host@example.com", "SEQUENCE:2", "STATUS:CONFIRMED", "UID:"} {
176 if !strings.Contains(body, want) {
177 t.Errorf("ics lacks %q:\n%s", want, body)
178 }
179 }
180 for _, never := range []string{"URL:", "CN=", "Room 9", "private", "boss@example.com", "LOCATION"} {
181 if strings.Contains(body, never) {
182 t.Errorf("ics carries %q:\n%s", never, body)
183 }
184 }
185 if ct := ics.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/calendar") {
186 t.Errorf("content-type %q", ct)
187 }
188 if events, _, err := calendar.ParseICS(strings.NewReader(body), "UTC"); err != nil || len(events) != 1 {
189 t.Errorf("our own parser: %d events, %v", len(events), err)
190 }
191 man := strangerGet(t, h, link).Body.String()
192 for _, never := range []string{"Room 9", "private", "boss@example.com"} {
193 if strings.Contains(man, never) {
194 t.Errorf("the manage page shows the host's private %q", never)
195 }
196 }
197 // A held booking has no ICS.
198 strangerPost(t, h, path, holdForm(starts[1], "B", "b@example.com"))
199 held := reManage.FindString(mailer.body(mailer.count() - 1))
200 if rec := strangerGet(t, h, held+"/calendar.ics"); rec.Code != http.StatusNotFound {
201 t.Errorf("ics for a hold: %d, want 404", rec.Code)
202 }
203}
204
205func TestTheSameSlotCannotBeHeldTwiceAndAWrongTokenIsTheRouters404(t *testing.T) {
206 app, _, path, _ := publishedPage(t)
207 h := app.Handler()
208 _, starts := futureDaySlots(t, h, path)
209 strangerPost(t, h, path, holdForm(starts[0], "A", "a@example.com"))
210 rec := strangerPost(t, h, path, holdForm(starts[0], "B", "b@example.com"))
211 if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "just gone") {
212 t.Errorf("second hold: %d", rec.Code)
213 }
214 a := strangerGet(t, h, "/book/m/"+strings.Repeat("0", 64))
215 b := strangerGet(t, h, "/no/such/route")
216 if a.Code != 404 || a.Body.String() != b.Body.String() {
217 t.Errorf("token 404 (%d) differs from route 404 (%d)", a.Code, b.Code)
218 }
219 if rec := strangerGet(t, h, "/book/nobody/zzzzzzzz"); rec.Code != 404 || rec.Body.String() != b.Body.String() {
220 t.Error("an unknown handle is not the router's 404")
221 }
222}
223
224func TestRescheduleNeedsTheRevisionAndCancelTellsBoth(t *testing.T) {
225 app, mailer, path, _ := publishedPage(t)
226 h := app.Handler()
227 _, starts := futureDaySlots(t, h, path)
228 strangerPost(t, h, path, holdForm(starts[0], "Doc", "doc@tito.io"))
229 link := reManage.FindString(mailer.body(0))
230 strangerPost(t, h, link+"/confirm", nil)
231 mailer.reset()
232
233 picker := strangerGet(t, h, link+"/reschedule?day="+time.Now().UTC().AddDate(0, 0, 1).Format("2006-01-02")+"&zone=UTC")
234 if picker.Code != 200 {
235 t.Fatalf("picker: %d\n%s", picker.Code, picker.Body.String())
236 }
237 rev := regexp.MustCompile(`name="rev" value="(\d+)"`).FindStringSubmatch(picker.Body.String())
238 next := reStart.FindAllStringSubmatch(picker.Body.String(), -1)
239 if rev == nil || len(next) < 2 {
240 t.Fatalf("picker lacks rev or slots:\n%s", picker.Body.String())
241 }
242 form := url.Values{"start": {next[1][1]}, "zone": {"UTC"}, "rev": {rev[1]}}
243 rec := strangerPost(t, h, link+"/reschedule", form)
244 if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Reschedule") {
245 t.Fatalf("reschedule: %d\n%s", rec.Code, rec.Body.String())
246 }
247 if mailer.count() != 2 {
248 t.Errorf("reschedule mailed %d, want booker and host", mailer.count())
249 }
250 if !strings.Contains(strangerGet(t, h, link+"/calendar.ics").Body.String(), "SEQUENCE:2") {
251 t.Error("SEQUENCE not bumped by the move")
252 }
253 // The same form again: stale rev, nothing happens.
254 mailer.reset()
255 strangerPost(t, h, link+"/reschedule", form)
256 if mailer.count() != 0 {
257 t.Error("a resubmitted reschedule form sent mail")
258 }
259 rec = strangerPost(t, h, link+"/cancel", nil)
260 if rec.Code != 200 || mailer.count() != 2 {
261 t.Errorf("cancel: %d, mails %d", rec.Code, mailer.count())
262 }
263 if !strings.Contains(strangerGet(t, h, link+"/calendar.ics").Body.String(), "METHOD:CANCEL") {
264 t.Error("no METHOD:CANCEL after cancel")
265 }
266 mailer.reset()
267 strangerPost(t, h, link+"/cancel", nil)
268 if mailer.count() != 0 {
269 t.Error("cancelling twice mailed again")
270 }
271}
272
273func TestAHoldWhoseMailIsRefusedStillStands(t *testing.T) {
274 app, mailer, path, _ := publishedPage(t)
275 mailer.refuse = "bounce@example.com"
276 h := app.Handler()
277 _, starts := futureDaySlots(t, h, path)
278 rec := strangerPost(t, h, path, holdForm(starts[0], "B", "bounce@example.com"))
279 if rec.Code != 200 || !strings.Contains(rec.Body.String(), "If nothing arrives") {
280 t.Errorf("refused mail: %d\n%s", rec.Code, rec.Body.String())
281 }
282 // The slot is still held.
283 if again := strangerPost(t, h, path, holdForm(starts[0], "C", "c@example.com")); again.Code != http.StatusConflict {
284 t.Errorf("the slot was released: %d", again.Code)
285 }
286}
287