oficina / memoria Public

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

Plain git — no account needed to clone.

Download

Download this file

1package memoriatest
2
3import (
4 "context"
5 "io"
6 "log/slog"
7 "net/http"
8 "net/url"
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13
14 "amadan.net/rastrillo/rastrillo/db"
15
16 memoria "amadan.net/oficina/memoria/internal/memoria"
17 "amadan.net/oficina/memoria/internal/memoria/notion"
18)
19
20// testApp is the app's test surface. Declared as an interface because
21// AppForTest returns an unexported type, which a test in another package
22// cannot name.
23type testApp interface {
24 Store() *memoria.Store
25 AsPerson(string)
26 SetOwners([]string)
27 UseFakeSource(memoria.SourceClient, func(context.Context, string) (string, error))
28 WaitForSync(int64, time.Duration) bool
29}
30
31// fakeSource is a Notion stand-in for the run loop: a tree, some bodies,
32// and per-page errors. No HTTP — the client's own package tests that.
33type fakeSource struct {
34 roots []notion.Ref
35 children map[string][]notion.Ref
36 bodies map[string]string
37 unread map[string]int
38 failWith map[string]error
39 fetches map[string]int
40 // orphans are results search returned whose parent it did not — a
41 // page inside a toggle, or a middle ancestor missing from an
42 // eventually-consistent index. They must not be treated as roots.
43 orphans []notion.Ref
44}
45
46func newSource() *fakeSource {
47 return &fakeSource{
48 children: map[string][]notion.Ref{}, bodies: map[string]string{},
49 unread: map[string]int{}, failWith: map[string]error{}, fetches: map[string]int{},
50 }
51}
52
53func (f *fakeSource) Roots(ctx context.Context) (roots, orphans []notion.Ref, err error) {
54 if e := f.failWith["__roots__"]; e != nil {
55 return nil, nil, e
56 }
57 return f.roots, f.orphans, nil
58}
59
60func (f *fakeSource) Fetch(ctx context.Context, ref notion.Ref) (*notion.Content, error) {
61 f.fetches[ref.ID]++
62 if err := f.failWith[ref.ID]; err != nil {
63 return nil, err
64 }
65 return &notion.Content{
66 Markdown: f.bodies[ref.ID],
67 Children: f.children[ref.ID],
68 UnreadBlocks: f.unread[ref.ID],
69 }, nil
70}
71
72// add puts a page in the tree. parent "" makes it a root.
73func (f *fakeSource) add(id, title, parent, body string, path ...string) {
74 ref := notion.Ref{ID: id, Title: title, Path: path, EditedTime: "t1", Kind: notion.KindPage}
75 f.bodies[id] = body
76 if parent == "" {
77 f.roots = append(f.roots, ref)
78 return
79 }
80 f.children[parent] = append(f.children[parent], ref)
81}
82
83func (f *fakeSource) edit(id, body, editedTime string) {
84 f.bodies[id] = body
85 f.retime(id, editedTime)
86}
87
88func (f *fakeSource) retime(id, editedTime string) {
89 for i := range f.roots {
90 if f.roots[i].ID == id {
91 f.roots[i].EditedTime = editedTime
92 }
93 }
94 for p := range f.children {
95 for i := range f.children[p] {
96 if f.children[p][i].ID == id {
97 f.children[p][i].EditedTime = editedTime
98 }
99 }
100 }
101}
102
103// rename changes a page's title wherever it appears, and rewrites the
104// inherited ancestry of everything below it — which is what Notion does
105// NOT tell us about, since an ancestor rename leaves a descendant's own
106// last_edited_time untouched.
107func (f *fakeSource) rename(id, was, now string) {
108 retitle := func(r *notion.Ref) {
109 if r.ID == id {
110 r.Title = now
111 }
112 for i, seg := range r.Path {
113 if seg == was {
114 r.Path[i] = now
115 }
116 }
117 }
118 for i := range f.roots {
119 retitle(&f.roots[i])
120 }
121 for p := range f.children {
122 for i := range f.children[p] {
123 retitle(&f.children[p][i])
124 }
125 }
126}
127
128// connectorApp builds an app with a configured connector and a fake
129// Notion behind it.
130func connectorApp(t *testing.T, src *fakeSource) (http.Handler, testApp, int64, *strings.Builder) {
131 t.Helper()
132 var logs strings.Builder
133 logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
134 d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
135 if err != nil {
136 t.Fatalf("db.Open: %v", err)
137 }
138 t.Cleanup(func() { d.Close() })
139 mux, a, err := memoria.AppForTest(d, testOrigin, testDEK(t), logger)
140 if err != nil {
141 t.Fatalf("AppForTest: %v", err)
142 }
143 app := any(a).(testApp)
144 app.UseFakeSource(src, func(ctx context.Context, token string) (string, error) {
145 return "workspace-1", nil
146 })
147 if err := app.Store().SaveConnector(context.Background(), "notion", "ntn_secret_token_value", "workspace-1"); err != nil {
148 t.Fatal(err)
149 }
150 c, err := app.Store().Connector(context.Background(), "notion")
151 if err != nil {
152 t.Fatal(err)
153 }
154 return mux, app, c.ID, &logs
155}
156
157func sync(t *testing.T, h http.Handler, app testApp, connectorID int64) {
158 t.Helper()
159 rec := post(t, h, "/connectors/notion/sync", url.Values{})
160 if rec.Code != http.StatusSeeOther {
161 t.Fatalf("sync: status %d, want 303", rec.Code)
162 }
163 if !app.WaitForSync(connectorID, 5*time.Second) {
164 t.Fatal("sync did not finish")
165 }
166}
167
168func latest(t *testing.T, app testApp, connectorID int64) *memoria.SyncRun {
169 t.Helper()
170 run, err := app.Store().LatestRun(context.Background(), connectorID)
171 if err != nil {
172 t.Fatal(err)
173 }
174 if run == nil {
175 t.Fatal("no run recorded")
176 }
177 return run
178}
179
180// TestSyncImportsTheTreeWithAncestry is the happy path, and the vacuity
181// guard for everything below it: a gate about failures proves nothing if
182// the success case never worked.
183func TestSyncImportsTheTreeWithAncestry(t *testing.T) {
184 src := newSource()
185 src.add("root", "Handbook", "", "# Welcome\n\nEverything a new starter needs.")
186 src.add("kid", "Onboarding", "root", "Week one covers expenses and travel booking.", "Handbook")
187 src.add("grandkid", "Equipment", "kid", "Laptops are ordered through the request form.", "Handbook", "Onboarding")
188
189 h, app, cid, logs := connectorApp(t, src)
190 sync(t, h, app, cid)
191
192 run := latest(t, app, cid)
193 if run.State != memoria.RunDone {
194 t.Fatalf("run state %q, want done (error %q)\nLOGS:\n%s", run.State, run.ErrorCode, logs.String())
195 }
196 if run.Total != 3 {
197 t.Errorf("total %d, want 3", run.Total)
198 }
199 if run.Fetched != 3 {
200 t.Errorf("fetched %d, want 3", run.Fetched)
201 }
202 // The ancestry must be searchable, or carrying it was pointless.
203 hits, err := searchAsReader(app.Store(), context.Background(), "onboarding", 10)
204 if err != nil {
205 t.Fatal(err)
206 }
207 if len(hits) == 0 {
208 t.Error("the imported ancestry is not searchable")
209 }
210}
211
212// TestAFailedPageIsNamedNotAbsent. A page that has gone between
213// enumeration and fetch must be counted and recorded, never dropped: 3
214// unreadable pages inside 1,930 that simply vanish read as a complete
215// import.
216//
217// MUTATION-TESTED 2026-09-05: replacing the markByPage/stats.failed++ in
218// the gone(err) branch with a bare `continue` makes this fail. Reverted.
219func TestAFailedPageIsNamedNotAbsent(t *testing.T) {
220 src := newSource()
221 src.add("root", "Handbook", "", "Everything a new starter needs.")
222 src.add("kid", "Onboarding", "root", "Week one.", "Handbook")
223 src.failWith["kid"] = &notion.Error{Code: notion.CodeTraversalFailed, Status: 404}
224
225 h, app, cid, _ := connectorApp(t, src)
226 sync(t, h, app, cid)
227
228 run := latest(t, app, cid)
229 if run.State != memoria.RunDone {
230 t.Errorf("state %q, want done — one missing page must not fail the whole import", run.State)
231 }
232 if run.FailedPages != 1 {
233 t.Errorf("failed_pages %d, want 1 — the page vanished from the totals", run.FailedPages)
234 }
235 // The label is unconditional markup, so asserting on it proves
236 // nothing — an earlier version of this test did exactly that. The
237 // NOTE beside the count renders only when there were failures.
238 body := get(t, h, "/connectors/notion").Body.String()
239 if !strings.Contains(body, "counted here rather than left out of the totals") {
240 t.Error("the status page does not say the unreadable pages are counted rather than dropped")
241 }
242}
243
244// TestTraversalFailureNeverReportsDone. A failure BELOW a page means the
245// subtree was never enumerated. Continuing would report a complete
246// import while missing everything under that point — a failure rendered
247// as an absence, which this codebase ranks as the worst available.
248//
249// MUTATION-TESTED 2026-09-05: treating every fetch error as a per-page
250// failure (making gone() return true unconditionally) makes this fail.
251// Reverted.
252func TestTraversalFailureNeverReportsDone(t *testing.T) {
253 src := newSource()
254 src.add("root", "Handbook", "", "Everything a new starter needs.")
255 src.add("kid", "Onboarding", "root", "Week one.", "Handbook")
256 src.failWith["kid"] = &notion.Error{Code: notion.CodeRateLimited, Status: 429}
257
258 h, app, cid, _ := connectorApp(t, src)
259 sync(t, h, app, cid)
260
261 run := latest(t, app, cid)
262 if run.State != memoria.RunFailed {
263 t.Fatalf("state %q, want failed — the pages below the failure were never seen", run.State)
264 }
265 if run.ErrorCode != string(notion.CodeRateLimited) {
266 t.Errorf("error_code %q, want %q", run.ErrorCode, notion.CodeRateLimited)
267 }
268 body := get(t, h, "/connectors/notion").Body.String()
269 if strings.Contains(body, "Import finished.") {
270 t.Error("a failed import is described as finished")
271 }
272}
273
274// TestSyncNeverDeletesOnAbsence. memoria is replacing Notion, so a page
275// disappearing upstream is the expected end state of a migration, not a
276// signal to forget it. Archiving a page in Notion after migrating it
277// must not delete it here.
278//
279// MUTATION-TESTED 2026-09-05: adding a diff-and-delete pass over ledger
280// rows not seen by the latest run makes this fail. Reverted.
281func TestSyncNeverDeletesOnAbsence(t *testing.T) {
282 src := newSource()
283 src.add("root", "Handbook", "", "Everything a new starter needs.")
284 src.add("kid", "Onboarding", "root", "Week one covers expenses.", "Handbook")
285
286 h, app, cid, _ := connectorApp(t, src)
287 sync(t, h, app, cid)
288 if hits, _ := searchAsReader(app.Store(), context.Background(), "expenses", 10); len(hits) == 0 {
289 t.Fatal("the page was never imported, so its survival proves nothing")
290 }
291
292 // The page is archived in Notion: it is simply no longer returned.
293 src.children["root"] = nil
294 sync(t, h, app, cid)
295
296 run := latest(t, app, cid)
297 if run.State != memoria.RunDone {
298 t.Errorf("state %q, want done — an absence is not an error either", run.State)
299 }
300 if hits, _ := searchAsReader(app.Store(), context.Background(), "expenses", 10); len(hits) == 0 {
301 t.Error("a page that vanished from Notion was deleted from memoria")
302 }
303}
304
305// TestResumeSkipsWhatIsAlreadyImported. The ledger is per connector, not
306// per run, which is what makes a second run continue rather than start
307// over.
308//
309// MUTATION-TESTED 2026-09-05: making upToDate always return false
310// re-ingests everything and makes the skipped assertion fail. Reverted.
311func TestResumeSkipsWhatIsAlreadyImported(t *testing.T) {
312 src := newSource()
313 src.add("root", "Handbook", "", "Everything a new starter needs.")
314 src.add("kid", "Onboarding", "root", "Week one covers expenses.", "Handbook")
315
316 h, app, cid, _ := connectorApp(t, src)
317 sync(t, h, app, cid)
318 sync(t, h, app, cid)
319
320 run := latest(t, app, cid)
321 if run.Skipped != 2 {
322 t.Errorf("skipped %d on an unchanged re-sync, want 2 — nothing changed upstream", run.Skipped)
323 }
324 if run.Fetched != 0 {
325 t.Errorf("fetched %d on an unchanged re-sync, want 0", run.Fetched)
326 }
327}
328
329// TestEditAndAncestorRenameBothReachMemoria.
330//
331// The rename half is the one an earlier design missed entirely. Editing
332// a page changes its own last_edited_time; renaming its ANCESTOR does
333// not, so a sync keyed only on edit times leaves every descendant of a
334// renamed page carrying stale ancestry forever, with nothing upstream
335// ever signalling it.
336//
337// MUTATION-TESTED 2026-09-05: dropping the IngestedPath comparison from
338// upToDate (leaving only the edit-time check) makes the rename half
339// fail. Reverted.
340func TestEditAndAncestorRenameBothReachMemoria(t *testing.T) {
341 src := newSource()
342 src.add("root", "Handbook", "", "Everything a new starter needs.")
343 src.add("kid", "Onboarding", "root", "Week one covers expenses.", "Handbook")
344
345 h, app, cid, _ := connectorApp(t, src)
346 sync(t, h, app, cid)
347
348 t.Run("an edit is re-ingested", func(t *testing.T) {
349 src.edit("kid", "Week one now covers parking permits.", "t2")
350 sync(t, h, app, cid)
351 hits, _ := searchAsReader(app.Store(), context.Background(), "parking permits", 10)
352 if len(hits) == 0 {
353 t.Error("an edited page was not re-ingested")
354 }
355 })
356
357 t.Run("an ancestor rename is re-ingested", func(t *testing.T) {
358 // Handbook becomes Playbook. The CHILD's own edit time does not
359 // move — Notion would not move it either.
360 src.rename("root", "Handbook", "Playbook")
361 sync(t, h, app, cid)
362
363 if hits, _ := searchAsReader(app.Store(), context.Background(), "playbook", 10); len(hits) == 0 {
364 t.Error("the new ancestry is not searchable: an ancestor rename never reached memoria")
365 }
366 if hits, _ := searchAsReader(app.Store(), context.Background(), "handbook", 10); len(hits) != 0 {
367 t.Error("the old ancestry is still searchable after an ancestor rename")
368 }
369 })
370}
371
372// TestInterruptedRunDoesNotWedgeTheConnector.
373//
374// The run is a goroutine. When the platform sleeps the process it dies
375// having written nothing, leaving the row "fetching" — and a
376// single-flight check that trusted that row would refuse every later
377// sync as already-running, permanently, on the NORMAL path. Boot
378// reclaims them, which is sound by construction: a process that has just
379// started cannot be running a sync.
380//
381// MUTATION-TESTED 2026-09-05: removing the ReclaimRuns call from App()
382// makes this fail with "a sync is already running". Reverted.
383func TestInterruptedRunDoesNotWedgeTheConnector(t *testing.T) {
384 src := newSource()
385 src.add("root", "Handbook", "", "Everything a new starter needs.")
386
387 dir := t.TempDir()
388 path := filepath.Join(dir, "app.db")
389 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
390
391 d, err := db.Open(path, logger)
392 if err != nil {
393 t.Fatal(err)
394 }
395 _, a, err := memoria.AppForTest(d, testOrigin, testDEK(t), logger)
396 if err != nil {
397 t.Fatal(err)
398 }
399 app := any(a).(testApp)
400 if err := app.Store().SaveConnector(context.Background(), "notion", "tok", "w"); err != nil {
401 t.Fatal(err)
402 }
403 c, err := app.Store().Connector(context.Background(), "notion")
404 if err != nil {
405 t.Fatal(err)
406 }
407 // A run starts and the process dies: the row stays in flight.
408 if _, err := app.Store().StartRun(context.Background(), c.ID); err != nil {
409 t.Fatal(err)
410 }
411 d.Close()
412
413 // Boot again over the same database.
414 d2, err := db.Open(path, logger)
415 if err != nil {
416 t.Fatal(err)
417 }
418 t.Cleanup(func() { d2.Close() })
419 h2, a2, err := memoria.AppForTest(d2, testOrigin, testDEK(t), logger)
420 if err != nil {
421 t.Fatal(err)
422 }
423 app2 := any(a2).(testApp)
424 app2.UseFakeSource(src, nil)
425
426 rec := post(t, h2, "/connectors/notion/sync", url.Values{})
427 if loc := rec.Header().Get("Location"); strings.Contains(loc, "err=inflight") {
428 t.Fatal("the connector is wedged: a run killed by a restart still blocks every later sync")
429 }
430 if !app2.WaitForSync(c.ID, 5*time.Second) {
431 t.Fatal("the new run did not finish")
432 }
433}
434
435// TestNoNotionContentInLogs extends gate 4 to the connector. Titles,
436// bodies, ancestry, page ids and the token are all content or
437// coordinates, and `carlos logs` is storage we cannot grep.
438//
439// The failing page's TITLE is the canary, so the fetch-error path is
440// exercised with content in scope. It is not a canary in a response
441// body: this fake returns a Go error with no body at all, and an earlier
442// version of this comment claimed otherwise. Notion's real 404 body does
443// quote a page id and the connection name, and that path is covered
444// where it can be — notion.TestErrorsCarryNoResponseBody.
445//
446// MUTATION-TESTED 2026-09-05: logging ref.Title at Info in the fetch
447// loop, or %w-ing the notion error into the run's failure line, makes
448// this fail. Reverted.
449func TestNoNotionContentInLogs(t *testing.T) {
450 const secret = "WOMBATCONFIDENTIAL9931"
451 src := newSource()
452 src.add("root", secret+" handbook", "", "A passage about "+secret+" and revenue.")
453 src.add("kid", "Onboarding", "root", "Week one.", secret+" handbook")
454 src.failWith["kid"] = &notion.Error{Code: notion.CodeTraversalFailed, Status: 404}
455
456 h, app, cid, logs := connectorApp(t, src)
457 sync(t, h, app, cid)
458 get(t, h, "/connectors/notion")
459
460 got := logs.String()
461 if strings.Contains(got, secret) {
462 t.Errorf("a log line carries Notion content:\n%s", got)
463 }
464 if strings.Contains(got, "ntn_secret_token_value") {
465 t.Errorf("a log line carries the integration token:\n%s", got)
466 }
467 // Vacuity guard: the run must actually have logged something, or
468 // this scan proves nothing.
469 if !strings.Contains(got, "sync") {
470 t.Fatal("no sync log lines at all; this gate scanned nothing relevant")
471 }
472}
473
474// TestTokenIsNeverRendered. A stored token must not come back to the
475// page — not in a value attribute, not masked. A masked secret is still
476// a secret in the DOM.
477//
478// "No substring" would be vacuous, since every single character is a
479// substring; the test looks for the whole token and for any run of eight
480// characters of it.
481//
482// MUTATION-TESTED 2026-09-05: rendering the token into the input's value
483// makes this fail. Reverted.
484func TestTokenIsNeverRendered(t *testing.T) {
485 const token = "ntn_secret_token_value"
486 src := newSource()
487 h, app, _, _ := connectorApp(t, src)
488
489 // The CONNECTED page, which is the one with a stored token and a
490 // field that could carry it. An earlier version of this gate looked
491 // only at the not-yet-connected page — which has no token field at
492 // all — so rendering the token into the rotation form left it green.
493 // Found by mutation-testing this gate.
494 body := get(t, h, "/connectors/notion").Body.String()
495 if !strings.Contains(body, `name="token"`) {
496 t.Fatal("the connected page has no token field, so this gate is scanning a page that could not leak one")
497 }
498 assertNoToken(t, "connected page", body, token)
499
500 // And the not-yet-connected page, for completeness.
501 if err := app.Store().DeleteConnector(context.Background(), "notion"); err != nil {
502 t.Fatal(err)
503 }
504 assertNoToken(t, "setup page", get(t, h, "/connectors/notion").Body.String(), token)
505}
506
507// assertNoToken looks for the whole token and for any eight-character run
508// of it. "No substring" would be vacuous — every single character is a
509// substring of the token.
510func assertNoToken(t *testing.T, where, body, token string) {
511 t.Helper()
512 if strings.Contains(body, token) {
513 t.Errorf("%s renders the whole token", where)
514 return
515 }
516 for i := 0; i+8 <= len(token); i++ {
517 if strings.Contains(body, token[i:i+8]) {
518 t.Errorf("%s renders %q, an 8-character run of the token", where, token[i:i+8])
519 return
520 }
521 }
522}
523
524// TestConnectorRoutesRefuseANonOwner.
525//
526// In slice 0 "owner" is the whole admitted set, so this gate is about
527// the mechanism rather than a live distinction — but the mechanism has
528// to exist and fail closed before slice 1 gives it something to
529// separate. It could not be written at all until AppForTest grew an
530// injectable identity.
531//
532// MUTATION-TESTED 2026-09-05: making requireOwner call next
533// unconditionally makes this fail. Reverted.
534func TestConnectorRoutesRefuseANonOwner(t *testing.T) {
535 src := newSource()
536 h, app, _, _ := connectorApp(t, src)
537
538 if code := get(t, h, "/connectors").Code; code != http.StatusOK {
539 t.Fatalf("an owner got %d from /connectors; this gate proves nothing if nobody can reach it", code)
540 }
541
542 app.AsPerson("stranger@example.com")
543 for _, target := range []string{"/connectors", "/connectors/notion"} {
544 // 404, not 403: a 403 confirms the route exists.
545 if code := get(t, h, target).Code; code != http.StatusNotFound {
546 t.Errorf("a non-owner got %d from %s, want 404", code, target)
547 }
548 }
549 if code := post(t, h, "/connectors/notion/sync", url.Values{}).Code; code != http.StatusNotFound {
550 t.Errorf("a non-owner could reach the sync route (%d)", code)
551 }
552}
553
554// TestDisconnectKeepsTheDocuments. Parent §3.6 promises that
555// disconnecting stops memoria seeing new changes and does not remove
556// what memoria already holds. This is that promise as a test.
557//
558// MUTATION-TESTED 2026-09-05: deleting documents in DeleteConnector
559// makes this fail. Reverted.
560func TestDisconnectKeepsTheDocuments(t *testing.T) {
561 src := newSource()
562 src.add("root", "Handbook", "", "Everything a new starter needs about expenses.")
563
564 h, app, cid, _ := connectorApp(t, src)
565 sync(t, h, app, cid)
566 if hits, _ := searchAsReader(app.Store(), context.Background(), "expenses", 10); len(hits) == 0 {
567 t.Fatal("nothing was imported, so keeping it proves nothing")
568 }
569
570 if code := post(t, h, "/connectors/notion/disconnect", url.Values{}).Code; code != http.StatusSeeOther {
571 t.Fatalf("disconnect: status %d", code)
572 }
573 if _, err := app.Store().Connector(context.Background(), "notion"); err == nil {
574 t.Error("the connector survived disconnect")
575 }
576 // The DATABASE, not only the index. An earlier version of this gate
577 // asserted searchability alone, and deleting the document rows left
578 // the in-memory index still answering — so the gate passed while the
579 // documents were gone. Found by mutation-testing it.
580 docs, err := app.Store().Summaries(context.Background())
581 if err != nil {
582 t.Fatal(err)
583 }
584 if len(docs) == 0 {
585 t.Error("disconnecting deleted the stored documents; what memoria imported, memoria keeps")
586 }
587 if hits, _ := searchAsReader(app.Store(), context.Background(), "expenses", 10); len(hits) == 0 {
588 t.Error("disconnecting removed the documents from the index")
589 }
590}
591
592// TestEmptyPageIsImportedAndCounted. Real workspaces are full of
593// placeholders. Failing the run on one lets a single empty page block an
594// import; skipping makes it an absence with nothing to explain it.
595//
596// MUTATION-TESTED 2026-09-05: turning AllowTitleOnly off in Syncer.ingest
597// makes the run record a failure instead of an empty page. Reverted.
598func TestEmptyPageIsImportedAndCounted(t *testing.T) {
599 src := newSource()
600 src.add("root", "Quarterly planning", "", "")
601
602 h, app, cid, _ := connectorApp(t, src)
603 sync(t, h, app, cid)
604
605 run := latest(t, app, cid)
606 if run.Empty != 1 {
607 t.Errorf("empty %d, want 1 (failed %d)", run.Empty, run.FailedPages)
608 }
609 if hits, _ := searchAsReader(app.Store(), context.Background(), "quarterly planning", 10); len(hits) == 0 {
610 t.Error("a title-only page is not findable by the only words it has")
611 }
612}
613
614// TestUnreadBlocksSurfaceOnTheStatusPage. A page that came in thin should
615// carry a reason rather than look like a thin page.
616func TestUnreadBlocksSurfaceOnTheStatusPage(t *testing.T) {
617 src := newSource()
618 src.add("root", "Notes", "", "A readable paragraph.")
619 src.unread["root"] = 3
620
621 h, app, cid, _ := connectorApp(t, src)
622 sync(t, h, app, cid)
623
624 if run := latest(t, app, cid); run.UnreadBlocks != 3 {
625 t.Fatalf("unread_blocks %d, want 3", run.UnreadBlocks)
626 }
627 if body := get(t, h, "/connectors/notion").Body.String(); !strings.Contains(body, "does not read") {
628 t.Error("the status page does not mention blocks memoria could not read")
629 }
630}
631