release gate / check (push) Failing after 7m7s
Implement Notion-like upgrade phase 3: owner-scoped nested pages with cycle rejection and child promotion on delete; Markdown-derived internal links and backlinks; live collection embeds resolved in the content-save transaction. Add migration 008, API/docs, integration coverage, and page/sidebar UI.
162 lines
7.3 KiB
Go
162 lines
7.3 KiB
Go
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)
|
|
}
|
|
}
|