oficina / memoria Public

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

Plain git — no account needed to clone.

Download

Download this file

1// A deleted document must not survive as a citation.
2//
3// These tests live in package memoria for the same reason lag_test.go
4// does: reaching the state they are about means breaking the in-memory
5// index under a running app, and the only way to do that from outside
6// would be to grow a production method whose only caller is a test.
7package memoria
8
9import (
10 "context"
11 "net/http"
12 "net/http/httptest"
13 "strconv"
14 "strings"
15 "testing"
16
17 "amadan.net/oficina/memoria/internal/memoria/index"
18)
19
20// storeOneDocument puts a document in and proves it is findable, so every
21// test below starts from a document that is genuinely there.
22func storeOneDocument(t *testing.T, a *app, term string) int64 {
23 t.Helper()
24 ctx := context.Background()
25 id, err := a.store.Ingest(ctx, IngestSpec{
26 Source: "upload", Key: "regional-notes.txt", Title: "regional notes",
27 Body: "The " + term + " terms for the departing regional lead were agreed in March.",
28 })
29 if err != nil {
30 t.Fatalf("Ingest: %v", err)
31 }
32 hits, err := searchAsReader(a.store, ctx, term, 10)
33 if err != nil {
34 t.Fatalf("Search: %v", err)
35 }
36 if len(hits) != 1 {
37 t.Fatalf("%d hits for the freshly stored document, want 1. Nothing below is about a "+
38 "stale citation if the document was never findable in the first place", len(hits))
39 }
40 return id
41}
42
43// postDelete presses the delete button the way the page does.
44func postDelete(t *testing.T, h http.Handler, id int64) *httptest.ResponseRecorder {
45 t.Helper()
46 req := httptest.NewRequest(http.MethodPost, "/documents/"+strconv.FormatInt(id, 10)+"/delete", nil)
47 req.Header.Set("Origin", lagOrigin)
48 rec := httptest.NewRecorder()
49 h.ServeHTTP(rec, req)
50 return rec
51}
52
53// getWithFlash follows a redirect carrying whatever cookie came back, so
54// the sentence a scriptless browser is shown is read off the page it is
55// shown on rather than out of a header.
56func getWithFlash(t *testing.T, h http.Handler, path string, from *httptest.ResponseRecorder) string {
57 t.Helper()
58 req := httptest.NewRequest(http.MethodGet, path, nil)
59 for _, c := range from.Result().Cookies() {
60 req.AddCookie(c)
61 }
62 rec := httptest.NewRecorder()
63 h.ServeHTTP(rec, req)
64 if rec.Code != http.StatusOK {
65 t.Fatalf("GET %s answered %d, want 200", path, rec.Code)
66 }
67 return rec.Body.String()
68}
69
70// A search never cites a document the database does not have.
71//
72// The failure this replaces: Store.Delete removed the rows in a
73// transaction and then removed the index entries, and a failure of that
74// second step was returned as a plain error and swallowed by the handler.
75// The document was gone and its passages stayed in the index, so Search
76// returned a hit for it, the handler resolved the title out of the
77// database and found nothing, and the reader got an empty-titled result
78// linking to a page that 404s — a citation to text that is not there,
79// which is the failure §4.1 exists to prevent.
80//
81// The divergence is produced by running the real Delete with the store's
82// index swapped for a scratch one, so the removal does not reach the
83// index Search reads. That is exactly the state a failed RemoveDocument
84// leaves — rows gone, entries still there — and it is reached this way
85// rather than by deleting rows in the test because Delete is the code
86// that produces it. There is no way to make the index's DELETE fail while
87// its SELECT still works: closing it breaks both.
88//
89// This test stays honest after the ordering fix in Delete, and that is
90// the point of it. Removing the index entries first stops Delete creating
91// this state; it does not stop it existing — an ingest racing a delete on
92// the same document can still re-index it between the two steps, and a
93// partial rebuild can leave it at boot. Whatever the index says, the
94// database decides whether a passage may be cited.
95//
96// MUTATION-TESTED 2026-09-04, each reverted, each observed red:
97//
98// (a) the `if !livePassage[h.ChunkID] { continue }` guard deleted from
99// Store.Search — the code as it stood before this review → "the
100// search cited 1 document(s) that are not in the database ... its
101// title came back empty ("")", and the page assertion under it
102// (b) `livePassage` filled from the HITS instead of from the chunk rows
103// the database returned, which is how someone shortens this later
104// → the same two failures. The guard is only a guard because it
105// asks the database.
106//
107// A third was tried and came back GREEN, which is why the code no longer
108// has the thing it mutated: an earlier version of the guard also asked
109// whether the DOCUMENT was still in the database, and filling that map
110// from the hits changed nothing. A chunk row cannot outlive its document
111// row — one transaction, and the schema cascades — so the document half
112// could not fail for anything the passage half does not already catch,
113// and a guard nobody can see fail is the kind this file exists to stop.
114func TestASearchNeverCitesADocumentThatIsNotInTheDatabase(t *testing.T) {
115 h, a := appWithAWorkingIndex(t)
116 ctx := context.Background()
117 const term = "severance"
118 id := storeOneDocument(t, a, term)
119
120 // Delete for real, with an index that does not hear about it.
121 scratch, err := index.Open()
122 if err != nil {
123 t.Fatalf("index.Open: %v", err)
124 }
125 t.Cleanup(func() { scratch.Close() })
126 live := a.store.ix
127 a.store.ix = scratch
128 if err := a.store.Delete(ctx, id); err != nil {
129 t.Fatalf("Delete: %v", err)
130 }
131 a.store.ix = live
132
133 // Two vacuity guards, because either failing would make every
134 // assertion below pass for the wrong reason.
135 docs, err := a.store.Summaries(ctx)
136 if err != nil {
137 t.Fatalf("listing documents: %v", err)
138 }
139 if len(docs) != 0 {
140 t.Fatalf("%d documents are still in the database, want 0; the delete did not happen, so "+
141 "this is not a test about a stale index", len(docs))
142 }
143 if n, err := a.store.Count(); err != nil {
144 t.Fatalf("counting passages: %v", err)
145 } else if n == 0 {
146 t.Fatalf("the index holds no passages, so it is not stale and a search finding nothing " +
147 "here would prove nothing at all")
148 }
149
150 hits, err := searchAsReader(a.store, ctx, term, 10)
151 if err != nil {
152 t.Fatalf("Search: %v", err)
153 }
154 if len(hits) != 0 {
155 t.Errorf("the search cited %d document(s) that are not in the database: %v. The row is "+
156 "gone, so its title came back empty (%q) and the link goes to a page that 404s — "+
157 "a citation to text that is not there.", len(hits), hits, hits[0].Title)
158 }
159
160 // And the same thing where a reader would see it. A result rendered
161 // from a hit like that is a blank link on the page.
162 body := getWithFlash(t, h, "/?q="+term, httptest.NewRecorder())
163 if strings.Contains(body, `href="/documents/`+strconv.FormatInt(id, 10)+`#p`) {
164 t.Errorf("the results page links to document %d, which is deleted:\n%s", id, body)
165 }
166}
167
168// A search never cites a passage the document no longer contains.
169//
170// This is the same bug in the other function, and it is the one the
171// document check above does not catch. Ingest replaces a document's
172// chunks inside its transaction and drops the old index entries AFTER the
173// commit; when that drop fails it returns ErrIndexLag, and the document
174// is still there, so the stale entries pass every check that asks only
175// whether the document exists. What the reader gets is a snippet of text
176// the author deleted in the last revision, under a live title, linking to
177// an ordinal that now holds something else.
178//
179// It also makes the sentence the app says about ErrIndexLag wrong.
180// "Stored but will not be found by a search until this instance restarts"
181// is said about a document whose previous revision is still answering
182// searches.
183//
184// Built the same way as the test above, and with the same limitation: the
185// old entries are left behind by re-ingesting with the store's index
186// swapped for a scratch one, because there is no way to make the index's
187// DELETE fail while its SELECT still works.
188//
189// MUTATION-TESTED 2026-09-04, each reverted, each observed red:
190//
191// (a) `!livePassage[h.ChunkID]` weakened to the check this guard was
192// first written as — whether the DOCUMENT is still among the rows
193// the database returned — → "the search cited 1 passage(s) that are
194// no longer in the document ... quoted under the document's live
195// title". That is the mutation that matters here: the document IS
196// still there, so a check that looks at documents sees nothing
197// wrong, and the test above passes throughout.
198// (b) `livePassage` filled from the hits rather than from the chunk
199// rows the database returned → the same failure
200// (c) the guard deleted outright → the same failure, plus the deleted-
201// document test above
202func TestASearchNeverCitesAPassageTheDocumentNoLongerHas(t *testing.T) {
203 _, a := appWithAWorkingIndex(t)
204 ctx := context.Background()
205 const gone = "reorganisation"
206 id := storeOneDocument(t, a, gone)
207
208 // Re-ingest under the same key with the word taken out, with an index
209 // that does not hear about the old passages.
210 scratch, err := index.Open()
211 if err != nil {
212 t.Fatalf("index.Open: %v", err)
213 }
214 t.Cleanup(func() { scratch.Close() })
215 live := a.store.ix
216 a.store.ix = scratch
217 again, err := a.store.Ingest(ctx, IngestSpec{
218 Source: "upload", Key: "regional-notes.txt", Title: "regional notes",
219 Body: "The terms for the departing regional lead were agreed in March.",
220 })
221 if err != nil {
222 t.Fatalf("re-Ingest: %v", err)
223 }
224 a.store.ix = live
225 if again != id {
226 t.Fatalf("the re-ingest made document %d instead of updating %d, so this is two documents "+
227 "and not a revision at all", again, id)
228 }
229
230 // Vacuity: the document is still there, the word is genuinely out of
231 // it, and the index still holds the passage that had it.
232 body := passageText(t, a, id)
233 if strings.Contains(body, gone) {
234 t.Fatalf("the new revision still contains %q, so there is no stale passage here", gone)
235 }
236 if n, err := a.store.Count(); err != nil {
237 t.Fatalf("counting passages: %v", err)
238 } else if n == 0 {
239 t.Fatalf("the index holds no passages, so nothing stale survived and a search finding " +
240 "nothing here would prove nothing")
241 }
242
243 hits, err := searchAsReader(a.store, ctx, gone, 10)
244 if err != nil {
245 t.Fatalf("Search: %v", err)
246 }
247 if len(hits) != 0 {
248 t.Errorf("the search cited %d passage(s) that are no longer in the document: %v. The "+
249 "snippet is text the last revision removed, and it is quoted under the document's "+
250 "live title — a citation to text that is not there.", len(hits), hits)
251 }
252}
253
254// passageText joins a document's stored passages, so a test can say what
255// the document now contains rather than what it was given.
256func passageText(t *testing.T, a *app, id int64) string {
257 t.Helper()
258 ps, err := a.store.Passages(context.Background(), id)
259 if err != nil {
260 t.Fatalf("Passages: %v", err)
261 }
262 var b strings.Builder
263 for _, p := range ps {
264 b.WriteString(p.Text)
265 b.WriteString("\n")
266 }
267 return b.String()
268}
269
270// A delete the index refused is not a delete.
271//
272// Delete removes the index entries BEFORE the transaction, so a failure
273// there leaves the document whole and findable rather than deleted and
274// findable. The old order left the second of those: the row gone, the
275// passages still in the index, and — since deleteDocument swallowed the
276// error and redirected — nobody told. The passage someone deleted may be
277// exactly the passage that must not be findable, so the delete that
278// cannot be completed is refused outright.
279//
280// MUTATION-TESTED 2026-09-04, each reverted, each observed red:
281//
282// (a) Store.Delete's index removal moved back after the transaction —
283// the code as it stood before this review → "the document was
284// deleted from the database even though the index still holds its
285// passages"
286// (b) the notice deleted from deleteDocument's error branch → "the
287// page says nothing about the delete that did not happen"
288//
289// One more was tried and came back GREEN, recorded because a mutation
290// nobody can see fail is worth knowing about: deleting the early `return`
291// from that branch so it falls through to the redirect below. It is
292// behaviour-preserving — the flash is already set and one redirect still
293// goes out — so it is a style edit, not a defect, and no test should be
294// contorted to catch it.
295func TestADeleteTheIndexRefusesLeavesTheDocumentAlone(t *testing.T) {
296 h, a := appWithAWorkingIndex(t)
297 ctx := context.Background()
298 id := storeOneDocument(t, a, "redundancy")
299
300 // From here the index refuses everything, removals included.
301 if err := a.store.ix.Close(); err != nil {
302 t.Fatalf("closing the index: %v", err)
303 }
304
305 rec := postDelete(t, h, id)
306 if rec.Code != http.StatusSeeOther {
307 t.Fatalf("delete answered %d, want 303:\n%s", rec.Code, rec.Body.String())
308 }
309
310 docs, err := a.store.Summaries(ctx)
311 if err != nil {
312 t.Fatalf("listing documents: %v", err)
313 }
314 if len(docs) != 1 {
315 t.Errorf("%d documents are in the database, want 1: the document was deleted from the "+
316 "database even though the index still holds its passages, which leaves a passage "+
317 "that can be found and cited for a document that no longer exists.", len(docs))
318 }
319
320 c := newDropZone()
321 body := getWithFlash(t, h, "/", rec)
322 if !strings.Contains(body, c.DeleteFailed) {
323 t.Errorf("the page says nothing about the delete that did not happen:\n want %q in the "+
324 "page after the redirect", c.DeleteFailed)
325 }
326}
327
328// A failed delete's notice survives a visit to /add.
329//
330// It is the index's notice, and /add has nowhere to show it. When both
331// pages shared one cookie, whichever loaded first spent it, so opening
332// Add content in another tab was enough to lose the only word that a
333// delete had not happened.
334//
335// MUTATION-TESTED 2026-09-29, reverted: addContent takes indexNotice
336// instead of addNotice → "/add showed the failed delete's notice, which
337// belongs on the index".
338func TestAFailedDeleteNoticeWaitsForTheIndex(t *testing.T) {
339 h, a := appWithAWorkingIndex(t)
340 id := storeOneDocument(t, a, "redundancy")
341 if err := a.store.ix.Close(); err != nil {
342 t.Fatalf("closing the index: %v", err)
343 }
344
345 b := newCookieBrowser(t, h)
346 b.keep(postDelete(t, h, id))
347 visit := b.visit
348
349 failed := newDropZone().DeleteFailed
350 if strings.Contains(visit("/add"), failed) {
351 t.Error("/add showed the failed delete's notice, which belongs on the index")
352 }
353 if !strings.Contains(visit("/"), failed) {
354 t.Error("after a visit to /add, the index no longer says the delete failed")
355 }
356}
357
358// The ordinary delete still deletes, said here because the test above
359// only proves the refusal. A guard that made every delete fail would pass
360// it.
361//
362// MUTATION-TESTED 2026-09-04, reverted: the transaction removed from
363// Store.Delete, leaving only the index removal → "1 documents are in the
364// database after a delete that reported no error".
365func TestAnOrdinaryDeleteStillDeletes(t *testing.T) {
366 _, a := appWithAWorkingIndex(t)
367 ctx := context.Background()
368 const term = "restructure"
369 id := storeOneDocument(t, a, term)
370
371 if err := a.store.Delete(ctx, id); err != nil {
372 t.Fatalf("Delete: %v", err)
373 }
374 docs, err := a.store.Summaries(ctx)
375 if err != nil {
376 t.Fatalf("listing documents: %v", err)
377 }
378 if len(docs) != 0 {
379 t.Errorf("%d documents are in the database after a delete that reported no error", len(docs))
380 }
381 if n, err := a.store.Count(); err != nil {
382 t.Fatalf("counting passages: %v", err)
383 } else if n != 0 {
384 t.Errorf("%d passages are still indexed after a delete that reported no error", n)
385 }
386 hits, err := searchAsReader(a.store, ctx, term, 10)
387 if err != nil {
388 t.Fatalf("Search: %v", err)
389 }
390 if len(hits) != 0 {
391 t.Errorf("a deleted document is still searchable: %v", hits)
392 }
393}
394