oficina / memoria Public

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

Plain git — no account needed to clone.

Download

Download this file

1package notion
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "strings"
7 "testing"
8)
9
10func client(t *testing.T, f *fakeNotion) *Client {
11 t.Helper()
12 srv := f.start()
13 c, err := NewForTest("ntn_test", srv.URL)
14 if err != nil {
15 t.Fatal(err)
16 }
17 return c
18}
19
20// A small workspace: a root with a child and a grandchild, none of which
21// search will admit to except the root.
22func nestedFake(t *testing.T) *fakeNotion {
23 f := newFake(t)
24 f.page("root", "Handbook", "", heading(1, "Welcome"), para("Everything a new starter needs."))
25 f.page("kid", "Onboarding", "root", para("Week one covers expenses and travel booking."))
26 f.page("grandkid", "Equipment", "kid", para("Laptops are ordered through the request form."))
27 return f
28}
29
30// TestSearchIsNotUsedAsAnEnumerator is the gate for the whole traversal
31// design, and the reason draft 1 of the spec was rewritten.
32//
33// Notion documents that /v1/search "is not guaranteed to return
34// everything" and is "not optimized for exhaustively enumerating through
35// all the documents that a bot has access to" — and it returns NO field
36// saying a result was partial. So an enumerator built on search can page
37// cleanly to has_more:false, miss pages, and report a complete import,
38// with no error anywhere. The fake models exactly that: its search
39// returns only roots.
40//
41// MUTATION-TESTED 2026-09-05: replacing the traversal in walkAll with
42// "use the search results as the page list" finds 1 page instead of 3
43// and fails here. Reverted.
44func TestSearchIsNotUsedAsAnEnumerator(t *testing.T) {
45 f := nestedFake(t)
46 c := client(t, f)
47
48 refs, _, err := walkAll(t, c)
49 if err != nil {
50 t.Fatal(err)
51 }
52 if len(refs) != 3 {
53 var names []string
54 for _, r := range refs {
55 names = append(names, r.Title)
56 }
57 t.Fatalf("found %d pages %v, want 3 — search returned only the root, so anything "+
58 "missing here was never traversed to", len(refs), names)
59 }
60 byID := map[string]Ref{}
61 for _, r := range refs {
62 byID[r.ID] = r
63 }
64 if got := strings.Join(byID["grandkid"].Path, " › "); got != "Handbook › Onboarding" {
65 t.Errorf("grandchild ancestry is %q, want %q", got, "Handbook › Onboarding")
66 }
67 if got := strings.Join(byID["root"].Path, " › "); got != "" {
68 t.Errorf("root ancestry is %q, want empty", got)
69 }
70}
71
72// TestTraversalIncompleteFailsTheRun. Every enumeration yields everything
73// or it fails. A partial return that looks whole is how an import reports
74// success while missing pages — this codebase's worst failure shape.
75//
76// MUTATION-TESTED 2026-09-05: returning the accumulated results with a
77// nil error on retry exhaustion, or treating has_more-without-cursor as
78// the end of the list, makes each subtest pass silently. Reverted.
79func TestTraversalIncompleteFailsTheRun(t *testing.T) {
80 t.Run("has_more with no cursor", func(t *testing.T) {
81 f := nestedFake(t)
82 f.hasMoreNoCursor["blocks:root"] = true
83 _, _, err := walkAll(t, client(t, f))
84 if err == nil {
85 t.Fatal("an unfinishable enumeration was reported as complete")
86 }
87 if CodeOf(err) != CodeTraversalFailed {
88 t.Errorf("code %q, want %q", CodeOf(err), CodeTraversalFailed)
89 }
90 })
91
92 t.Run("repeating cursor", func(t *testing.T) {
93 f := nestedFake(t)
94 f.loopCursor["blocks:root"] = true
95 _, _, err := walkAll(t, client(t, f))
96 if err == nil {
97 t.Fatal("a looping cursor did not terminate as an error")
98 }
99 })
100
101 t.Run("rate limited past the budget", func(t *testing.T) {
102 f := nestedFake(t)
103 f.alwaysRateLimit["blocks:kid"] = true
104 _, _, err := walkAll(t, client(t, f))
105 if err == nil {
106 t.Fatal("exhausting the retry budget returned no error")
107 }
108 if CodeOf(err) != CodeRateLimited {
109 t.Errorf("code %q, want %q", CodeOf(err), CodeRateLimited)
110 }
111 })
112
113 // 529 is Notion's "service_overload". It is not a standard status, is
114 // easy to miss, and is documented to be retried exactly like a 429.
115 t.Run("529 service_overload past the budget", func(t *testing.T) {
116 f := nestedFake(t)
117 f.overload["blocks:kid"] = true
118 _, _, err := walkAll(t, client(t, f))
119 if err == nil {
120 t.Fatal("a 529 past the budget returned no error")
121 }
122 if CodeOf(err) != CodeRateLimited {
123 t.Errorf("code %q, want %q — 529 is retried and reported like a 429",
124 CodeOf(err), CodeRateLimited)
125 }
126 })
127}
128
129// TestClientDialsOnlyNotion is the runtime half of the network gate. It
130// is what pays for internal/memoria/notion being exempt from the source
131// grep that forbids outbound calls everywhere else.
132//
133// The token rides in an Authorization header. Go drops that header across
134// a cross-host redirect, which is not enough on its own: a SAME-host
135// redirect keeps it, and a tampered base URL is never cross-host to begin
136// with. So the pin is checked on every request and redirects are refused
137// outright.
138//
139// MUTATION-TESTED 2026-09-05: returning p.next.RoundTrip unconditionally
140// from pinnedTransport, or returning nil from CheckRedirect, makes the
141// corresponding subtest fail. Reverted.
142func TestClientDialsOnlyNotion(t *testing.T) {
143 // The target here is "localhost" against a server pinned to
144 // "127.0.0.1": a DIFFERENT HOST that is otherwise entirely reachable,
145 // same scheme, same port, live listener.
146 //
147 // The first version of this subtest pointed at http://127.0.0.2:1 and
148 // was worthless. That address failed the transport's *scheme* check
149 // as well, so deleting the host pin left the test green — a
150 // neighbouring error path supplying the failure, which is the exact
151 // pattern AGENTS.md opens with. Found by mutation-testing this gate.
152 t.Run("another host is refused", func(t *testing.T) {
153 f := newFake(t)
154 srv := f.start()
155 c, err := NewForTest("ntn_test", srv.URL)
156 if err != nil {
157 t.Fatal(err)
158 }
159 // Point the client at the same listener by another name, as a
160 // tampered credential row or a mis-set constant would.
161 c.base = strings.Replace(srv.URL, "127.0.0.1", "localhost", 1)
162 if c.base == srv.URL {
163 t.Fatal("could not build a different-host URL; this subtest proves nothing")
164 }
165 if _, _, err := c.Roots(t.Context()); err == nil {
166 t.Fatal("the client called a host it was not pinned to")
167 } else if CodeOf(err) != CodeForbiddenHost {
168 t.Errorf("code %q, want %q", CodeOf(err), CodeForbiddenHost)
169 }
170 })
171
172 t.Run("another scheme is refused", func(t *testing.T) {
173 f := newFake(t)
174 srv := f.start()
175 c, err := NewForTest("ntn_test", srv.URL)
176 if err != nil {
177 t.Fatal(err)
178 }
179 c.base = strings.Replace(srv.URL, "http://", "https://", 1)
180 if _, _, err := c.Roots(t.Context()); err == nil {
181 t.Fatal("the client accepted a scheme it was not pinned to")
182 } else if CodeOf(err) != CodeForbiddenHost {
183 t.Errorf("code %q, want %q", CodeOf(err), CodeForbiddenHost)
184 }
185 })
186
187 t.Run("a same-host redirect is refused", func(t *testing.T) {
188 var landed bool
189 mux := http.NewServeMux()
190 mux.HandleFunc("/v1/search", func(w http.ResponseWriter, r *http.Request) {
191 http.Redirect(w, r, "/collect", http.StatusFound)
192 })
193 mux.HandleFunc("/collect", func(w http.ResponseWriter, r *http.Request) {
194 // If we get here holding the bearer token, the redirect was
195 // followed and the token went with it.
196 if r.Header.Get("Authorization") != "" {
197 landed = true
198 }
199 writeJSON(w, map[string]any{"object": "list", "results": []any{}, "has_more": false})
200 })
201 srv := httptest.NewServer(mux)
202 defer srv.Close()
203
204 c, err := NewForTest("ntn_test", srv.URL)
205 if err != nil {
206 t.Fatal(err)
207 }
208 if _, _, err := c.Roots(t.Context()); err == nil {
209 t.Error("a redirect was followed; the bearer token travels with it")
210 }
211 if landed {
212 t.Error("the token reached the redirect target")
213 }
214 })
215}
216
217// TestNotionVersionIsPinned. The object model changed incompatibly at
218// 2025-09-03 and again at 2026-03-11; an unpinned client is one release
219// from extracting nothing and calling it success. The fake refuses any
220// request that does not carry the pin.
221//
222// MUTATION-TESTED 2026-09-05: dropping the Notion-Version header makes
223// this fail. Reverted.
224func TestNotionVersionIsPinned(t *testing.T) {
225 f := newFake(t)
226 c := client(t, f)
227 var out map[string]any
228 if err := c.get(t.Context(), "/v1/users/me", &out); err != nil {
229 t.Fatalf("the pinned version was rejected by the API: %v", err)
230 }
231 if APIVersion == "" {
232 t.Fatal("no API version is pinned")
233 }
234}
235
236// TestUnknownBlocksAreCountedNotSwallowed. Notion adds block types.
237// Failing a run on an unrecognised one breaks memoria on Notion's release
238// schedule; rendering nothing and saying nothing makes a page look thin
239// for no stated reason.
240//
241// MUTATION-TESTED 2026-09-05: deleting the `*unread++` in render's
242// default branch makes this fail. Reverted.
243func TestUnknownBlocksAreCountedNotSwallowed(t *testing.T) {
244 f := newFake(t)
245 f.page("root", "Notes", "",
246 para("A readable paragraph."),
247 unknownBlock("some_future_block"),
248 unknownBlock("another_new_thing"))
249 c := client(t, f)
250
251 _, got, err := walkAll(t, c)
252 if err != nil {
253 t.Fatal(err)
254 }
255 content := got["root"]
256 if content.UnreadBlocks != 2 {
257 t.Errorf("UnreadBlocks = %d, want 2", content.UnreadBlocks)
258 }
259 if !strings.Contains(content.Markdown, "A readable paragraph.") {
260 t.Error("the readable block was lost alongside the unreadable ones")
261 }
262}
263
264// TestChildPagesAreNotInlined. A child page is its own document. Inlining
265// it would duplicate every one of its passages into every ancestor, so a
266// search would return the same text several times under different titles.
267func TestChildPagesAreNotInlined(t *testing.T) {
268 f := nestedFake(t)
269 _, got, err := walkAll(t, client(t, f))
270 if err != nil {
271 t.Fatal(err)
272 }
273 if strings.Contains(got["root"].Markdown, "Week one covers expenses") {
274 t.Error("the child page's body was inlined into its parent")
275 }
276 if !strings.Contains(got["kid"].Markdown, "Week one covers expenses") {
277 t.Error("the child page's own body is missing")
278 }
279}
280
281// TestMarkdownShape covers the block renderings a Notion page actually
282// leans on, because the chunker downstream keys on Markdown headings.
283func TestMarkdownShape(t *testing.T) {
284 f := newFake(t)
285 f.page("root", "Doc", "",
286 heading(1, "Revenue"),
287 para("June was strong."),
288 map[string]any{"object": "block", "id": "t1", "type": "to_do", "has_children": false,
289 "to_do": map[string]any{"checked": true, "rich_text": []map[string]any{{"plain_text": "Ship it"}}}},
290 map[string]any{"object": "block", "id": "c1", "type": "code", "has_children": false,
291 "code": map[string]any{"language": "go", "rich_text": []map[string]any{{"plain_text": "x := 1"}}}},
292 map[string]any{"object": "block", "id": "q1", "type": "quote", "has_children": false,
293 "quote": map[string]any{"rich_text": []map[string]any{{"plain_text": "As agreed."}}}},
294 )
295 _, got, err := walkAll(t, client(t, f))
296 if err != nil {
297 t.Fatal(err)
298 }
299 md := got["root"].Markdown
300 for _, want := range []string{"# Revenue", "June was strong.", "- [x] Ship it", "```go", "x := 1", "> As agreed."} {
301 if !strings.Contains(md, want) {
302 t.Errorf("markdown missing %q\n---\n%s", want, md)
303 }
304 }
305}
306
307// TestDatabaseRowsBecomePagesUnderTheDatabase.
308func TestDatabaseRowsBecomePagesUnderTheDatabase(t *testing.T) {
309 f := newFake(t)
310 f.page("root", "Workspace", "")
311 f.pages["root"].childDBs = []string{"db1"}
312 f.dbs["db1"] = []string{"ds1"}
313 f.dbParent["db1"] = "root"
314 f.page("row1", "Project Bluebird", "").extra = map[string]any{
315 "Status": map[string]any{"type": "status", "status": map[string]any{"name": "Shipped"}},
316 "Owner": map[string]any{"type": "rich_text", "rich_text": []map[string]any{{"plain_text": "Ana"}}},
317 }
318 f.page("row2", "Project Kestrel", "")
319 f.roots = []string{"root"} // rows are reached through the database only
320 f.rows["ds1"] = []string{"row1", "row2"}
321
322 refs, content, err := walkAll(t, client(t, f))
323 if err != nil {
324 t.Fatal(err)
325 }
326 found := map[string][]string{}
327 for _, r := range refs {
328 found[r.Title] = r.Path
329 }
330 for _, title := range []string{"Project Bluebird", "Project Kestrel"} {
331 path, ok := found[title]
332 if !ok {
333 t.Fatalf("database row %q was not reached", title)
334 }
335 if got := strings.Join(path, " › "); got != "Workspace › Projects" {
336 t.Errorf("row %q ancestry is %q, want %q", title, got, "Workspace › Projects")
337 }
338 }
339
340 // The row's COLUMNS are its content. A row's body is usually empty,
341 // so importing rows without their properties produced blank
342 // documents — the design warned about it and the first
343 // implementation shipped it anyway, with the renderer written and
344 // never called.
345 //
346 // MUTATION-TESTED 2026-09-05: dropping the Properties() prepend in
347 // Fetch makes this fail. Reverted.
348 md := content["row1"].Markdown
349 for _, want := range []string{"Status: Shipped", "Owner: Ana"} {
350 if !strings.Contains(md, want) {
351 t.Errorf("row markdown missing %q\n---\n%s", want, md)
352 }
353 }
354}
355
356// TestTrashedPagesAreSkipped. `in_trash` replaced `archived` at API
357// version 2026-03-11.
358func TestTrashedPagesAreSkipped(t *testing.T) {
359 f := nestedFake(t)
360 f.pages["kid"].inTrash = true
361 f.roots = []string{"root", "kid"}
362 refs, _, err := walkAll(t, client(t, f))
363 if err != nil {
364 t.Fatal(err)
365 }
366 for _, r := range refs {
367 if r.ID == "kid" && len(r.Path) == 0 {
368 t.Error("a trashed page was admitted as a root")
369 }
370 }
371}
372
373// TestSearchLeakingADescendantDoesNotStripItsAncestry is the gate for the
374// blocker a code review found — and the fake had a switch built for this
375// case with no test using it.
376//
377// Notion's search returns "all parent or child pages ... shared with a
378// connection", not only the ones shared directly. And a page nested
379// inside a toggle has a `block_id` parent, which search never returns. So
380// a descendant routinely comes back from search with a parent that is not
381// in the result set, and looks exactly like a root.
382//
383// Treated as a root it is ingested with EMPTY ancestry and marked
384// visited, so the correct "Handbook › Onboarding" Ref is discarded when
385// the traversal reaches it. Wrong ancestry is then sealed, indexed and
386// shown, and it flaps on the next run when search changes its mind.
387//
388// MUTATION-TESTED 2026-09-05: classifying every non-present-parent result
389// as a root (the pre-fix behaviour) makes this fail with an empty path.
390// Reverted.
391func TestSearchLeakingADescendantDoesNotStripItsAncestry(t *testing.T) {
392 f := nestedFake(t)
393 f.searchLeaks = []string{"grandkid"}
394 c := client(t, f)
395
396 roots, orphans, err := c.Roots(t.Context())
397 if err != nil {
398 t.Fatal(err)
399 }
400 for _, r := range roots {
401 if r.ID == "grandkid" {
402 t.Error("a nested page came back as a true root; it would be ingested with no ancestry")
403 }
404 }
405 if len(orphans) != 1 || orphans[0].ID != "grandkid" {
406 t.Fatalf("expected the leaked descendant classified as an orphan, got %d", len(orphans))
407 }
408
409 refs, _, err := walkAll(t, c)
410 if err != nil {
411 t.Fatal(err)
412 }
413 var got string
414 var seen int
415 for _, r := range refs {
416 if r.ID == "grandkid" {
417 seen++
418 got = strings.Join(r.Path, " › ")
419 }
420 }
421 if seen != 1 {
422 t.Errorf("the grandchild was walked %d times, want once", seen)
423 }
424 if got != "Handbook › Onboarding" {
425 t.Errorf("grandchild ancestry is %q, want %q — the orphan claimed it first",
426 got, "Handbook › Onboarding")
427 }
428}
429
430// TestErrorsCarryNoResponseBody. Notion's error bodies contain content: a
431// real 404 reads "Could not find block with ID: <uuid>. Make sure the
432// relevant pages and databases are shared with your connection <name>."
433// A %w of that into a log line puts a coordinate and a workspace name
434// into storage nobody can grep.
435//
436// notion.Error therefore has no message field at all — the shape is the
437// guarantee, rather than the discipline of whoever formats it.
438//
439// MUTATION-TESTED 2026-09-05: adding a Body field to Error and filling it
440// from the response makes this fail. Reverted.
441func TestErrorsCarryNoResponseBody(t *testing.T) {
442 f := nestedFake(t)
443 f.notFound["kid"] = true
444 c := client(t, f)
445
446 _, _, err := walkAll(t, c)
447 if err == nil {
448 t.Fatal("expected the 404 to surface as an error")
449 }
450 for _, canary := range []string{"CANARYCONNECTION", "Could not find block"} {
451 if strings.Contains(err.Error(), canary) {
452 t.Errorf("the error string carries %q from the response body:\n%s", canary, err.Error())
453 }
454 }
455}
456