oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
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.
14func 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
26func 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.
37func 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
69func 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.
98func 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.
128func 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.
163func 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.
183func 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
217func 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
255func 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.
272func 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
308func 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.
337func 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
352func 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