| 1 | package docs |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | ) |
| 10 | |
| 11 | // seedTitles creates documents with the given titles, in order, and |
| 12 | // returns the owner. Created in order so the LAST one is the most |
| 13 | // recently updated — which is what the rank tie-break relies on. |
| 14 | func seedTitles(t *testing.T, app *App, address string, titles ...string) int64 { |
| 15 | t.Helper() |
| 16 | ctx := context.Background() |
| 17 | owner := app.TestPerson(t, address) |
| 18 | for _, title := range titles { |
| 19 | if _, err := app.Store().Create(ctx, owner, title); err != nil { |
| 20 | t.Fatalf("Create(%q): %v", title, err) |
| 21 | } |
| 22 | } |
| 23 | return owner |
| 24 | } |
| 25 | |
| 26 | func titlesOf(docs []*Document) []string { |
| 27 | out := make([]string, 0, len(docs)) |
| 28 | for _, d := range docs { |
| 29 | out = append(out, d.Title) |
| 30 | } |
| 31 | return out |
| 32 | } |
| 33 | |
| 34 | // The order is the feature. Somebody should be able to look at a result |
| 35 | // list and say why it is in that order, which is why the ranks are |
| 36 | // ordered rather than scored. |
| 37 | func TestSearchRanksExactThenPrefixThenWordThenAnywhere(t *testing.T) { |
| 38 | app, err := NewForTest(t) |
| 39 | if err != nil { |
| 40 | t.Fatalf("NewForTest: %v", err) |
| 41 | } |
| 42 | seedTitles(t, app, "writer@example.com", |
| 43 | "Determining the notes", // "notes" inside a word boundary, but late |
| 44 | "Notes on the waterworks", // starts with it |
| 45 | "Meeting notes", // word starts with it |
| 46 | "Notes", // exact |
| 47 | ) |
| 48 | |
| 49 | got, err := app.Store().Search(context.Background(), app.TestPerson(t, "writer@example.com"), "notes") |
| 50 | if err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | want := []string{ |
| 54 | "Notes", |
| 55 | "Notes on the waterworks", |
| 56 | "Determining the notes", |
| 57 | "Meeting notes", |
| 58 | } |
| 59 | // "Determining the notes" and "Meeting notes" are both word-prefix |
| 60 | // matches, so they tie and fall back to most-recently-updated. |
| 61 | // Created earlier means updated earlier, so "Meeting notes" (made |
| 62 | // third) sorts above "Determining the notes" (made first). |
| 63 | want = []string{"Notes", "Notes on the waterworks", "Meeting notes", "Determining the notes"} |
| 64 | if strings.Join(titlesOf(got), " | ") != strings.Join(want, " | ") { |
| 65 | t.Errorf("ranking\n got: %v\nwant: %v", titlesOf(got), want) |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | func TestSearchIsCaseAndPunctuationInsensitive(t *testing.T) { |
| 70 | app, err := NewForTest(t) |
| 71 | if err != nil { |
| 72 | t.Fatalf("NewForTest: %v", err) |
| 73 | } |
| 74 | owner := seedTitles(t, app, "writer@example.com", "Packing List — Thursday Site Visit") |
| 75 | |
| 76 | for _, query := range []string{ |
| 77 | "packing", |
| 78 | "PACKING", |
| 79 | "packing list", |
| 80 | "packing list thursday", // the em dash reads as a space |
| 81 | "packing list - thursday", // and so does a hyphen |
| 82 | " packing list ", // and runs of whitespace collapse |
| 83 | } { |
| 84 | got, err := app.Store().Search(context.Background(), owner, query) |
| 85 | if err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | if len(got) != 1 { |
| 89 | t.Errorf("query %q matched %d documents, want 1", query, len(got)) |
| 90 | } |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | // An accent is not silently equated with its unaccented form: doing |
| 95 | // that well across twelve locales is a design, and doing it badly makes |
| 96 | // searches match things a person did not ask for. Stated as a test so |
| 97 | // the behaviour is a decision rather than an accident. |
| 98 | func TestSearchDoesNotFoldAccents(t *testing.T) { |
| 99 | app, err := NewForTest(t) |
| 100 | if err != nil { |
| 101 | t.Fatalf("NewForTest: %v", err) |
| 102 | } |
| 103 | owner := seedTitles(t, app, "writer@example.com", "Café notes") |
| 104 | |
| 105 | if got, _ := app.Store().Search(context.Background(), owner, "café"); len(got) != 1 { |
| 106 | t.Errorf(`"café" matched %d, want 1`, len(got)) |
| 107 | } |
| 108 | if got, _ := app.Store().Search(context.Background(), owner, "cafe"); len(got) != 0 { |
| 109 | t.Errorf(`"cafe" matched %d, want 0 — accent folding is deliberately not done`, len(got)) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | // The same text in its two Unicode encodings is the same text, and this |
| 114 | // is a different question from the accent folding above — which is why |
| 115 | // it gets its own test rather than a case in that one. |
| 116 | // |
| 117 | // TestSearchDoesNotFoldAccents is about "café" and "cafe": two |
| 118 | // DIFFERENT strings, which a person may or may not want treated as one, |
| 119 | // and Paul's call (branch task 21). This is about "café" and "café": |
| 120 | // the SAME string, once as U+00E9 and once as "e" + U+0301, which |
| 121 | // nobody has ever wanted treated as two. A macOS filesystem hands over |
| 122 | // the decomposed form, several IMEs produce it, and a title typed on |
| 123 | // one machine and searched for on another can differ by nothing else. |
| 124 | // |
| 125 | // Both directions, because normalising only the query or only the title |
| 126 | // fixes exactly one of them and looks fixed from whichever side the |
| 127 | // author happened to test. |
| 128 | func TestSearchNormalisesUnicodeForm(t *testing.T) { |
| 129 | app, err := NewForTest(t) |
| 130 | if err != nil { |
| 131 | t.Fatalf("NewForTest: %v", err) |
| 132 | } |
| 133 | ctx := context.Background() |
| 134 | |
| 135 | const precomposed = "Café notes" // café, one rune |
| 136 | const decomposed = "Café notes" // café, e + combining acute |
| 137 | if precomposed == decomposed { |
| 138 | t.Fatal("the two forms are byte-identical — this test proves nothing") |
| 139 | } |
| 140 | |
| 141 | owner := seedTitles(t, app, "writer@example.com", precomposed) |
| 142 | if got, _ := app.Store().Search(ctx, owner, decomposed); len(got) != 1 { |
| 143 | t.Errorf("decomposed query against a precomposed title matched %d, want 1", len(got)) |
| 144 | } |
| 145 | |
| 146 | // And the other way round: a title stored decomposed, queried |
| 147 | // precomposed. |
| 148 | other := seedTitles(t, app, "second@example.com", decomposed) |
| 149 | if got, _ := app.Store().Search(ctx, other, precomposed); len(got) != 1 { |
| 150 | t.Errorf("precomposed query against a decomposed title matched %d, want 1", len(got)) |
| 151 | } |
| 152 | |
| 153 | // Normalising must not have quietly folded the accent away as a |
| 154 | // side effect — that is task 21's decision, not this change's. |
| 155 | if got, _ := app.Store().Search(ctx, owner, "cafe notes"); len(got) != 0 { |
| 156 | t.Errorf(`"cafe notes" matched %d, want 0 — NFC must not fold accents`, len(got)) |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // "No query" is not "match nothing", and it is not "match everything" |
| 161 | // either. The caller decides what an empty query shows; Search says |
| 162 | // nothing. |
| 163 | func TestSearchOfAnEmptyQueryReturnsNothing(t *testing.T) { |
| 164 | app, err := NewForTest(t) |
| 165 | if err != nil { |
| 166 | t.Fatalf("NewForTest: %v", err) |
| 167 | } |
| 168 | owner := seedTitles(t, app, "writer@example.com", "One", "Two") |
| 169 | for _, query := range []string{"", " ", "\t"} { |
| 170 | got, err := app.Store().Search(context.Background(), owner, query) |
| 171 | if err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | if len(got) != 0 { |
| 175 | t.Errorf("query %q returned %d documents, want 0", query, len(got)) |
| 176 | } |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | // The scoping property every document route has, held against the one |
| 181 | // route that exists to return a LIST of titles — the place where a |
| 182 | // missing scope leaks the most at once. |
| 183 | func TestSearchNeverCrossesOwners(t *testing.T) { |
| 184 | app, err := NewForTest(t) |
| 185 | if err != nil { |
| 186 | t.Fatalf("NewForTest: %v", err) |
| 187 | } |
| 188 | seedTitles(t, app, "owner@example.com", "Secret plans") |
| 189 | stranger := seedTitles(t, app, "stranger@example.com", "My own notes") |
| 190 | |
| 191 | got, err := app.Store().Search(context.Background(), stranger, "secret") |
| 192 | if err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | if len(got) != 0 { |
| 196 | t.Fatalf("a stranger found %v", titlesOf(got)) |
| 197 | } |
| 198 | |
| 199 | // And through the HTTP route, which is what is actually reachable. |
| 200 | rec := serve(app, app.TestRequest(t, "stranger@example.com", http.MethodGet, "/documents/search?q=secret", nil)) |
| 201 | if rec.Code != http.StatusOK { |
| 202 | t.Fatalf("got %d", rec.Code) |
| 203 | } |
| 204 | if strings.Contains(rec.Body.String(), "Secret plans") { |
| 205 | t.Errorf("the search endpoint leaked another person's title: %s", rec.Body.String()) |
| 206 | } |
| 207 | |
| 208 | // The list page's own search path, too — it is a different handler |
| 209 | // reaching the same store method, and "the store is scoped" is not |
| 210 | // the same claim as "every caller passes the right owner". |
| 211 | page := serve(app, app.TestRequest(t, "stranger@example.com", http.MethodGet, "/?q=secret", nil)) |
| 212 | if strings.Contains(page.Body.String(), "Secret plans") { |
| 213 | t.Errorf("the list page leaked another person's title") |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | func TestSearchEndpointReturnsJSON(t *testing.T) { |
| 218 | app, err := NewForTest(t) |
| 219 | if err != nil { |
| 220 | t.Fatalf("NewForTest: %v", err) |
| 221 | } |
| 222 | seedTitles(t, app, "writer@example.com", "Meeting notes", "Packing list") |
| 223 | |
| 224 | rec := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, "/documents/search?q=meeting", nil)) |
| 225 | if rec.Code != http.StatusOK { |
| 226 | t.Fatalf("got %d, want 200", rec.Code) |
| 227 | } |
| 228 | if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { |
| 229 | t.Errorf("Content-Type is %q", ct) |
| 230 | } |
| 231 | if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { |
| 232 | t.Errorf("a person's own titles should not be cached: Cache-Control is %q", cc) |
| 233 | } |
| 234 | |
| 235 | var results []searchResult |
| 236 | if err := json.Unmarshal(rec.Body.Bytes(), &results); err != nil { |
| 237 | t.Fatalf("the body is not the JSON the palette expects: %v", err) |
| 238 | } |
| 239 | if len(results) != 1 || results[0].Title != "Meeting notes" { |
| 240 | t.Fatalf("got %+v", results) |
| 241 | } |
| 242 | if !strings.HasPrefix(results[0].Href, "/d/") || results[0].When == "" { |
| 243 | t.Errorf("a result needs a link and a date: %+v", results[0]) |
| 244 | } |
| 245 | |
| 246 | // An empty query is an empty array, not null — the palette |
| 247 | // iterates the result without checking, and `null` would be a |
| 248 | // TypeError in the browser rather than an empty list. |
| 249 | empty := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, "/documents/search?q=", nil)) |
| 250 | if strings.TrimSpace(empty.Body.String()) != "[]" { |
| 251 | t.Errorf("an empty query returned %q, want []", empty.Body.String()) |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | func TestSearchEndpointRequiresASession(t *testing.T) { |
| 256 | app, err := NewForTest(t) |
| 257 | if err != nil { |
| 258 | t.Fatalf("NewForTest: %v", err) |
| 259 | } |
| 260 | seedTitles(t, app, "writer@example.com", "Meeting notes") |
| 261 | |
| 262 | req, _ := http.NewRequest(http.MethodGet, "/documents/search?q=meeting", nil) |
| 263 | rec := serve(app, req) |
| 264 | if rec.Code == http.StatusOK && strings.Contains(rec.Body.String(), "Meeting notes") { |
| 265 | t.Error("the search endpoint served titles to a signed-out request") |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | // The whole feature has to work with no JavaScript (spec §6.2): the |
| 270 | // field is a GET form, so a search is an ordinary page at an ordinary |
| 271 | // URL. |
| 272 | func TestSearchWorksAsAPlainPageRequest(t *testing.T) { |
| 273 | app, err := NewForTest(t) |
| 274 | if err != nil { |
| 275 | t.Fatalf("NewForTest: %v", err) |
| 276 | } |
| 277 | seedTitles(t, app, "writer@example.com", "Meeting notes", "Packing list", "Waterworks history") |
| 278 | |
| 279 | rec := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, "/?q=packing", nil)) |
| 280 | if rec.Code != http.StatusOK { |
| 281 | t.Fatalf("got %d", rec.Code) |
| 282 | } |
| 283 | body := rec.Body.String() |
| 284 | if !strings.Contains(body, "Packing list") { |
| 285 | t.Error("the matching document is not on the page") |
| 286 | } |
| 287 | if strings.Contains(body, "Waterworks history") { |
| 288 | t.Error("a non-matching document is on the page") |
| 289 | } |
| 290 | // The query is echoed back, so the page can be edited and |
| 291 | // re-submitted rather than retyped. |
| 292 | if !strings.Contains(body, `value="packing"`) { |
| 293 | t.Error("the search field does not hold the query that produced the page") |
| 294 | } |
| 295 | // A search result list is ranked, so it must not also be grouped by |
| 296 | // recency — that would order the same rows twice by two rules. |
| 297 | // |
| 298 | // The RENDERED element, not the bare attribute name: list.html's |
| 299 | // own stylesheet contains "[docs-group-label]", so a substring |
| 300 | // check for the attribute matches every page whether or not a |
| 301 | // single group was rendered. That version of this check passed on |
| 302 | // a page with no groups on it at all. |
| 303 | if strings.Contains(body, "<h2 docs-group-label>") { |
| 304 | t.Error("search results are grouped by recency as well as ranked") |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | func TestSearchWithNoMatchesIsADesignedScreen(t *testing.T) { |
| 309 | app, err := NewForTest(t) |
| 310 | if err != nil { |
| 311 | t.Fatalf("NewForTest: %v", err) |
| 312 | } |
| 313 | seedTitles(t, app, "writer@example.com", "Meeting notes") |
| 314 | |
| 315 | rec := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, "/?q=zzzznothing", nil)) |
| 316 | body := rec.Body.String() |
| 317 | |
| 318 | // Asserted on the rendered structure, not on the English. A direct |
| 319 | // App.Handler() call installs no locale middleware, so rastrillo.T |
| 320 | // returns each key verbatim (render.go's own note on Locales) — |
| 321 | // comparing against Catalog's English fails here for a reason that |
| 322 | // has nothing to do with whether the screen is right. |
| 323 | if !strings.Contains(body, "rst-empty-title") { |
| 324 | t.Error("no empty state for a search that found nothing") |
| 325 | } |
| 326 | if strings.Contains(body, "<div rst-row>") { |
| 327 | t.Error("a search that matched nothing still rendered rows") |
| 328 | } |
| 329 | // And a way back out that does not require knowing the URL. |
| 330 | if !strings.Contains(body, "docs-search-clear") { |
| 331 | t.Error("no way to clear the search") |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | // A title is arbitrary text somebody typed, and it is rendered back |
| 336 | // into an HTML attribute on the results page. |
| 337 | func TestSearchQueryIsEscapedInTheField(t *testing.T) { |
| 338 | app, err := NewForTest(t) |
| 339 | if err != nil { |
| 340 | t.Fatalf("NewForTest: %v", err) |
| 341 | } |
| 342 | seedTitles(t, app, "writer@example.com", "Meeting notes") |
| 343 | |
| 344 | rec := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, |
| 345 | `/?q=%22%3E%3Cscript%3Ealert(1)%3C%2Fscript%3E`, nil)) |
| 346 | body := rec.Body.String() |
| 347 | if strings.Contains(body, "<script>alert(1)</script>") { |
| 348 | t.Error("the query was written into the page unescaped") |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | func TestSearchCapsItsResults(t *testing.T) { |
| 353 | app, err := NewForTest(t) |
| 354 | if err != nil { |
| 355 | t.Fatalf("NewForTest: %v", err) |
| 356 | } |
| 357 | ctx := context.Background() |
| 358 | owner := app.TestPerson(t, "writer@example.com") |
| 359 | for i := 0; i < maxSearchResults+10; i++ { |
| 360 | if _, err := app.Store().Create(ctx, owner, "Notes"); err != nil { |
| 361 | t.Fatal(err) |
| 362 | } |
| 363 | } |
| 364 | got, err := app.Store().Search(ctx, owner, "notes") |
| 365 | if err != nil { |
| 366 | t.Fatal(err) |
| 367 | } |
| 368 | if len(got) != maxSearchResults { |
| 369 | t.Errorf("got %d results, want the cap of %d", len(got), maxSearchResults) |
| 370 | } |
| 371 | } |
| 372 | |