package http import ( "testing" ) // Connected pages (Notion-like upgrades phase 3): a real hierarchy with cycle // rejection, Markdown-derived internal links/backlinks that never cross // accounts, and live collection embeds that stay owner-scoped. func pageVersion(t *testing.T, c apiClient, id string) int { t.Helper() v := c.request("GET", "/api/pages/"+id, nil, 200) version, _ := v["version"].(float64) return int(version) } func TestPageHierarchy(t *testing.T) { srv := testServer(t) alice := newAPIUser(t, srv, "pages-tree@computer.test") bob := newAPIUser(t, srv, "pages-tree-other@computer.test") root := alice.request("POST", "/api/pages", map[string]any{"title": "Root"}, 201)["id"].(string) child := alice.request("POST", "/api/pages", map[string]any{"title": "Child", "parentId": root}, 201)["id"].(string) grand := alice.request("POST", "/api/pages", map[string]any{"title": "Grandchild", "parentId": child}, 201)["id"].(string) listed := map[string]string{} for _, p := range alice.getJSON("/api/pages", 200) { parent, _ := p["parentId"].(string) listed[p["id"].(string)] = parent } if listed[root] != "" || listed[child] != root || listed[grand] != child { t.Fatalf("parentId not preserved in the list: %v", listed) } // A page cannot parent itself, nor may a move create a cycle. alice.request("PATCH", "/api/pages/"+child, map[string]any{"parentId": child}, 400) alice.request("PATCH", "/api/pages/"+root, map[string]any{"parentId": grand}, 400) alice.request("PATCH", "/api/pages/"+child, map[string]any{"parentId": grand}, 400) // Foreign pages are not valid parents (and are not disclosed as existing). foreign := bob.request("POST", "/api/pages", map[string]any{"title": "Bob only"}, 201)["id"].(string) alice.request("PATCH", "/api/pages/"+root, map[string]any{"parentId": foreign}, 400) alice.request("POST", "/api/pages", map[string]any{"title": "Hijack", "parentId": foreign}, 400) // Moving a page between parents works and is metadata-only (no version // requirement, matching title/pin changes). moved := alice.request("PATCH", "/api/pages/"+grand, map[string]any{"parentId": root}, 200) if moved["parentId"] != root { t.Fatalf("parent move not applied: %v", moved["parentId"]) } // Explicit null promotes to the top level. promoted := alice.request("PATCH", "/api/pages/"+grand, map[string]any{"parentId": nil}, 200) if promoted["parentId"] != nil { t.Fatalf("null parent did not promote page: %v", promoted["parentId"]) } alice.request("PATCH", "/api/pages/"+grand, map[string]any{"parentId": child}, 200) // Deleting a parent promotes its children instead of deleting them. alice.request("DELETE", "/api/pages/"+child, nil, 200) alice.request("GET", "/api/pages/"+child, nil, 404) alice.request("GET", "/api/pages/"+grand, nil, 200) if got := alice.request("GET", "/api/pages/"+grand, nil, 200)["parentId"]; got != nil { t.Fatalf("orphaned child was not promoted after parent delete: %v", got) } // A foreign user can never see or mutate the tree. bob.request("GET", "/api/pages/"+root, nil, 404) bob.request("PATCH", "/api/pages/"+root, map[string]any{"parentId": foreign}, 404) } func TestPageBacklinksAndEmbeds(t *testing.T) { srv := testServer(t) alice := newAPIUser(t, srv, "pages-graph@computer.test") bob := newAPIUser(t, srv, "pages-graph-other@computer.test") target := alice.request("POST", "/api/pages", map[string]any{"title": "Target page"}, 201)["id"].(string) bobTarget := bob.request("POST", "/api/pages", map[string]any{"title": "Private title"}, 201)["id"].(string) mine := alice.request("POST", "/api/collections", map[string]any{"name": "Reading list"}, 201) collection := mine["id"].(string) view := mine["views"].([]any)[0].(map[string]any)["id"].(string) foreignCollection := bob.request("POST", "/api/collections", map[string]any{"name": "Bob private"}, 201)["id"].(string) content := "# Connected\n\n" + "- [Target](computer:page/" + target + ")\n" + "- [Alias](/pages?id=" + target + ")\n" + "- [Bob's page](computer:page/" + bobTarget + ")\n\n" + "{{collection:" + collection + "|view=" + view + "}}\n" + "{{collection:" + foreignCollection + "}}\n" source := alice.request("POST", "/api/pages", map[string]any{"title": "Source page", "content": content}, 201)["id"].(string) // Duplicate aliases resolve to one edge; the foreign page is never stored. tgt := alice.request("GET", "/api/pages/"+target, nil, 200) backlinks := tgt["backlinks"].([]any) if len(backlinks) != 1 || backlinks[0].(map[string]any)["id"] != source || backlinks[0].(map[string]any)["title"] != "Source page" { t.Fatalf("backlinks did not resolve owner-scoped pages: %v", backlinks) } listed := alice.getJSON("/api/pages/"+target+"/backlinks", 200) if len(listed) != 1 || listed[0]["id"] != source { t.Fatalf("backlinks endpoint: %v", listed) } bob.request("GET", "/api/pages/"+target+"/backlinks", nil, 404) src := alice.request("GET", "/api/pages/"+source, nil, 200) links := src["links"].([]any) if len(links) != 1 || links[0].(map[string]any)["id"] != target { t.Fatalf("outgoing links should contain only the owned target: %v", links) } embeds := src["embeds"].([]any) if len(embeds) != 1 { t.Fatalf("foreign collection embed was stored: %v", embeds) } embed := embeds[0].(map[string]any) if embed["collectionId"] != collection || embed["viewId"] != view { t.Fatalf("embed reference/view not preserved: %v", embed) } col := embed["collection"].(map[string]any) if col["name"] != "Reading list" { t.Fatalf("live embed is missing its collection projection: %v", col) } if props, ok := col["properties"].([]any); !ok || len(props) != 0 { t.Fatalf("embed projection missing properties array: %v", col["properties"]) } // Removing the Markdown and re-saving removes both derived rows, proving // they are not append-only caches. alice.request("PATCH", "/api/pages/"+source, map[string]any{ "content": "# Disconnected", "version": pageVersion(t, alice, source), }, 200) if b := alice.request("GET", "/api/pages/"+target, nil, 200)["backlinks"].([]any); len(b) != 0 { t.Fatalf("stale backlink survived a content save: %v", b) } src = alice.request("GET", "/api/pages/"+source, nil, 200) if e := src["embeds"].([]any); len(e) != 0 { t.Fatalf("stale embed survived a content save: %v", e) } if l := src["links"].([]any); len(l) != 0 { t.Fatalf("stale outgoing link survived a content save: %v", l) } // Re-linking re-establishes the edge; deleting the target removes it. alice.request("PATCH", "/api/pages/"+source, map[string]any{ "content": "[Target](computer:page/" + target + ")", "version": pageVersion(t, alice, source), }, 200) if b := alice.request("GET", "/api/pages/"+target, nil, 200)["backlinks"].([]any); len(b) != 1 { t.Fatalf("re-link was not resolved: %v", b) } alice.request("DELETE", "/api/pages/"+target, nil, 200) if l := alice.request("GET", "/api/pages/"+source, nil, 200)["links"].([]any); len(l) != 0 { t.Fatalf("deleted target left a dangling link: %v", l) } // Self-links are ignored (a page is not its own backlink). selfContent := "[Me](computer:page/" + source + ")" alice.request("PATCH", "/api/pages/"+source, map[string]any{ "content": selfContent, "version": pageVersion(t, alice, source), }, 200) if b := alice.request("GET", "/api/pages/"+source, nil, 200)["backlinks"].([]any); len(b) != 0 { t.Fatalf("self-link became a backlink: %v", b) } }