| 1 | package memoriatest |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "regexp" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | func TestStoreThenFind(t *testing.T) { |
| 11 | h := newApp(t) |
| 12 | if rec := store(t, h, "Q3 board pack", "## Revenue\n\nJune revenue was 1,350,000 across all regions.\n\n## Costs\n\nHosting fell after the migration."); rec.Code != http.StatusSeeOther { |
| 13 | t.Fatalf("store: status %d, want 303", rec.Code) |
| 14 | } |
| 15 | body := get(t, h, "/?q=june+revenue").Body.String() |
| 16 | if !strings.Contains(body, "Q3 board pack") { |
| 17 | t.Errorf("search does not show the document title:\n%s", body) |
| 18 | } |
| 19 | if !strings.Contains(body, "Revenue") { |
| 20 | t.Error("search result carries no heading trail") |
| 21 | } |
| 22 | if !strings.Contains(body, "<mark>") { |
| 23 | t.Error("search result carries no highlight") |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | // The list must distinguish "nothing matched" from "nothing indexed". |
| 28 | // Conflating them is the plausible-empty-list failure. |
| 29 | func TestNoMatchSaysTheCorpusIsNotEmpty(t *testing.T) { |
| 30 | h := newApp(t) |
| 31 | store(t, h, "Notes", strings.Repeat("hosting migration notes. ", 20)) |
| 32 | body := get(t, h, "/?q=xylophone").Body.String() |
| 33 | // One quiet line (shared chrome §4), not "0 results" over a callout. |
| 34 | if !strings.Contains(body, "No matches for “xylophone”.") { |
| 35 | t.Errorf("no-match page does not say nothing matched:\n%s", body) |
| 36 | } |
| 37 | // The count is on the same page, in the footer, and it is not zero — |
| 38 | // so a reader can tell an absent answer from an empty index. |
| 39 | if !regexp.MustCompile(`[1-9][0-9]* passages? indexed`).MatchString(body) { |
| 40 | t.Error("no-match page does not say how much is indexed, so a reader " + |
| 41 | "cannot tell an absent answer from an empty index") |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | // Stored text is untrusted. A document containing markup must not |
| 46 | // execute in a search result or on the document page. |
| 47 | func TestStoredMarkupIsEscaped(t *testing.T) { |
| 48 | h := newApp(t) |
| 49 | const nasty = `<script>alert('xss')</script>` |
| 50 | store(t, h, "Payload "+nasty, "## Heading "+nasty+"\n\nA passage containing "+nasty+" inline and revenue too.") |
| 51 | |
| 52 | for _, target := range []string{"/?q=revenue", "/documents/1"} { |
| 53 | body := get(t, h, target).Body.String() |
| 54 | if strings.Contains(body, "<script>") { |
| 55 | t.Errorf("%s renders an unescaped <script>:\n%s", target, body) |
| 56 | } |
| 57 | if !strings.Contains(body, "<script>") { |
| 58 | t.Errorf("%s does not show the escaped form, so the payload may have been dropped "+ |
| 59 | "rather than escaped — check the snippet path", target) |
| 60 | } |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | // Re-storing under the same key is idempotent. The key is the filename |
| 65 | // for an upload and the title for a paste, so pasting the same text twice |
| 66 | // under the same title updates one document. |
| 67 | func TestReStoringUnderTheSameKeyDoesNotDuplicate(t *testing.T) { |
| 68 | h := newApp(t) |
| 69 | const text = "## Revenue\n\nJune revenue was 1,350,000 across every region we serve." |
| 70 | store(t, h, "Board pack", text) |
| 71 | store(t, h, "Board pack", text) |
| 72 | if body := get(t, h, "/").Body.String(); !strings.Contains(body, "1 document") { |
| 73 | t.Errorf("re-storing under one key made more than one document:\n%s", body) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | // Two different documents that happen to share text are two documents. |
| 78 | // Under the old scheme — external_id as a hash of the body — they |
| 79 | // collided into one, and the second silently overwrote the first's title. |
| 80 | func TestSameTextUnderDifferentKeysIsTwoDocuments(t *testing.T) { |
| 81 | h := newApp(t) |
| 82 | const text = "## Revenue\n\nJune revenue was 1,350,000 across every region we serve." |
| 83 | store(t, h, "Board pack", text) |
| 84 | store(t, h, "Minutes", text) |
| 85 | body := get(t, h, "/").Body.String() |
| 86 | if !strings.Contains(body, "2 documents") { |
| 87 | t.Errorf("two documents sharing text collapsed into one:\n%s", body) |
| 88 | } |
| 89 | for _, want := range []string{"Board pack", "Minutes"} { |
| 90 | if !strings.Contains(body, want) { |
| 91 | t.Errorf("%q is missing from the list", want) |
| 92 | } |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | // A changed document must not leave its old passages searchable: a |
| 97 | // citation to text that is no longer there is worse than a missing one. |
| 98 | func TestRevisingRemovesTheOldPassages(t *testing.T) { |
| 99 | h := newApp(t) |
| 100 | store(t, h, "Doc", "## Revenue\n\nThe zebrafinch figure was 100.") |
| 101 | if !strings.Contains(get(t, h, "/?q=zebrafinch").Body.String(), "Doc") { |
| 102 | t.Fatal("the first version was not indexed") |
| 103 | } |
| 104 | // Same title, different body: a new document under this design, |
| 105 | // since the hash differs. Delete the first and confirm it is gone. |
| 106 | if rec := post(t, h, "/documents/1/delete", nil); rec.Code != http.StatusSeeOther { |
| 107 | t.Fatalf("delete: status %d, want 303", rec.Code) |
| 108 | } |
| 109 | body := get(t, h, "/?q=zebrafinch").Body.String() |
| 110 | if strings.Contains(body, "<mark>") { |
| 111 | t.Errorf("a deleted document is still searchable:\n%s", body) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | func TestMissingDocument404s(t *testing.T) { |
| 116 | if rec := get(t, newApp(t), "/documents/999"); rec.Code != http.StatusNotFound { |
| 117 | t.Errorf("GET /documents/999: status %d, want 404", rec.Code) |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | func TestEmptySubmissionIsRefusedWithCopy(t *testing.T) { |
| 122 | h := newApp(t) |
| 123 | if rec := store(t, h, "", " "); rec.Code != http.StatusSeeOther { |
| 124 | t.Fatalf("empty store: status %d, want 303", rec.Code) |
| 125 | } |
| 126 | if !strings.Contains(get(t, h, "/add?err=empty").Body.String(), "nothing to store") { |
| 127 | t.Error("the empty-submission page does not say what went wrong") |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | // The footer states slice 0's honest claim on every screen. |
| 132 | // |
| 133 | // This test used to assert "nothing leaves this instance", which is how a |
| 134 | // false claim stayed put: the copy was wrong and the test held it there. |
| 135 | // Sign-in fetches https://<the address's domain>/.well-known/keymail |
| 136 | // before anyone is admitted, so something does leave. What is true, and |
| 137 | // what the product can support, is that no *document* does. |
| 138 | func TestFooterStatesTheClaimItCanSupport(t *testing.T) { |
| 139 | body := get(t, newApp(t), "/").Body.String() |
| 140 | if !strings.Contains(body, "no document ever") { |
| 141 | t.Errorf("the page does not state the document-egress property:\n%s", body) |
| 142 | } |
| 143 | if strings.Contains(body, "nothing leaves this instance") { |
| 144 | t.Error("the page overclaims: sign-in dials the address's domain") |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | // The heading has its own indexed column, so it must not also be inside |
| 149 | // the body column: it would count twice in BM25 and appear at the front |
| 150 | // of every snippet. Caught by reading a rendered page, not by a test — |
| 151 | // hence this one. |
| 152 | func TestSnippetDoesNotRepeatTheHeading(t *testing.T) { |
| 153 | h := newApp(t) |
| 154 | store(t, h, "Board pack", "## Revenue\n\nJune revenue was 1,350,000 across all regions and every product line we sell today.") |
| 155 | body := get(t, h, "/?q=june+revenue").Body.String() |
| 156 | |
| 157 | // Find the snippet paragraph and check it does not open with the |
| 158 | // heading text. |
| 159 | i := strings.Index(body, "rst-result-snippet") |
| 160 | if i < 0 { |
| 161 | t.Fatalf("no snippet rendered:\n%s", body) |
| 162 | } |
| 163 | snippet := body[i:min(i+300, len(body))] |
| 164 | // The heading is "Revenue"; the passage itself begins "June revenue". |
| 165 | if strings.Contains(snippet, "Revenue</mark> <mark>June") || strings.Contains(snippet, "Revenue June revenue") { |
| 166 | t.Errorf("the snippet repeats the heading before the body:\n%s", snippet) |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | // A search result links to "#p{n}" using the ordinal stored at ingest. |
| 171 | // The document page used to re-run the chunker at view time, so any |
| 172 | // change to chunking silently misaligned every citation on every existing |
| 173 | // document — and this branch changed the chunker twice. |
| 174 | func TestCitedAnchorExistsOnTheDocumentPage(t *testing.T) { |
| 175 | h := newApp(t) |
| 176 | store(t, h, "Board pack", |
| 177 | "## Revenue\nJune revenue was 1,350,000 across all regions.\n\n"+ |
| 178 | "## Costs\nHosting fell after the migration and support was flat.\n\n"+ |
| 179 | "## Risks\nOne customer is 22% of recurring revenue, which is the concentration risk.") |
| 180 | |
| 181 | results := get(t, h, "/?q=concentration").Body.String() |
| 182 | m := regexp.MustCompile(`/documents/(\d+)#p(\d+)`).FindStringSubmatch(results) |
| 183 | if m == nil { |
| 184 | t.Fatalf("no citation anchor in the results:\n%s", results) |
| 185 | } |
| 186 | page := get(t, h, "/documents/"+m[1]).Body.String() |
| 187 | anchor := `id="p` + m[2] + `"` |
| 188 | if !strings.Contains(page, anchor) { |
| 189 | t.Errorf("the search cited %s but the document page has no %s — "+ |
| 190 | "the citation points at nothing", m[0], anchor) |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | // A document edited and re-ingested under the same key must UPDATE, not |
| 195 | // accumulate. external_id used to be a hash of the body, so every edit |
| 196 | // produced a second document with the first still indexed under the same |
| 197 | // title — and Revision, which the design leans on for staleness, could |
| 198 | // only ever move for a byte-identical re-upload. Found by a code review. |
| 199 | func TestEditingUnderTheSameKeyUpdatesOneDocument(t *testing.T) { |
| 200 | h := newApp(t) |
| 201 | const key = "board-pack.md" |
| 202 | upload(t, h, key, "## Revenue\nThe zebrafinch figure was 100 for the quarter just gone.") |
| 203 | upload(t, h, key, "## Revenue\nThe wildebeest figure was 200 for the quarter just gone.") |
| 204 | |
| 205 | list := get(t, h, "/").Body.String() |
| 206 | if !strings.Contains(list, "1 document") { |
| 207 | t.Errorf("editing under the same key made more than one document:\n%s", list) |
| 208 | } |
| 209 | // The old passage must be gone, not merely outranked. |
| 210 | if got := get(t, h, "/?q=zebrafinch").Body.String(); strings.Contains(got, "<mark>") { |
| 211 | t.Error("the superseded passage is still searchable; a citation would point at text that is no longer there") |
| 212 | } |
| 213 | if got := get(t, h, "/?q=wildebeest").Body.String(); !strings.Contains(got, "<mark>") { |
| 214 | t.Error("the new passage was not indexed") |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | // A term that appears only in the document's title must find the |
| 219 | // document. Reported from the live instance: a paste titled "Passport |
| 220 | // numbers" whose body was numbers could not be found by "passport", and |
| 221 | // the page said so confidently — "1 passage indexed, so this is a real |
| 222 | // absence". It was not an absence. The index held two columns, heading |
| 223 | // and body, and neither is the title: a chunk's heading trail comes from |
| 224 | // Markdown headings *inside* the body, so a plain paste has none at all. |
| 225 | // The title was sealed, shown on every result, and never indexed. |
| 226 | func TestATermOnlyInTheTitleIsFound(t *testing.T) { |
| 227 | h := newApp(t) |
| 228 | store(t, h, "Passport numbers", "L898902C3\n\n7727833B1\n\n3041005D9") |
| 229 | body := get(t, h, "/?q=passport").Body.String() |
| 230 | // Count the result rows, not the title text. The same page lists |
| 231 | // every document below the results, so asserting on the title alone |
| 232 | // passes on a 0-result page — which is how the first draft of this |
| 233 | // test passed against the very bug it was written for. |
| 234 | if n := strings.Count(body, "rst-result-title"); n != 1 { |
| 235 | t.Errorf("a term that appears only in the title returns %d results, want 1:\n%s", n, body) |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | // The title must not drown the body. Indexing the title into every chunk |
| 240 | // makes it a term in each one; this checks a body match still ranks and |
| 241 | // that the title has not become the only thing that matches. |
| 242 | func TestBodyStillMatchesWhenTitleIsIndexed(t *testing.T) { |
| 243 | h := newApp(t) |
| 244 | store(t, h, "Passport numbers", "The renewal fee rose in June.") |
| 245 | body := get(t, h, "/?q=renewal").Body.String() |
| 246 | if n := strings.Count(body, "rst-result-title"); n != 1 { |
| 247 | t.Errorf("body term returns %d results, want 1:\n%s", n, body) |
| 248 | } |
| 249 | } |
| 250 | |