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 "encoding/json"
5 "net/http"
6 "net/http/httptest"
7 "strings"
8 "testing"
9)
10
11// fakeNotion is a Notion workspace built from the published API
12// reference, with switches for each failure the client must survive.
13//
14// Its /v1/search returns ONLY the roots, never the pages nested beneath
15// them. That is not a simplification — it is the behaviour Notion
16// documents ("search is not guaranteed to return everything", "not
17// optimized for exhaustively enumerating"), and modelling it is what
18// makes TestSearchIsNotUsedAsAnEnumerator able to fail.
19type fakeNotion struct {
20 t *testing.T
21 pages map[string]*fakePage
22 roots []string
23 dbs map[string][]string // database id -> data source ids
24 rows map[string][]string // data source id -> row page ids
25 // dbParent is the page a database lives under. Notion sets a child
26 // database's parent to its containing page, and getting this wrong
27 // in the fake made a nested database look like a root — a real
28 // distinction, since Roots drops any result whose parent is also in
29 // the result set, and the root version carries no ancestry.
30 dbParent map[string]string
31
32 // Failure switches.
33 hasMoreNoCursor map[string]bool // path -> emit has_more with no cursor
34 loopCursor map[string]bool // path -> emit the same cursor forever
35 alwaysRateLimit map[string]bool // path -> 429 with Retry-After: 0
36 notFound map[string]bool // page id -> 404
37 overload map[string]bool // path -> 529
38 calls map[string]int
39 searchLeaks []string // extra ids search returns beyond the roots
40}
41
42type fakePage struct {
43 id string
44 title string
45 edited string
46 parent string
47 inTrash bool
48 blocks []map[string]any
49 children []string // child_page ids
50 childDBs []string // child_database ids
51 // extra are properties beyond the title, as a database row carries.
52 // A row's body is usually empty and these are the whole of what it
53 // says, which is why importing rows without them produced blank
54 // documents.
55 extra map[string]any
56}
57
58func newFake(t *testing.T) *fakeNotion {
59 return &fakeNotion{
60 t: t, pages: map[string]*fakePage{},
61 dbs: map[string][]string{}, rows: map[string][]string{}, dbParent: map[string]string{},
62 hasMoreNoCursor: map[string]bool{}, loopCursor: map[string]bool{},
63 alwaysRateLimit: map[string]bool{}, notFound: map[string]bool{},
64 overload: map[string]bool{}, calls: map[string]int{},
65 }
66}
67
68func (f *fakeNotion) page(id, title, parent string, blocks ...map[string]any) *fakePage {
69 p := &fakePage{id: id, title: title, parent: parent, edited: "2026-09-01T10:00:00.000Z", blocks: blocks}
70 f.pages[id] = p
71 if parent == "" {
72 f.roots = append(f.roots, id)
73 } else if pp, ok := f.pages[parent]; ok {
74 pp.children = append(pp.children, id)
75 }
76 return p
77}
78
79func para(text string) map[string]any {
80 return map[string]any{
81 "object": "block", "id": "b-" + text[:min(6, len(text))], "type": "paragraph",
82 "has_children": false,
83 "paragraph": map[string]any{"rich_text": []map[string]any{{"plain_text": text}}},
84 }
85}
86
87func heading(level int, text string) map[string]any {
88 k := "heading_" + string(rune('0'+level))
89 return map[string]any{
90 "object": "block", "id": "h-" + text[:min(6, len(text))], "type": k,
91 "has_children": false,
92 k: map[string]any{"rich_text": []map[string]any{{"plain_text": text}}},
93 }
94}
95
96func unknownBlock(kind string) map[string]any {
97 return map[string]any{"object": "block", "id": "u-" + kind, "type": kind, "has_children": false}
98}
99
100func min(a, b int) int {
101 if a < b {
102 return a
103 }
104 return b
105}
106
107func (f *fakeNotion) object(id string) map[string]any {
108 p := f.pages[id]
109 return map[string]any{
110 "object": "page", "id": p.id, "in_trash": p.inTrash,
111 "last_edited_time": p.edited,
112 "parent": parentOf(p.parent),
113 "properties": properties(p),
114 }
115}
116
117func properties(p *fakePage) map[string]any {
118 out := map[string]any{
119 "Name": map[string]any{"type": "title", "title": []map[string]any{{"plain_text": p.title}}},
120 }
121 for k, v := range p.extra {
122 out[k] = v
123 }
124 return out
125}
126
127func parentOf(id string) map[string]any {
128 if id == "" {
129 return map[string]any{"type": "workspace", "workspace": true}
130 }
131 return map[string]any{"type": "page_id", "page_id": id}
132}
133
134// paged writes a cursor-paginated list response, honouring the switches.
135func (f *fakeNotion) paged(w http.ResponseWriter, path string, items []map[string]any, cursor string) {
136 if f.hasMoreNoCursor[path] {
137 writeJSON(w, map[string]any{"object": "list", "results": items, "has_more": true, "next_cursor": nil})
138 return
139 }
140 if f.loopCursor[path] {
141 writeJSON(w, map[string]any{"object": "list", "results": items, "has_more": true, "next_cursor": "stuck"})
142 return
143 }
144 // Real pagination: one item per page, so the cursor path is actually
145 // exercised rather than assumed.
146 if cursor == "" && len(items) > 1 {
147 writeJSON(w, map[string]any{"object": "list", "results": items[:1], "has_more": true, "next_cursor": "c1"})
148 return
149 }
150 if strings.HasPrefix(cursor, "c") {
151 n := int(cursor[1] - '0')
152 if n < len(items) {
153 rest := items[n:]
154 if len(rest) > 1 {
155 writeJSON(w, map[string]any{"object": "list", "results": rest[:1], "has_more": true,
156 "next_cursor": "c" + string(rune('0'+n+1))})
157 return
158 }
159 writeJSON(w, map[string]any{"object": "list", "results": rest, "has_more": false, "next_cursor": nil})
160 return
161 }
162 }
163 writeJSON(w, map[string]any{"object": "list", "results": items, "has_more": false, "next_cursor": nil})
164}
165
166func writeJSON(w http.ResponseWriter, v any) {
167 w.Header().Set("Content-Type", "application/json")
168 _ = json.NewEncoder(w).Encode(v)
169}
170
171func (f *fakeNotion) start() *httptest.Server {
172 mux := http.NewServeMux()
173
174 mux.HandleFunc("/v1/search", func(w http.ResponseWriter, r *http.Request) {
175 f.calls["search"]++
176 if f.reject(w, "search") {
177 return
178 }
179 var body map[string]any
180 _ = json.NewDecoder(r.Body).Decode(&body)
181 cursor, _ := body["start_cursor"].(string)
182
183 var items []map[string]any
184 // ONLY the roots. Children exist and search does not return them.
185 for _, id := range f.roots {
186 items = append(items, f.object(id))
187 }
188 for _, id := range f.searchLeaks {
189 items = append(items, f.object(id))
190 }
191 for db := range f.dbs {
192 items = append(items, map[string]any{
193 "object": "data_source", "id": db, "last_edited_time": "2026-09-01T10:00:00.000Z",
194 "name": "Projects", "parent": parentOf(f.dbParent[db]),
195 })
196 }
197 f.paged(w, "search", items, cursor)
198 })
199
200 mux.HandleFunc("/v1/blocks/", func(w http.ResponseWriter, r *http.Request) {
201 id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/v1/blocks/"), "/children")
202 f.calls["blocks:"+id]++
203 if f.reject(w, "blocks:"+id) {
204 return
205 }
206 if f.notFound[id] {
207 w.WriteHeader(http.StatusNotFound)
208 // Notion's real 404 body quotes the id and the connection
209 // name. Kept verbatim in shape so the log gate sees a body
210 // that actually carries a coordinate.
211 writeJSON(w, map[string]any{"object": "error", "status": 404, "code": "object_not_found",
212 "message": "Could not find block with ID: " + id + ". Make sure the relevant pages and " +
213 "databases are shared with your connection \"CANARYCONNECTION\"."})
214 return
215 }
216 p, ok := f.pages[id]
217 if !ok {
218 w.WriteHeader(http.StatusNotFound)
219 writeJSON(w, map[string]any{"object": "error", "status": 404})
220 return
221 }
222 items := append([]map[string]any{}, p.blocks...)
223 for _, c := range p.children {
224 items = append(items, map[string]any{
225 "object": "block", "id": c, "type": "child_page", "has_children": true,
226 "child_page": map[string]any{"title": f.pages[c].title},
227 })
228 }
229 for _, d := range p.childDBs {
230 items = append(items, map[string]any{
231 "object": "block", "id": d, "type": "child_database", "has_children": true,
232 "child_database": map[string]any{"title": "Projects"},
233 })
234 }
235 f.paged(w, "blocks:"+id, items, r.URL.Query().Get("start_cursor"))
236 })
237
238 mux.HandleFunc("/v1/databases/", func(w http.ResponseWriter, r *http.Request) {
239 id := strings.TrimPrefix(r.URL.Path, "/v1/databases/")
240 f.calls["db:"+id]++
241 sources, ok := f.dbs[id]
242 if !ok {
243 w.WriteHeader(http.StatusNotFound)
244 writeJSON(w, map[string]any{"object": "error", "status": 404})
245 return
246 }
247 var ds []map[string]any
248 for _, s := range sources {
249 ds = append(ds, map[string]any{"id": s, "name": "Projects"})
250 }
251 writeJSON(w, map[string]any{"object": "database", "id": id, "data_sources": ds})
252 })
253
254 mux.HandleFunc("/v1/data_sources/", func(w http.ResponseWriter, r *http.Request) {
255 id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/v1/data_sources/"), "/query")
256 f.calls["ds:"+id]++
257 if f.reject(w, "ds:"+id) {
258 return
259 }
260 var items []map[string]any
261 for _, rid := range f.rows[id] {
262 items = append(items, f.object(rid))
263 }
264 var body map[string]any
265 _ = json.NewDecoder(r.Body).Decode(&body)
266 cursor, _ := body["start_cursor"].(string)
267 f.paged(w, "ds:"+id, items, cursor)
268 })
269
270 mux.HandleFunc("/v1/users/me", func(w http.ResponseWriter, r *http.Request) {
271 if r.Header.Get("Notion-Version") != APIVersion {
272 w.WriteHeader(http.StatusBadRequest)
273 writeJSON(w, map[string]any{"object": "error", "code": "missing_version"})
274 return
275 }
276 writeJSON(w, map[string]any{"object": "user", "id": "bot", "name": "memoria"})
277 })
278
279 srv := httptest.NewServer(mux)
280 f.t.Cleanup(srv.Close)
281 return srv
282}
283
284func (f *fakeNotion) reject(w http.ResponseWriter, key string) bool {
285 if f.alwaysRateLimit[key] {
286 w.Header().Set("Retry-After", "0")
287 w.WriteHeader(http.StatusTooManyRequests)
288 writeJSON(w, map[string]any{"object": "error", "code": "rate_limited"})
289 return true
290 }
291 if f.overload[key] {
292 w.Header().Set("Retry-After", "0")
293 w.WriteHeader(529)
294 writeJSON(w, map[string]any{"object": "error", "code": "service_overload"})
295 return true
296 }
297 return false
298}
299
300// walkAll drives a full traversal the way the connector will, returning
301// every page reached, in order.
302func walkAll(t *testing.T, c *Client) ([]Ref, map[string]*Content, error) {
303 t.Helper()
304 roots, orphans, err := c.Roots(t.Context())
305 if err != nil {
306 return nil, nil, err
307 }
308 var out []Ref
309 got := map[string]*Content{}
310 seen := map[string]bool{}
311 // Two passes, exactly as the syncer does it: the real tree first, so
312 // every page that has an ancestor is reached through it, and only
313 // then the orphans, skipping anything already visited. Draining them
314 // into one queue would let an orphan claim a page before the
315 // traversal could give it real ancestry.
316 queue := append([]Ref{}, roots...)
317 for _, phase := range [][]Ref{queue, orphans} {
318 q := append([]Ref{}, phase...)
319 for len(q) > 0 {
320 ref := q[0]
321 q = q[1:]
322 if seen[ref.ID] {
323 continue
324 }
325 seen[ref.ID] = true
326 content, err := c.Fetch(t.Context(), ref)
327 if err != nil {
328 return out, got, err
329 }
330 out = append(out, ref)
331 got[ref.ID] = content
332 q = append(q, content.Children...)
333 }
334 }
335 return out, got, nil
336}
337