| 1 | package calendartest |
| 2 | |
| 3 | import ( |
| 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. |
| 25 | func testDay(n int) string { |
| 26 | return time.Now().UTC().AddDate(0, 0, n).Format("2006-01-02") |
| 27 | } |
| 28 | |
| 29 | func 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. |
| 38 | func 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. |
| 101 | func 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. |
| 107 | func 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. |
| 141 | func 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. |
| 187 | func 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). |
| 216 | func 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). |
| 252 | func 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 | |