| 1 | package calendartest |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/url" |
| 6 | "regexp" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | ) |
| 10 | |
| 11 | // createdEvent is the redirect a successful quick add sends: /events/ |
| 12 | // followed by an id and nothing else. |
| 13 | // |
| 14 | // The obvious check — HasPrefix "/events/" — is worthless here, because |
| 15 | // the failure this file is guarding against redirects to |
| 16 | // /events/new?text=..., which has that prefix too. Asserting the prefix |
| 17 | // passes whether the event was created or refused. |
| 18 | var createdEvent = regexp.MustCompile(`^/events/\d+$`) |
| 19 | |
| 20 | // One sentence in, one event out — through the real router, with no |
| 21 | // script anywhere in the path. This is the scriptless floor, and it is |
| 22 | // the product rather than a fallback. |
| 23 | func TestQuickAddCreatesAnEventFromASentence(t *testing.T) { |
| 24 | app, session := signedIn(t, "someone@example.com") |
| 25 | h := app.Handler() |
| 26 | |
| 27 | rec := postAs(t, h, session, "/events/quick", url.Values{ |
| 28 | "text": {"Board meeting with doc@tito.io tomorrow at 3pm in McCloskeys"}, |
| 29 | }) |
| 30 | if rec.Code != http.StatusSeeOther { |
| 31 | t.Fatalf("quick add: status %d, want 303\n%s", rec.Code, rec.Body.String()) |
| 32 | } |
| 33 | loc := rec.Header().Get("Location") |
| 34 | if !strings.HasPrefix(loc, "/events/") { |
| 35 | t.Fatalf("quick add redirected to %q, want the new event", loc) |
| 36 | } |
| 37 | |
| 38 | page := getAs(t, h, session, loc).Body.String() |
| 39 | for _, want := range []string{"Board meeting", "McCloskeys", "doc@tito.io"} { |
| 40 | if !strings.Contains(page, want) { |
| 41 | t.Errorf("the created event does not show %q:\n%s", want, page) |
| 42 | } |
| 43 | } |
| 44 | // The one thing that could mislead somebody has to be on the page. |
| 45 | if !strings.Contains(page, "Nobody here was emailed") { |
| 46 | t.Error("the event page does not say that attendees were not emailed") |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | // The one rule, extended to attendees: an external attendee's address is |
| 51 | // who this team MEETS WITH, which is exactly what the bucket must not |
| 52 | // give up. Greps the raw file, not a query — a column that was never |
| 53 | // sealed looks identical through Go. |
| 54 | func TestQuickAddSealsAttendeeAddresses(t *testing.T) { |
| 55 | app, session := signedIn(t, "someone@example.com") |
| 56 | h := app.Handler() |
| 57 | |
| 58 | postAs(t, h, session, "/events/quick", url.Values{ |
| 59 | "text": {"Coffee with secret.person@elsewhere.example at 11am"}, |
| 60 | }) |
| 61 | |
| 62 | if found, where := app.GrepDatabaseForTest(t, "secret.person@elsewhere.example"); found { |
| 63 | t.Errorf("an attendee's address is readable in %s — it was not sealed", where) |
| 64 | } |
| 65 | // The keyed fingerprint must not be the address in another alphabet |
| 66 | // either: an unkeyed SHA-256 of an address is trivially reversed |
| 67 | // from a wordlist, which is why seal.Fingerprint is an HMAC. |
| 68 | if found, where := app.GrepDatabaseForTest(t, |
| 69 | "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"); found { |
| 70 | t.Errorf("something in %s looks like an unkeyed digest", where) |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | // The live pane is a FRAGMENT. Rendered through the page templates it |
| 75 | // would arrive wrapped in a second whole document and the browser would |
| 76 | // paste an <html> inside the open one. |
| 77 | func TestTheQuickAddPaneIsAFragmentNotAPage(t *testing.T) { |
| 78 | app, session := signedIn(t, "someone@example.com") |
| 79 | h := app.Handler() |
| 80 | |
| 81 | rec := postAs(t, h, session, "/events/quick/parse", url.Values{ |
| 82 | "text": {"Standup tomorrow at 9:30"}, |
| 83 | }) |
| 84 | if rec.Code != http.StatusOK { |
| 85 | t.Fatalf("parse: status %d, want 200", rec.Code) |
| 86 | } |
| 87 | body := rec.Body.String() |
| 88 | if strings.Contains(strings.ToLower(body), "<html") { |
| 89 | t.Errorf("the pane came back as a whole page:\n%s", body) |
| 90 | } |
| 91 | // And it says what it understood, which is the entire point of it. |
| 92 | for _, want := range []string{"Standup", "09:30"} { |
| 93 | if !strings.Contains(body, want) { |
| 94 | t.Errorf("the pane does not mention %q:\n%s", want, body) |
| 95 | } |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | // The box has to be on the page a person is looking at, and it has to be |
| 100 | // a real form pointing at a real route — not a button waiting for |
| 101 | // script to give it meaning. |
| 102 | func TestTheQuickAddBoxIsOnTheViewsAndNeedsNoScript(t *testing.T) { |
| 103 | app, session := signedIn(t, "someone@example.com") |
| 104 | h := app.Handler() |
| 105 | |
| 106 | for _, path := range []string{"/today", "/agenda"} { |
| 107 | body := follow(t, h, session, path).Body.String() |
| 108 | if !strings.Contains(body, "cal-quick") { |
| 109 | t.Errorf("%s has no quick add box", path) |
| 110 | } |
| 111 | if !strings.Contains(body, `action="/events/quick"`) { |
| 112 | t.Errorf("%s has a quick add box that posts nowhere", path) |
| 113 | } |
| 114 | // A <details> opens with no script. A div with a class does not. |
| 115 | if !strings.Contains(body, "<details cal-quick>") { |
| 116 | t.Errorf("%s builds the popover from something other than <details>, "+ |
| 117 | "so it needs script to open", path) |
| 118 | } |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | // A sentence with nothing usable in it must not create a nameless row. |
| 123 | func TestQuickAddWithNoTextGoesToTheForm(t *testing.T) { |
| 124 | app, session := signedIn(t, "someone@example.com") |
| 125 | h := app.Handler() |
| 126 | |
| 127 | rec := postAs(t, h, session, "/events/quick", url.Values{"text": {" "}}) |
| 128 | if rec.Code != http.StatusSeeOther { |
| 129 | t.Fatalf("empty quick add: status %d, want 303", rec.Code) |
| 130 | } |
| 131 | if got := rec.Header().Get("Location"); got != "/events/new" { |
| 132 | t.Errorf("empty quick add went to %q, want /events/new", got) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | // A run of days is a real event, created and visible. This is the whole |
| 137 | // point of the range work, and it is asserted through the router rather |
| 138 | // than against the parser because the bug it replaced was NOT in the |
| 139 | // parse: an all-day event whose end equals its start is refused by the |
| 140 | // validator, so the sentence used to bounce to the long form and |
| 141 | // nothing was ever created. |
| 142 | func TestQuickAddCreatesAnAllDayRun(t *testing.T) { |
| 143 | app, session := signedIn(t, "someone@example.com") |
| 144 | h := app.Handler() |
| 145 | |
| 146 | rec := postAs(t, h, session, "/events/quick", url.Values{ |
| 147 | "text": {"london tues-thurs"}, |
| 148 | }) |
| 149 | loc := rec.Header().Get("Location") |
| 150 | if rec.Code != http.StatusSeeOther || !createdEvent.MatchString(loc) { |
| 151 | t.Fatalf("quick add went to %q with status %d, want the new event", |
| 152 | loc, rec.Code) |
| 153 | } |
| 154 | page := getAs(t, h, session, loc).Body.String() |
| 155 | if !strings.Contains(page, "london") { |
| 156 | t.Errorf("the created event does not show its title:\n%s", page) |
| 157 | } |
| 158 | // The stranded word is the symptom everybody sees: before the range |
| 159 | // patterns existed the title came out as "london -thurs". |
| 160 | if strings.Contains(page, "thurs") { |
| 161 | t.Errorf("the end of the range was left in the title:\n%s", page) |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | // A single all-day sentence has to create an event too. It did not: the |
| 166 | // parser wrote "2027-03-12T00:00" for both ends, which is neither a |
| 167 | // DATE nor a non-empty range. |
| 168 | func TestQuickAddCreatesASingleAllDayEvent(t *testing.T) { |
| 169 | app, session := signedIn(t, "someone@example.com") |
| 170 | h := app.Handler() |
| 171 | |
| 172 | rec := postAs(t, h, session, "/events/quick", url.Values{ |
| 173 | "text": {"Team offsite all day on 12 March"}, |
| 174 | }) |
| 175 | if loc := rec.Header().Get("Location"); !createdEvent.MatchString(loc) { |
| 176 | t.Fatalf("an all-day sentence went to %q instead of creating an event", loc) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | // The pane prints the last day a person is actually busy, not the |
| 181 | // exclusive end. Telling somebody their trip runs to Friday when it ends |
| 182 | // on Thursday is the one thing a preview must not do. |
| 183 | func TestTheQuickAddPaneShowsTheInclusiveLastDay(t *testing.T) { |
| 184 | app, session := signedIn(t, "someone@example.com") |
| 185 | h := app.Handler() |
| 186 | |
| 187 | body := postAs(t, h, session, "/events/quick/parse", url.Values{ |
| 188 | "text": {"london tues-thurs"}, |
| 189 | }).Body.String() |
| 190 | |
| 191 | if !strings.Contains(body, "Thursday") { |
| 192 | t.Errorf("the pane does not name the last day of the run:\n%s", body) |
| 193 | } |
| 194 | if strings.Contains(body, "Friday") { |
| 195 | t.Errorf("the pane printed the exclusive end as the last day:\n%s", body) |
| 196 | } |
| 197 | if !strings.Contains(body, "all day") { |
| 198 | t.Errorf("the pane does not say the run is all day:\n%s", body) |
| 199 | } |
| 200 | } |
| 201 | |