package memoriatest import ( "context" "io" "log/slog" "net/http" "net/url" "path/filepath" "strings" "testing" "time" "amadan.net/rastrillo/rastrillo/db" memoria "amadan.net/oficina/memoria/internal/memoria" "amadan.net/oficina/memoria/internal/memoria/notion" ) // testApp is the app's test surface. Declared as an interface because // AppForTest returns an unexported type, which a test in another package // cannot name. type testApp interface { Store() *memoria.Store AsPerson(string) SetOwners([]string) UseFakeSource(memoria.SourceClient, func(context.Context, string) (string, error)) WaitForSync(int64, time.Duration) bool } // fakeSource is a Notion stand-in for the run loop: a tree, some bodies, // and per-page errors. No HTTP — the client's own package tests that. type fakeSource struct { roots []notion.Ref children map[string][]notion.Ref bodies map[string]string unread map[string]int failWith map[string]error fetches map[string]int // orphans are results search returned whose parent it did not — a // page inside a toggle, or a middle ancestor missing from an // eventually-consistent index. They must not be treated as roots. orphans []notion.Ref } func newSource() *fakeSource { return &fakeSource{ children: map[string][]notion.Ref{}, bodies: map[string]string{}, unread: map[string]int{}, failWith: map[string]error{}, fetches: map[string]int{}, } } func (f *fakeSource) Roots(ctx context.Context) (roots, orphans []notion.Ref, err error) { if e := f.failWith["__roots__"]; e != nil { return nil, nil, e } return f.roots, f.orphans, nil } func (f *fakeSource) Fetch(ctx context.Context, ref notion.Ref) (*notion.Content, error) { f.fetches[ref.ID]++ if err := f.failWith[ref.ID]; err != nil { return nil, err } return ¬ion.Content{ Markdown: f.bodies[ref.ID], Children: f.children[ref.ID], UnreadBlocks: f.unread[ref.ID], }, nil } // add puts a page in the tree. parent "" makes it a root. func (f *fakeSource) add(id, title, parent, body string, path ...string) { ref := notion.Ref{ID: id, Title: title, Path: path, EditedTime: "t1", Kind: notion.KindPage} f.bodies[id] = body if parent == "" { f.roots = append(f.roots, ref) return } f.children[parent] = append(f.children[parent], ref) } func (f *fakeSource) edit(id, body, editedTime string) { f.bodies[id] = body f.retime(id, editedTime) } func (f *fakeSource) retime(id, editedTime string) { for i := range f.roots { if f.roots[i].ID == id { f.roots[i].EditedTime = editedTime } } for p := range f.children { for i := range f.children[p] { if f.children[p][i].ID == id { f.children[p][i].EditedTime = editedTime } } } } // rename changes a page's title wherever it appears, and rewrites the // inherited ancestry of everything below it — which is what Notion does // NOT tell us about, since an ancestor rename leaves a descendant's own // last_edited_time untouched. func (f *fakeSource) rename(id, was, now string) { retitle := func(r *notion.Ref) { if r.ID == id { r.Title = now } for i, seg := range r.Path { if seg == was { r.Path[i] = now } } } for i := range f.roots { retitle(&f.roots[i]) } for p := range f.children { for i := range f.children[p] { retitle(&f.children[p][i]) } } } // connectorApp builds an app with a configured connector and a fake // Notion behind it. func connectorApp(t *testing.T, src *fakeSource) (http.Handler, testApp, int64, *strings.Builder) { t.Helper() var logs strings.Builder logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) mux, a, err := memoria.AppForTest(d, testOrigin, testDEK(t), logger) if err != nil { t.Fatalf("AppForTest: %v", err) } app := any(a).(testApp) app.UseFakeSource(src, func(ctx context.Context, token string) (string, error) { return "workspace-1", nil }) if err := app.Store().SaveConnector(context.Background(), "notion", "ntn_secret_token_value", "workspace-1"); err != nil { t.Fatal(err) } c, err := app.Store().Connector(context.Background(), "notion") if err != nil { t.Fatal(err) } return mux, app, c.ID, &logs } func sync(t *testing.T, h http.Handler, app testApp, connectorID int64) { t.Helper() rec := post(t, h, "/connectors/notion/sync", url.Values{}) if rec.Code != http.StatusSeeOther { t.Fatalf("sync: status %d, want 303", rec.Code) } if !app.WaitForSync(connectorID, 5*time.Second) { t.Fatal("sync did not finish") } } func latest(t *testing.T, app testApp, connectorID int64) *memoria.SyncRun { t.Helper() run, err := app.Store().LatestRun(context.Background(), connectorID) if err != nil { t.Fatal(err) } if run == nil { t.Fatal("no run recorded") } return run } // TestSyncImportsTheTreeWithAncestry is the happy path, and the vacuity // guard for everything below it: a gate about failures proves nothing if // the success case never worked. func TestSyncImportsTheTreeWithAncestry(t *testing.T) { src := newSource() src.add("root", "Handbook", "", "# Welcome\n\nEverything a new starter needs.") src.add("kid", "Onboarding", "root", "Week one covers expenses and travel booking.", "Handbook") src.add("grandkid", "Equipment", "kid", "Laptops are ordered through the request form.", "Handbook", "Onboarding") h, app, cid, logs := connectorApp(t, src) sync(t, h, app, cid) run := latest(t, app, cid) if run.State != memoria.RunDone { t.Fatalf("run state %q, want done (error %q)\nLOGS:\n%s", run.State, run.ErrorCode, logs.String()) } if run.Total != 3 { t.Errorf("total %d, want 3", run.Total) } if run.Fetched != 3 { t.Errorf("fetched %d, want 3", run.Fetched) } // The ancestry must be searchable, or carrying it was pointless. hits, err := searchAsReader(app.Store(), context.Background(), "onboarding", 10) if err != nil { t.Fatal(err) } if len(hits) == 0 { t.Error("the imported ancestry is not searchable") } } // TestAFailedPageIsNamedNotAbsent. A page that has gone between // enumeration and fetch must be counted and recorded, never dropped: 3 // unreadable pages inside 1,930 that simply vanish read as a complete // import. // // MUTATION-TESTED 2026-09-05: replacing the markByPage/stats.failed++ in // the gone(err) branch with a bare `continue` makes this fail. Reverted. func TestAFailedPageIsNamedNotAbsent(t *testing.T) { src := newSource() src.add("root", "Handbook", "", "Everything a new starter needs.") src.add("kid", "Onboarding", "root", "Week one.", "Handbook") src.failWith["kid"] = ¬ion.Error{Code: notion.CodeTraversalFailed, Status: 404} h, app, cid, _ := connectorApp(t, src) sync(t, h, app, cid) run := latest(t, app, cid) if run.State != memoria.RunDone { t.Errorf("state %q, want done — one missing page must not fail the whole import", run.State) } if run.FailedPages != 1 { t.Errorf("failed_pages %d, want 1 — the page vanished from the totals", run.FailedPages) } // The label is unconditional markup, so asserting on it proves // nothing — an earlier version of this test did exactly that. The // NOTE beside the count renders only when there were failures. body := get(t, h, "/connectors/notion").Body.String() if !strings.Contains(body, "counted here rather than left out of the totals") { t.Error("the status page does not say the unreadable pages are counted rather than dropped") } } // TestTraversalFailureNeverReportsDone. A failure BELOW a page means the // subtree was never enumerated. Continuing would report a complete // import while missing everything under that point — a failure rendered // as an absence, which this codebase ranks as the worst available. // // MUTATION-TESTED 2026-09-05: treating every fetch error as a per-page // failure (making gone() return true unconditionally) makes this fail. // Reverted. func TestTraversalFailureNeverReportsDone(t *testing.T) { src := newSource() src.add("root", "Handbook", "", "Everything a new starter needs.") src.add("kid", "Onboarding", "root", "Week one.", "Handbook") src.failWith["kid"] = ¬ion.Error{Code: notion.CodeRateLimited, Status: 429} h, app, cid, _ := connectorApp(t, src) sync(t, h, app, cid) run := latest(t, app, cid) if run.State != memoria.RunFailed { t.Fatalf("state %q, want failed — the pages below the failure were never seen", run.State) } if run.ErrorCode != string(notion.CodeRateLimited) { t.Errorf("error_code %q, want %q", run.ErrorCode, notion.CodeRateLimited) } body := get(t, h, "/connectors/notion").Body.String() if strings.Contains(body, "Import finished.") { t.Error("a failed import is described as finished") } } // TestSyncNeverDeletesOnAbsence. memoria is replacing Notion, so a page // disappearing upstream is the expected end state of a migration, not a // signal to forget it. Archiving a page in Notion after migrating it // must not delete it here. // // MUTATION-TESTED 2026-09-05: adding a diff-and-delete pass over ledger // rows not seen by the latest run makes this fail. Reverted. func TestSyncNeverDeletesOnAbsence(t *testing.T) { src := newSource() src.add("root", "Handbook", "", "Everything a new starter needs.") src.add("kid", "Onboarding", "root", "Week one covers expenses.", "Handbook") h, app, cid, _ := connectorApp(t, src) sync(t, h, app, cid) if hits, _ := searchAsReader(app.Store(), context.Background(), "expenses", 10); len(hits) == 0 { t.Fatal("the page was never imported, so its survival proves nothing") } // The page is archived in Notion: it is simply no longer returned. src.children["root"] = nil sync(t, h, app, cid) run := latest(t, app, cid) if run.State != memoria.RunDone { t.Errorf("state %q, want done — an absence is not an error either", run.State) } if hits, _ := searchAsReader(app.Store(), context.Background(), "expenses", 10); len(hits) == 0 { t.Error("a page that vanished from Notion was deleted from memoria") } } // TestResumeSkipsWhatIsAlreadyImported. The ledger is per connector, not // per run, which is what makes a second run continue rather than start // over. // // MUTATION-TESTED 2026-09-05: making upToDate always return false // re-ingests everything and makes the skipped assertion fail. Reverted. func TestResumeSkipsWhatIsAlreadyImported(t *testing.T) { src := newSource() src.add("root", "Handbook", "", "Everything a new starter needs.") src.add("kid", "Onboarding", "root", "Week one covers expenses.", "Handbook") h, app, cid, _ := connectorApp(t, src) sync(t, h, app, cid) sync(t, h, app, cid) run := latest(t, app, cid) if run.Skipped != 2 { t.Errorf("skipped %d on an unchanged re-sync, want 2 — nothing changed upstream", run.Skipped) } if run.Fetched != 0 { t.Errorf("fetched %d on an unchanged re-sync, want 0", run.Fetched) } } // TestEditAndAncestorRenameBothReachMemoria. // // The rename half is the one an earlier design missed entirely. Editing // a page changes its own last_edited_time; renaming its ANCESTOR does // not, so a sync keyed only on edit times leaves every descendant of a // renamed page carrying stale ancestry forever, with nothing upstream // ever signalling it. // // MUTATION-TESTED 2026-09-05: dropping the IngestedPath comparison from // upToDate (leaving only the edit-time check) makes the rename half // fail. Reverted. func TestEditAndAncestorRenameBothReachMemoria(t *testing.T) { src := newSource() src.add("root", "Handbook", "", "Everything a new starter needs.") src.add("kid", "Onboarding", "root", "Week one covers expenses.", "Handbook") h, app, cid, _ := connectorApp(t, src) sync(t, h, app, cid) t.Run("an edit is re-ingested", func(t *testing.T) { src.edit("kid", "Week one now covers parking permits.", "t2") sync(t, h, app, cid) hits, _ := searchAsReader(app.Store(), context.Background(), "parking permits", 10) if len(hits) == 0 { t.Error("an edited page was not re-ingested") } }) t.Run("an ancestor rename is re-ingested", func(t *testing.T) { // Handbook becomes Playbook. The CHILD's own edit time does not // move — Notion would not move it either. src.rename("root", "Handbook", "Playbook") sync(t, h, app, cid) if hits, _ := searchAsReader(app.Store(), context.Background(), "playbook", 10); len(hits) == 0 { t.Error("the new ancestry is not searchable: an ancestor rename never reached memoria") } if hits, _ := searchAsReader(app.Store(), context.Background(), "handbook", 10); len(hits) != 0 { t.Error("the old ancestry is still searchable after an ancestor rename") } }) } // TestInterruptedRunDoesNotWedgeTheConnector. // // The run is a goroutine. When the platform sleeps the process it dies // having written nothing, leaving the row "fetching" — and a // single-flight check that trusted that row would refuse every later // sync as already-running, permanently, on the NORMAL path. Boot // reclaims them, which is sound by construction: a process that has just // started cannot be running a sync. // // MUTATION-TESTED 2026-09-05: removing the ReclaimRuns call from App() // makes this fail with "a sync is already running". Reverted. func TestInterruptedRunDoesNotWedgeTheConnector(t *testing.T) { src := newSource() src.add("root", "Handbook", "", "Everything a new starter needs.") dir := t.TempDir() path := filepath.Join(dir, "app.db") logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(path, logger) if err != nil { t.Fatal(err) } _, a, err := memoria.AppForTest(d, testOrigin, testDEK(t), logger) if err != nil { t.Fatal(err) } app := any(a).(testApp) if err := app.Store().SaveConnector(context.Background(), "notion", "tok", "w"); err != nil { t.Fatal(err) } c, err := app.Store().Connector(context.Background(), "notion") if err != nil { t.Fatal(err) } // A run starts and the process dies: the row stays in flight. if _, err := app.Store().StartRun(context.Background(), c.ID); err != nil { t.Fatal(err) } d.Close() // Boot again over the same database. d2, err := db.Open(path, logger) if err != nil { t.Fatal(err) } t.Cleanup(func() { d2.Close() }) h2, a2, err := memoria.AppForTest(d2, testOrigin, testDEK(t), logger) if err != nil { t.Fatal(err) } app2 := any(a2).(testApp) app2.UseFakeSource(src, nil) rec := post(t, h2, "/connectors/notion/sync", url.Values{}) if loc := rec.Header().Get("Location"); strings.Contains(loc, "err=inflight") { t.Fatal("the connector is wedged: a run killed by a restart still blocks every later sync") } if !app2.WaitForSync(c.ID, 5*time.Second) { t.Fatal("the new run did not finish") } } // TestNoNotionContentInLogs extends gate 4 to the connector. Titles, // bodies, ancestry, page ids and the token are all content or // coordinates, and `carlos logs` is storage we cannot grep. // // The failing page's TITLE is the canary, so the fetch-error path is // exercised with content in scope. It is not a canary in a response // body: this fake returns a Go error with no body at all, and an earlier // version of this comment claimed otherwise. Notion's real 404 body does // quote a page id and the connection name, and that path is covered // where it can be — notion.TestErrorsCarryNoResponseBody. // // MUTATION-TESTED 2026-09-05: logging ref.Title at Info in the fetch // loop, or %w-ing the notion error into the run's failure line, makes // this fail. Reverted. func TestNoNotionContentInLogs(t *testing.T) { const secret = "WOMBATCONFIDENTIAL9931" src := newSource() src.add("root", secret+" handbook", "", "A passage about "+secret+" and revenue.") src.add("kid", "Onboarding", "root", "Week one.", secret+" handbook") src.failWith["kid"] = ¬ion.Error{Code: notion.CodeTraversalFailed, Status: 404} h, app, cid, logs := connectorApp(t, src) sync(t, h, app, cid) get(t, h, "/connectors/notion") got := logs.String() if strings.Contains(got, secret) { t.Errorf("a log line carries Notion content:\n%s", got) } if strings.Contains(got, "ntn_secret_token_value") { t.Errorf("a log line carries the integration token:\n%s", got) } // Vacuity guard: the run must actually have logged something, or // this scan proves nothing. if !strings.Contains(got, "sync") { t.Fatal("no sync log lines at all; this gate scanned nothing relevant") } } // TestTokenIsNeverRendered. A stored token must not come back to the // page — not in a value attribute, not masked. A masked secret is still // a secret in the DOM. // // "No substring" would be vacuous, since every single character is a // substring; the test looks for the whole token and for any run of eight // characters of it. // // MUTATION-TESTED 2026-09-05: rendering the token into the input's value // makes this fail. Reverted. func TestTokenIsNeverRendered(t *testing.T) { const token = "ntn_secret_token_value" src := newSource() h, app, _, _ := connectorApp(t, src) // The CONNECTED page, which is the one with a stored token and a // field that could carry it. An earlier version of this gate looked // only at the not-yet-connected page — which has no token field at // all — so rendering the token into the rotation form left it green. // Found by mutation-testing this gate. body := get(t, h, "/connectors/notion").Body.String() if !strings.Contains(body, `name="token"`) { t.Fatal("the connected page has no token field, so this gate is scanning a page that could not leak one") } assertNoToken(t, "connected page", body, token) // And the not-yet-connected page, for completeness. if err := app.Store().DeleteConnector(context.Background(), "notion"); err != nil { t.Fatal(err) } assertNoToken(t, "setup page", get(t, h, "/connectors/notion").Body.String(), token) } // assertNoToken looks for the whole token and for any eight-character run // of it. "No substring" would be vacuous — every single character is a // substring of the token. func assertNoToken(t *testing.T, where, body, token string) { t.Helper() if strings.Contains(body, token) { t.Errorf("%s renders the whole token", where) return } for i := 0; i+8 <= len(token); i++ { if strings.Contains(body, token[i:i+8]) { t.Errorf("%s renders %q, an 8-character run of the token", where, token[i:i+8]) return } } } // TestConnectorRoutesRefuseANonOwner. // // In slice 0 "owner" is the whole admitted set, so this gate is about // the mechanism rather than a live distinction — but the mechanism has // to exist and fail closed before slice 1 gives it something to // separate. It could not be written at all until AppForTest grew an // injectable identity. // // MUTATION-TESTED 2026-09-05: making requireOwner call next // unconditionally makes this fail. Reverted. func TestConnectorRoutesRefuseANonOwner(t *testing.T) { src := newSource() h, app, _, _ := connectorApp(t, src) if code := get(t, h, "/connectors").Code; code != http.StatusOK { t.Fatalf("an owner got %d from /connectors; this gate proves nothing if nobody can reach it", code) } app.AsPerson("stranger@example.com") for _, target := range []string{"/connectors", "/connectors/notion"} { // 404, not 403: a 403 confirms the route exists. if code := get(t, h, target).Code; code != http.StatusNotFound { t.Errorf("a non-owner got %d from %s, want 404", code, target) } } if code := post(t, h, "/connectors/notion/sync", url.Values{}).Code; code != http.StatusNotFound { t.Errorf("a non-owner could reach the sync route (%d)", code) } } // TestDisconnectKeepsTheDocuments. Parent §3.6 promises that // disconnecting stops memoria seeing new changes and does not remove // what memoria already holds. This is that promise as a test. // // MUTATION-TESTED 2026-09-05: deleting documents in DeleteConnector // makes this fail. Reverted. func TestDisconnectKeepsTheDocuments(t *testing.T) { src := newSource() src.add("root", "Handbook", "", "Everything a new starter needs about expenses.") h, app, cid, _ := connectorApp(t, src) sync(t, h, app, cid) if hits, _ := searchAsReader(app.Store(), context.Background(), "expenses", 10); len(hits) == 0 { t.Fatal("nothing was imported, so keeping it proves nothing") } if code := post(t, h, "/connectors/notion/disconnect", url.Values{}).Code; code != http.StatusSeeOther { t.Fatalf("disconnect: status %d", code) } if _, err := app.Store().Connector(context.Background(), "notion"); err == nil { t.Error("the connector survived disconnect") } // The DATABASE, not only the index. An earlier version of this gate // asserted searchability alone, and deleting the document rows left // the in-memory index still answering — so the gate passed while the // documents were gone. Found by mutation-testing it. docs, err := app.Store().Summaries(context.Background()) if err != nil { t.Fatal(err) } if len(docs) == 0 { t.Error("disconnecting deleted the stored documents; what memoria imported, memoria keeps") } if hits, _ := searchAsReader(app.Store(), context.Background(), "expenses", 10); len(hits) == 0 { t.Error("disconnecting removed the documents from the index") } } // TestEmptyPageIsImportedAndCounted. Real workspaces are full of // placeholders. Failing the run on one lets a single empty page block an // import; skipping makes it an absence with nothing to explain it. // // MUTATION-TESTED 2026-09-05: turning AllowTitleOnly off in Syncer.ingest // makes the run record a failure instead of an empty page. Reverted. func TestEmptyPageIsImportedAndCounted(t *testing.T) { src := newSource() src.add("root", "Quarterly planning", "", "") h, app, cid, _ := connectorApp(t, src) sync(t, h, app, cid) run := latest(t, app, cid) if run.Empty != 1 { t.Errorf("empty %d, want 1 (failed %d)", run.Empty, run.FailedPages) } if hits, _ := searchAsReader(app.Store(), context.Background(), "quarterly planning", 10); len(hits) == 0 { t.Error("a title-only page is not findable by the only words it has") } } // TestUnreadBlocksSurfaceOnTheStatusPage. A page that came in thin should // carry a reason rather than look like a thin page. func TestUnreadBlocksSurfaceOnTheStatusPage(t *testing.T) { src := newSource() src.add("root", "Notes", "", "A readable paragraph.") src.unread["root"] = 3 h, app, cid, _ := connectorApp(t, src) sync(t, h, app, cid) if run := latest(t, app, cid); run.UnreadBlocks != 3 { t.Fatalf("unread_blocks %d, want 3", run.UnreadBlocks) } if body := get(t, h, "/connectors/notion").Body.String(); !strings.Contains(body, "does not read") { t.Error("the status page does not mention blocks memoria could not read") } }