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 "net/http"
5 "net/url"
6 "strings"
7 "testing"
8 "time"
9)
10
11// testDay and testAt anchor these tests to dates that are always in the
12// FUTURE, and that is the whole point of them.
13//
14// The dates here used to be written out literally. That made the suite
15// rot on a clock: a chip's own label is tense-dependent — chipFor prints
16// the start time for an event still to come and "ended <end>" for one
17// already over (viewdata.go) — so an assertion that a day still shows
18// its 09:30 occurrence passed all morning and failed every afternoon,
19// and would have failed outright from the day after the literals were
20// written. It was found exactly that way, at 18:07 on the day the
21// literals named.
22//
23// The person these tests sign in as is in UTC, so UTC is the zone the
24// submitted wall time is read in.
25func testDay(n int) string {
26 return time.Now().UTC().AddDate(0, 0, n).Format("2006-01-02")
27}
28
29func testAt(n int, hhmm string) string { return testDay(n) + "T" + hhmm }
30
31// Editing one occurrence of a series is where calendars go wrong most
32// visibly: the moved occurrence appears at its new time AND the
33// original slot still shows the unmoved one, or the moved one vanishes
34// entirely.
35//
36// The rule that prevents both is that an override is keyed by the
37// occurrence's ORIGINAL local time and rendered at its new one.
38func TestMovingOneOccurrenceLeavesTheRestAlone(t *testing.T) {
39 app, session := signedIn(t, "someone@example.com")
40 h := app.Handler()
41
42 loc := createEvent(t, h, session, url.Values{
43 "title": {"Daily standup"},
44 "start": {testAt(1, "09:30")},
45 "end": {testAt(1, "09:45")},
46 "kind": {"meeting"},
47 "status": {"confirmed"},
48 "repeat": {"daily"},
49 })
50 id := strings.TrimPrefix(loc, "/events/")
51
52 // Move ONLY the second occurrence, from 09:30 to 14:00.
53 rec := postAs(t, h, session, "/events/"+id, url.Values{
54 "scope": {"this"},
55 "occurrence": {testAt(2, "09:30")},
56 "title": {"Daily standup"},
57 "calendar_id": {calendarIDOf(t, h, session)},
58 "start": {testAt(2, "14:00")},
59 "end": {testAt(2, "14:15")},
60 "kind": {"meeting"},
61 "status": {"confirmed"},
62 "repeat": {"none"},
63 })
64 if rec.Code != http.StatusSeeOther {
65 t.Fatalf("moving one occurrence: status %d\n%s", rec.Code, rec.Body.String())
66 }
67
68 // The moved day shows it once, at the new time, and not at the old.
69 //
70 // Assert against the CHIP's own time, not against the page: every
71 // day carries 48 slot links whose accessible names contain every
72 // half hour, so a bare Contains("09:30") matches on every day
73 // whether or not anything is scheduled then.
74 moved := getAs(t, h, session, "/day/"+testDay(2)).Body.String()
75 if !hasChipAt(moved, "14:00") {
76 t.Errorf("the moved occurrence is not at its new time:\n%s", moved)
77 }
78 if hasChipAt(moved, "09:30") {
79 t.Error("the moved occurrence still appears at its ORIGINAL time — " +
80 "the override is not claiming the slot the expander generates")
81 }
82 if n := strings.Count(moved, "Daily standup"); n != 1 {
83 t.Errorf("the moved day shows the event %d times, want exactly 1", n)
84 }
85
86 // Every other day is untouched.
87 for _, day := range []string{testDay(1), testDay(3)} {
88 body := getAs(t, h, session, "/day/"+day).Body.String()
89 if !hasChipAt(body, "09:30") {
90 t.Errorf("%s lost its 09:30 occurrence when a different day was moved", day)
91 }
92 if n := strings.Count(body, "Daily standup"); n != 1 {
93 t.Errorf("%s shows the event %d times, want exactly 1", day, n)
94 }
95 }
96}
97
98// hasChipAt reports whether a rendered page carries an event chip
99// starting at hhmm — as opposed to merely containing that time
100// somewhere, which every day does in its slot links.
101func hasChipAt(body, hhmm string) bool {
102 return strings.Contains(body, "<span cal-chip-time>"+hhmm+"</span>")
103}
104
105// Deleting one occurrence writes an EXDATE, which removes exactly that
106// day and nothing else.
107func TestDeletingOneOccurrenceRemovesOnlyThatDay(t *testing.T) {
108 app, session := signedIn(t, "someone@example.com")
109 h := app.Handler()
110
111 loc := createEvent(t, h, session, url.Values{
112 "title": {"Daily standup"},
113 "start": {testAt(1, "09:30")},
114 "end": {testAt(1, "09:45")},
115 "kind": {"meeting"},
116 "status": {"confirmed"},
117 "repeat": {"daily"},
118 })
119 id := strings.TrimPrefix(loc, "/events/")
120
121 rec := postAs(t, h, session, "/events/"+id+"/delete", url.Values{
122 "scope": {"this"},
123 "occurrence": {testAt(2, "09:30")},
124 })
125 if rec.Code != http.StatusSeeOther {
126 t.Fatalf("deleting one occurrence: status %d", rec.Code)
127 }
128
129 if body := getAs(t, h, session, "/day/"+testDay(2)).Body.String(); strings.Contains(body, "Daily standup") {
130 t.Error("the deleted occurrence is still on its day")
131 }
132 for _, day := range []string{testDay(1), testDay(3)} {
133 if body := getAs(t, h, session, "/day/"+day).Body.String(); !strings.Contains(body, "Daily standup") {
134 t.Errorf("%s lost its occurrence when a different day was deleted", day)
135 }
136 }
137}
138
139// "This one and everything after it" splits the series: the head keeps
140// its earlier occurrences unchanged, the tail starts from the edit.
141func TestSplittingASeriesLeavesTheHeadAlone(t *testing.T) {
142 app, session := signedIn(t, "someone@example.com")
143 h := app.Handler()
144
145 loc := createEvent(t, h, session, url.Values{
146 "title": {"Standup"},
147 "start": {testAt(1, "09:30")},
148 "end": {testAt(1, "09:45")},
149 "kind": {"meeting"},
150 "status": {"confirmed"},
151 "repeat": {"daily"},
152 })
153 id := strings.TrimPrefix(loc, "/events/")
154
155 rec := postAs(t, h, session, "/events/"+id, url.Values{
156 "scope": {"following"},
157 "occurrence": {testAt(9, "09:30")},
158 "title": {"Standup"},
159 "calendar_id": {calendarIDOf(t, h, session)},
160 "start": {testAt(9, "11:00")},
161 "end": {testAt(9, "11:15")},
162 "kind": {"meeting"},
163 "status": {"confirmed"},
164 "repeat": {"daily"},
165 })
166 if rec.Code != http.StatusSeeOther {
167 t.Fatalf("splitting the series: status %d\n%s", rec.Code, rec.Body.String())
168 }
169
170 // Before the split: still 09:30.
171 before := getAs(t, h, session, "/day/"+testDay(4)).Body.String()
172 if !hasChipAt(before, "09:30") {
173 t.Errorf("an occurrence before the split lost its original time:\n%s", before)
174 }
175 // After: 11:00, and only once.
176 after := getAs(t, h, session, "/day/"+testDay(10)).Body.String()
177 if !hasChipAt(after, "11:00") {
178 t.Errorf("an occurrence after the split is not at the new time:\n%s", after)
179 }
180 if n := strings.Count(after, "Standup"); n != 1 {
181 t.Errorf("a day after the split shows the event %d times, want 1 — "+
182 "the head series was not ended where the tail begins", n)
183 }
184}
185
186// calendarIDOf reads the calendar a new event would default to.
187func calendarIDOf(t *testing.T, h http.Handler, session *http.Cookie) string {
188 t.Helper()
189 page := getAs(t, h, session, "/events/new").Body.String()
190 i := strings.Index(page, `<option value="`)
191 for i >= 0 {
192 rest := page[i+len(`<option value="`):]
193 end := strings.Index(rest, `"`)
194 if end < 0 {
195 break
196 }
197 id := rest[:end]
198 if strings.Contains(rest[end:min(end+80, len(rest))], "selected") {
199 return id
200 }
201 next := strings.Index(rest, `<option value="`)
202 if next < 0 {
203 break
204 }
205 i = i + len(`<option value="`) + next
206 }
207 t.Fatal("no selected calendar on the create form")
208 return ""
209}
210
211// An override of a series that has already FINISHED is the one pass 2
212// cannot reach by itself: the master is excluded by until_utc, so the
213// override's series is never loaded and the moved occurrence vanishes.
214// The override row itself is in the range rows — it must volunteer its
215// series (booking spec §4.1, r2 B3).
216func TestAMovedOccurrenceOfAFinishedSeriesStillShows(t *testing.T) {
217 app, session := signedIn(t, "someone@example.com")
218 h := app.Handler()
219
220 loc := createEvent(t, h, session, url.Values{
221 "title": {"Sprint review"}, "start": {testAt(1, "09:30")}, "end": {testAt(1, "09:45")},
222 "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"daily"}, "repeat_end": {testDay(3)},
223 })
224 id := strings.TrimPrefix(loc, "/events/")
225
226 // Move the LAST occurrence ten days past the series' end.
227 rec := postAs(t, h, session, "/events/"+id, url.Values{
228 "scope": {"this"}, "occurrence": {testAt(3, "09:30")},
229 "title": {"Sprint review"}, "calendar_id": {calendarIDOf(t, h, session)},
230 "start": {testAt(13, "14:00")}, "end": {testAt(13, "14:15")},
231 "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"none"},
232 })
233 if rec.Code != http.StatusSeeOther {
234 t.Fatalf("moving the last occurrence: status %d\n%s", rec.Code, rec.Body.String())
235 }
236
237 moved := getAs(t, h, session, "/day/"+testDay(13)).Body.String()
238 if !hasChipAt(moved, "14:00") {
239 t.Errorf("the moved occurrence of a finished series does not show on its new day")
240 }
241 if n := strings.Count(moved, "Sprint review"); n != 1 {
242 t.Errorf("the moved day shows the event %d times, want exactly 1", n)
243 }
244 if orig := getAs(t, h, session, "/day/"+testDay(3)).Body.String(); hasChipAt(orig, "09:30") {
245 t.Error("the moved occurrence still appears at its ORIGINAL time")
246 }
247}
248
249// UNTIL bounds the last occurrence's START, not its end. A master whose
250// final occurrence runs for four days must still be a candidate for a
251// window that opens two days after that start (booking spec §4.1, r3 f7).
252func TestALongFinalOccurrenceShowsOnItsLaterDays(t *testing.T) {
253 app, session := signedIn(t, "someone@example.com")
254 h := app.Handler()
255
256 createEvent(t, h, session, url.Values{
257 "title": {"Offsite"}, "start": {testAt(1, "09:00")}, "end": {testAt(5, "09:00")},
258 "kind": {"commitment"}, "status": {"confirmed"}, "repeat": {"weekly"}, "repeat_end": {testDay(1)},
259 })
260
261 if body := getAs(t, h, session, "/day/"+testDay(3)).Body.String(); !strings.Contains(body, "Offsite") {
262 t.Error("the third day of a four-day final occurrence shows nothing — " +
263 "the master was excluded on until_utc alone, without its duration")
264 }
265}
266