| 1 | package notion |
| 2 | |
| 3 | import ( |
| 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. |
| 19 | type 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 | |
| 42 | type 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 | |
| 58 | func 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 | |
| 68 | func (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 | |
| 79 | func 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 | |
| 87 | func 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 | |
| 96 | func unknownBlock(kind string) map[string]any { |
| 97 | return map[string]any{"object": "block", "id": "u-" + kind, "type": kind, "has_children": false} |
| 98 | } |
| 99 | |
| 100 | func min(a, b int) int { |
| 101 | if a < b { |
| 102 | return a |
| 103 | } |
| 104 | return b |
| 105 | } |
| 106 | |
| 107 | func (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 | |
| 117 | func 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 | |
| 127 | func 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. |
| 135 | func (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 | |
| 166 | func writeJSON(w http.ResponseWriter, v any) { |
| 167 | w.Header().Set("Content-Type", "application/json") |
| 168 | _ = json.NewEncoder(w).Encode(v) |
| 169 | } |
| 170 | |
| 171 | func (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 | |
| 284 | func (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. |
| 302 | func 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 | |