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/url"
7 "regexp"
8 "strconv"
9 "strings"
10 "testing"
11 "time"
12)
13
14// The host's side of booking, through the router (booking spec §2.1,
15// §3.4, §7.4, B18, B25).
16
17func settingsForm(handle string) url.Values {
18 return url.Values{"zone": {"Europe/Dublin"}, "week_start": {"1"},
19 "default_reminder_min": {"10"}, "default_all_day_min": {"360"}, "handle": {handle}}
20}
21
22func pageForm() url.Values {
23 return url.Values{"title": {"Quick chat"}, "description": {"Fifteen minutes."},
24 "availability": {"mondays 12-3, tuesdays 12-2, 5-7"}, "duration_min": {"15"},
25 "buffer_min": {"10"}, "notice_min": {"120"}, "horizon_days": {"30"}}
26}
27
28var rePublicURL = regexp.MustCompile(`/book/paul/([a-z0-9]{8})`)
29
30func TestAHostMakesAPageAndSeesItsLinkOnlyOncePublished(t *testing.T) {
31 app, session := signedIn(t, "host@example.com")
32 h := app.Handler()
33 if rec := postAs(t, h, session, "/settings", settingsForm("paul")); rec.Code != http.StatusSeeOther {
34 t.Fatalf("settings: %d\n%s", rec.Code, rec.Body.String())
35 }
36 if rec := postAs(t, h, session, "/settings/booking", pageForm()); rec.Code != http.StatusSeeOther {
37 t.Fatalf("create: %d\n%s", rec.Code, rec.Body.String())
38 }
39 body := getAs(t, h, session, "/settings/booking").Body.String()
40 for _, want := range []string{"Quick chat", "Not published", "Tuesday 12:00–14:00, 17:00–19:00", "Anyone with this link can see when you are free"} {
41 if !strings.Contains(body, want) {
42 t.Errorf("list lacks %q", want)
43 }
44 }
45 if rePublicURL.MatchString(body) {
46 t.Error("an unpublished page shows a public URL")
47 }
48 if rec := postAs(t, h, session, "/settings/booking/1/publish", url.Values{"publish": {"1"}}); rec.Code != http.StatusSeeOther {
49 t.Fatalf("publish: %d", rec.Code)
50 }
51 body = getAs(t, h, session, "/settings/booking").Body.String()
52 if !rePublicURL.MatchString(body) || !strings.Contains(body, "Published") {
53 t.Errorf("a published page shows no public URL:\n%s", body)
54 }
55}
56
57func TestAPageWithAnUnreadableSentenceIsNotSaved(t *testing.T) {
58 app, session := signedIn(t, "host@example.com")
59 h := app.Handler()
60 postAs(t, h, session, "/settings", settingsForm("paul"))
61 form := pageForm()
62 form.Set("availability", "mondays whenever")
63 rec := postAs(t, h, session, "/settings/booking", form)
64 if rec.Code != http.StatusUnprocessableEntity {
65 t.Fatalf("status %d, want 422", rec.Code)
66 }
67 if !strings.Contains(rec.Body.String(), "whenever") {
68 t.Error("the refusal does not quote the clause")
69 }
70 if strings.Contains(getAs(t, h, session, "/settings/booking").Body.String(), "Quick chat") {
71 t.Error("the page was saved anyway")
72 }
73}
74
75func TestThePaneFragmentDescribesTheWindows(t *testing.T) {
76 app, session := signedIn(t, "host@example.com")
77 rec := postAs(t, app.Handler(), session, "/settings/booking/parse", url.Values{"text": {"fri 9-5"}})
78 body := rec.Body.String()
79 if !strings.Contains(body, "Friday 09:00–17:00") || strings.Contains(strings.ToLower(body), "<html") {
80 t.Errorf("pane:\n%s", body)
81 }
82}
83
84func TestAnotherPersonsBookingPageIs404(t *testing.T) {
85 app, mine := signedIn(t, "me@example.com")
86 h := app.Handler()
87 postAs(t, h, mine, "/settings", settingsForm("me"))
88 postAs(t, h, mine, "/settings/booking", pageForm())
89 theirs := app.SignInForTest(t, "them@example.com")
90 for _, c := range []struct {
91 method, path string
92 }{{"GET", "/settings/booking/1"}, {"POST", "/settings/booking/1"}, {"POST", "/settings/booking/1/publish"}} {
93 var rec = getAs(t, h, theirs, c.path)
94 if c.method == "POST" {
95 rec = postAs(t, h, theirs, c.path, pageForm())
96 }
97 if rec.Code != http.StatusNotFound {
98 t.Errorf("%s %s as another person: %d, want 404", c.method, c.path, rec.Code)
99 }
100 }
101}
102
103func TestATakenHandleIsRefusedOnSettings(t *testing.T) {
104 app, a := signedIn(t, "a@example.com")
105 h := app.Handler()
106 postAs(t, h, a, "/settings", settingsForm("paul"))
107 b := app.SignInForTest(t, "b@example.com")
108 rec := postAs(t, h, b, "/settings", settingsForm("paul"))
109 if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "taken") {
110 t.Errorf("second claim: %d\n%s", rec.Code, rec.Body.String())
111 }
112}
113
114// A booked event's form: the scheduling controls are disabled and their
115// stored values ride along hidden, so a scriptless title-only save
116// works — and still works after the host changes their profile zone,
117// because the hidden tz is the event's own (B25).
118func TestABookedEventsFormLocksItsTimeButSavesItsTitle(t *testing.T) {
119 // Keep this scenario independent of the machine's wall clock. With
120 // time.Now() it failed only when run between 14:00 and 15:00 UTC in
121 // summer time, on any date (a sweep of every hour, 20-30 September,
122 // failed at 14:17 and nowhere else): the stale-host bug the reread below
123 // fixes. The first case is that hour, and fails without the reread;
124 // the other two are either side of Dublin's DST boundaries.
125 for _, now := range []struct {
126 name string
127 now time.Time
128 }{
129 {"ci-failure-2026-09-24-1417Z", time.Date(2026, time.September, 24, 14, 17, 0, 0, time.UTC)},
130 {"dublin-dst-start", time.Date(2026, time.March, 29, 13, 0, 0, 0, time.UTC)},
131 {"dublin-dst-end", time.Date(2026, time.October, 25, 13, 0, 0, 0, time.UTC)},
132 } {
133 t.Run(now.name, func(t *testing.T) {
134 testABookedEventsFormLocksItsTimeButSavesItsTitle(t, now.now)
135 })
136 }
137}
138
139func testABookedEventsFormLocksItsTimeButSavesItsTitle(t *testing.T, now time.Time) {
140 app, host, page := hostWithPage(t)
141 h := app.Handler()
142 session := app.SignInForTest(t, host.Address)
143 st := app.StoreForTest()
144 slot := slotsFor(t, app, page, host, now, 3)[0]
145 b, err := st.HoldBooking(context.Background(), holdInput(page, host, slot, "doc@tito.io", "ip"), now)
146 if err != nil {
147 t.Fatal(err)
148 }
149 st.ConfirmBooking(context.Background(), b.ID, now)
150
151 edit := "/events/" + strconv.FormatInt(b.EventID, 10) + "/edit"
152 body := getAs(t, h, session, edit).Body.String()
153 for _, want := range []string{`name="tz" value="UTC"`, `name="start" value="`, "This is a booked meeting", "Doc"} {
154 if !strings.Contains(body, want) {
155 t.Errorf("the booked form lacks %q", want)
156 }
157 }
158 // The host moves zone; a title-only save still passes.
159 postAs(t, h, session, "/settings", settingsForm("paul"))
160 form := formValuesFrom(body)
161 form.Set("title", "Renamed by the host")
162 rec := postAs(t, h, session, "/events/"+strconv.FormatInt(b.EventID, 10), form)
163 if rec.Code != http.StatusSeeOther {
164 t.Fatalf("title-only save: %d\n%s", rec.Code, rec.Body.String())
165 }
166 // And a stale form with the old time after a public reschedule is refused.
167 // Settings above changed the host's zone. Read the booking again before
168 // selecting the new public slot, so FreeSlots and RescheduleBooking use
169 // the same host-zone record.
170 v, err := st.BookingByID(context.Background(), b.ID, false)
171 if err != nil {
172 t.Fatal(err)
173 }
174 slots := slotsFor(t, app, v.Page, v.Host, now, 3)
175 if changed, err := st.RescheduleBooking(context.Background(), b.ID, slots[3].StartUTC, slots[3].EndUTC, v.Event.Sequence, now); err != nil || !changed {
176 t.Fatalf("reschedule: changed=%v err=%v", changed, err)
177 }
178 rec = postAs(t, h, session, "/events/"+strconv.FormatInt(b.EventID, 10), form)
179 if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "booked meeting") {
180 t.Errorf("stale form after a public reschedule: %d", rec.Code)
181 }
182 // The event page says how to move it.
183 if page := getAs(t, h, session, "/events/"+strconv.FormatInt(b.EventID, 10)).Body.String(); !strings.Contains(page, "cancel it and let Doc rebook") {
184 t.Error("the event page does not say how a booked meeting is moved")
185 }
186}
187
188// formValuesFrom pulls every hidden input off a rendered form, plus the
189// fields a title-only save needs, the way a browser would submit them.
190func formValuesFrom(body string) url.Values {
191 form := url.Values{}
192 for _, m := range regexp.MustCompile(`name="([a-z_]+)" value="([^"]*)"`).FindAllStringSubmatch(body, -1) {
193 if form.Get(m[1]) == "" {
194 form.Set(m[1], m[2])
195 }
196 }
197 // The status select's selected option.
198 if m := regexp.MustCompile(`<option value="(confirmed|tentative|cancelled)" selected`).FindStringSubmatch(body); m != nil {
199 form.Set("status", m[1])
200 }
201 if m := regexp.MustCompile(`name="calendar_id"[^>]*>[\s\S]*?<option value="(\d+)" selected`).FindStringSubmatch(body); m != nil {
202 form.Set("calendar_id", m[1])
203 }
204 form.Set("title", "Renamed")
205 form.Del("all_day")
206 return form
207}
208