initial commit on Federted servers
release gate / check (push) Failing after 8m35s

This commit is contained in:
Andrew S Erwin
2026-10-19 16:23:55 -05:00
commit b9721ff13f
217 changed files with 31534 additions and 0 deletions
+420
View File
@@ -0,0 +1,420 @@
package http
import (
"testing"
)
// Collections (Notion-like upgrades phase 2). Proves the locked semantics:
// contextual membership (same bookmark, independent workflows, bookmark
// never owned by the membership), versioned PATCHes (DATA-05 lesson),
// server-side validated filters/sorts with deterministic cursor pagination,
// and access revocation reaching every item surface (N-08).
func collectionBookmark(t *testing.T, c apiClient, url string) string {
t.Helper()
return c.request("POST", "/api/bookmarks", map[string]any{"url": url, "title": "BM " + url}, 201)["id"].(string)
}
func TestCollectionsLifecycle(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "coll-life@computer.test")
bob := newAPIUser(t, srv, "coll-other@computer.test")
bm1 := collectionBookmark(t, alice, "https://127.0.0.1/one")
col := alice.request("POST", "/api/collections", map[string]any{"name": "Research", "description": "phase 2"}, 201)
cid := col["id"].(string)
views, _ := col["views"].([]any)
if len(views) != 1 {
t.Fatalf("new collection did not get a default view: %v", col)
}
// Property surface: typed, validated, RELATION reserved but rejected.
alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Rel", "type": "RELATION"}, 400)
alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Bad", "type": "PERSON"}, 400)
status := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{
"name": "Status", "type": "SELECT",
"config": map[string]any{"options": []any{
map[string]any{"label": "Backlog"}, map[string]any{"label": "Doing"}, map[string]any{"label": "Done"},
}},
}, 201)
statusID := status["id"].(string)
opts, _ := status["config"].(map[string]any)["options"].([]any)
doing := opts[1].(map[string]any)["id"].(string)
done := opts[2].(map[string]any)["id"].(string)
priority := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Priority", "type": "NUMBER"}, 201)
priorityID := priority["id"].(string)
notes := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Notes", "type": "TEXT"}, 201)
notesID := notes["id"].(string)
alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{
"name": "Due", "type": "DATE", "config": map[string]any{"mode": "bogus"},
}, 400)
due := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{
"name": "Due", "type": "DATE", "config": map[string]any{"mode": "date"},
}, 201)
dueID := due["id"].(string)
// Duplicate property name is a unique violation -> 409.
alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Priority", "type": "TEXT"}, 409)
// Items: visibility required at creation, values validated, duplicates 409.
foreign := collectionBookmark(t, bob, "https://127.0.0.1/private")
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": foreign}, 400)
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1,
"propertyValues": map[string]any{statusID: "not-an-option"}}, 400)
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1,
"propertyValues": map[string]any{notesID: 5}}, 400)
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1,
"propertyValues": map[string]any{dueID: "2026-13-45"}}, 400)
item := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1,
"propertyValues": map[string]any{statusID: doing, priorityID: 2, dueID: "2026-12-31", notesID: "context"}}, 201)
itemID := item["id"].(string)
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1}, 409)
pv, _ := item["propertyValues"].(map[string]any)
if pv[dueID] != "2026-12-31" {
t.Fatalf("date-only value not stored verbatim: %v", pv[dueID])
}
if _, ok := item["bookmark"]; !ok {
t.Fatal("item response missing the bookmark projection")
}
// Item PATCH enforces version (DATA-05 from day one) and merge semantics:
// omitted keys untouched, null removes.
alice.request("PATCH", "/api/collections/"+cid+"/items/"+itemID, map[string]any{"propertyValues": map[string]any{notesID: "x"}}, 400)
alice.request("PATCH", "/api/collections/"+cid+"/items/"+itemID, map[string]any{"version": 99, "propertyValues": map[string]any{notesID: "x"}}, 409)
updated := alice.request("PATCH", "/api/collections/"+cid+"/items/"+itemID, map[string]any{
"version": 1, "propertyValues": map[string]any{notesID: nil, statusID: done},
}, 200)
if updated["version"].(float64) != 2 {
t.Fatalf("item patch did not bump version: %v", updated["version"])
}
pv, _ = updated["propertyValues"].(map[string]any)
if _, stillThere := pv[notesID]; stillThere {
t.Fatal("null did not remove the value")
}
if pv[statusID] != done {
t.Fatal("status not updated")
}
// The same bookmark in a second collection: contextual values independent,
// intrinsic edits propagate to both (test matrix row 1).
col2 := alice.request("POST", "/api/collections", map[string]any{"name": "Reading"}, 201)
cid2 := col2["id"].(string)
status2 := alice.request("POST", "/api/collections/"+cid2+"/properties", map[string]any{
"name": "Status", "type": "SELECT",
"config": map[string]any{"options": []any{map[string]any{"label": "Unread"}, map[string]any{"label": "Read"}}},
}, 201)
unread := status2["config"].(map[string]any)["options"].([]any)[0].(map[string]any)["id"].(string)
item2 := alice.request("POST", "/api/collections/"+cid2+"/items", map[string]any{
"bookmarkId": bm1, "propertyValues": map[string]any{status2["id"].(string): unread},
}, 201)
putBookmark(alice, bm1, map[string]any{"title": "Renamed intrinsically"}, 200)
for _, pair := range [][2]string{{cid, itemID}, {cid2, item2["id"].(string)}} {
got := alice.getJSON("/api/collections/"+pair[0]+"/items", 200)
found := false
for _, it := range got {
if it["id"] == pair[1] {
found = true
bmk := it["bookmark"].(map[string]any)
if bmk["title"] != "Renamed intrinsically" {
t.Fatal("intrinsic edit did not propagate to membership", pair[0])
}
}
}
if !found {
t.Fatal("item missing from list", pair[0])
}
}
// Contextual independence: collection 1 says Done, collection 2 says Unread.
first := alice.getJSON("/api/collections/"+cid+"/items", 200)[0]
if first["propertyValues"].(map[string]any)[statusID] != done {
t.Fatal("contextual value leaked between collections")
}
// Removing membership and then the whole collection never touches the
// bookmark (N-01 acceptance).
alice.request("DELETE", "/api/collections/"+cid2+"/items/"+item2["id"].(string), nil, 200)
alice.request("GET", "/api/bookmarks/"+bm1, nil, 200)
alice.request("DELETE", "/api/collections/"+cid2, nil, 200)
alice.request("GET", "/api/bookmarks/"+bm1, nil, 200)
alice.request("GET", "/api/collections/"+cid2, nil, 404)
// Foreign users get 404 on every collection surface (ownership flow).
bob.request("GET", "/api/collections/"+cid, nil, 404)
bob.request("PATCH", "/api/collections/"+cid, map[string]any{"version": 1, "name": "hijack"}, 404)
bob.request("GET", "/api/collections/"+cid+"/items", nil, 404)
bob.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1}, 404)
bob.request("DELETE", "/api/collections/"+cid, nil, 404)
}
func TestCollectionViewsFilterSortPaginate(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "coll-views@computer.test")
col := alice.request("POST", "/api/collections", map[string]any{"name": "Pipeline"}, 201)
cid := col["id"].(string)
status := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{
"name": "Status", "type": "SELECT",
"config": map[string]any{"options": []any{map[string]any{"label": "Doing"}, map[string]any{"label": "Done"}}},
}, 201)
statusID := status["id"].(string)
doing := status["config"].(map[string]any)["options"].([]any)[0].(map[string]any)["id"].(string)
done := status["config"].(map[string]any)["options"].([]any)[1].(map[string]any)["id"].(string)
prio := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Prio", "type": "NUMBER"}, 201)
prioID := prio["id"].(string)
ids := []string{}
for i, s := range []string{doing, done, doing} {
bm := collectionBookmark(t, alice, "https://127.0.0.1/v"+string(rune('a'+i)))
p := float64(i)
if i == 2 {
p = 1 // tie with item 1 on the sort key: id must break it deterministically
}
it := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{
"bookmarkId": bm, "propertyValues": map[string]any{statusID: s, prioID: p},
}, 201)
ids = append(ids, it["id"].(string))
}
// View validation: unknown properties and type-incompatible operators 400.
alice.request("POST", "/api/collections/"+cid+"/views", map[string]any{"name": "Bad", "filters": map[string]any{
"operator": "AND", "clauses": []any{map[string]any{"propertyId": "nope", "op": "eq", "value": "x"}},
}}, 400)
alice.request("POST", "/api/collections/"+cid+"/views", map[string]any{"name": "Bad2", "filters": map[string]any{
"clauses": []any{map[string]any{"propertyId": statusID, "op": "gt", "value": doing}},
}}, 400)
alice.request("POST", "/api/collections/"+cid+"/views", map[string]any{"name": "Bad3", "sorts": []any{
map[string]any{"field": "nope"},
}}, 400)
board := alice.request("POST", "/api/collections/"+cid+"/views", map[string]any{
"name": "Doing board", "type": "BOARD",
"filters": map[string]any{"operator": "AND", "clauses": []any{map[string]any{"propertyId": statusID, "op": "eq", "value": doing}}},
"sorts": []any{map[string]any{"field": prioID, "dir": "desc"}},
"groupByPropertyId": statusID,
}, 201)
vid := board["id"].(string)
// Server-side filtering: only the two Doing items come back.
doingItems := alice.getJSON("/api/collections/"+cid+"/items?viewId="+vid, 200)
if len(doingItems) != 2 {
t.Fatalf("filter returned %d items, want 2", len(doingItems))
}
// Sorted by priority desc: first page item has prio 1 (the tie item), and
// cursor pagination with limit=1 walks both pages exactly once.
first := doingItems[0]
if first["propertyValues"].(map[string]any)[prioID].(float64) != 1 {
t.Fatal("sort desc did not put the higher priority first")
}
res, e := alice.c.Get(alice.base + "/api/collections/" + cid + "/items?viewId=" + vid + "&limit=1")
if e != nil {
t.Fatal(e)
}
cursor := res.Header.Get("X-Next-Cursor")
res.Body.Close()
if cursor == "" {
t.Fatal("no cursor despite more rows")
}
page := alice.getJSON("/api/collections/"+cid+"/items?viewId="+vid+"&limit=1&cursor="+cursor, 200)
if len(page) != 1 {
t.Fatalf("cursor page returned %d items", len(page))
}
if page[0]["id"] == first["id"] {
t.Fatal("cursor page repeated the first row (non-deterministic tie-break)")
}
// View PATCH enforces version like everything else.
alice.request("PATCH", "/api/collections/"+cid+"/views/"+vid, map[string]any{"name": "No version"}, 400)
alice.request("PATCH", "/api/collections/"+cid+"/views/"+vid, map[string]any{"version": 99, "name": "Stale"}, 409)
renamed := alice.request("PATCH", "/api/collections/"+cid+"/views/"+vid, map[string]any{"version": 1, "name": "Doing board v2"}, 200)
if renamed["version"].(float64) != 2 {
t.Fatalf("view patch did not bump version: %v", renamed["version"])
}
// Option removal with existing values is refused (stated policy); after
// clearing the values it succeeds. Renames keep the option identity.
alice.request("PATCH", "/api/collections/"+cid+"/properties/"+statusID, map[string]any{"version": 1,
"config": map[string]any{"options": []any{map[string]any{"id": doing, "label": "In flight"}}}}, 400)
// Clear the Doing values by moving every item to Done.
for _, row := range alice.getJSON("/api/collections/"+cid+"/items", 200) {
alice.request("PATCH", "/api/collections/"+cid+"/items/"+row["id"].(string), map[string]any{
"version": row["version"].(float64), "propertyValues": map[string]any{statusID: done},
}, 200)
}
renamedProp := alice.request("PATCH", "/api/collections/"+cid+"/properties/"+statusID, map[string]any{"version": 1,
"config": map[string]any{"options": []any{map[string]any{"id": done, "label": "Finished"}}}}, 200)
opts := renamedProp["config"].(map[string]any)["options"].([]any)
if len(opts) != 1 || opts[0].(map[string]any)["id"] != done {
t.Fatalf("option removal/rename produced %+v", opts)
}
// Type changes are forbidden; deleting the property scrubs values and views.
alice.request("PATCH", "/api/collections/"+cid+"/properties/"+statusID, map[string]any{"version": 2, "type": "TEXT"}, 400)
alice.request("DELETE", "/api/collections/"+cid+"/properties/"+statusID, nil, 200)
view := alice.request("GET", "/api/collections/"+cid, nil, 200)
for _, raw := range view["views"].([]any) {
v := raw.(map[string]any)
if v["id"] != vid {
continue
}
f := v["filters"].(map[string]any)
if len(f["clauses"].([]any)) != 0 {
t.Fatalf("deleted property left filter clauses behind: %v", f)
}
// The sort keys on the surviving Prio property and must be untouched.
if len(v["sorts"].([]any)) != 1 {
t.Fatalf("unrelated sorts were scrubbed: %v", v["sorts"])
}
if v["groupByPropertyId"] != nil {
t.Fatalf("groupBy was not cleared: %v", v["groupByPropertyId"])
}
}
for _, raw := range alice.getJSON("/api/collections/"+cid+"/items", 200) {
pv, _ := raw["propertyValues"].(map[string]any)
if _, still := pv[statusID]; still {
t.Fatal("deleted property left item values behind")
}
}
}
func TestCollectionRevocationReachesItems(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "coll-rev@computer.test")
aliceID := alice.request("GET", "/api/me", nil, 200)["user"].(map[string]any)["id"].(string)
bob := newAPIUser(t, srv, "coll-rev-owner@computer.test")
// Bob owns a bookmark and shares it with Alice; Alice puts it in HER
// collection. Revocation must make the item vanish from every surface
// (list, count, mutation) without deleting anything (N-08, decision #4).
shared := collectionBookmark(t, bob, "https://127.0.0.1/shared")
bob.request("POST", "/api/bookmarks/"+shared+"/share", map[string]any{"userIds": []string{aliceID}}, 200)
col := alice.request("POST", "/api/collections", map[string]any{"name": "Watchlist"}, 201)
cid := col["id"].(string)
item := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": shared}, 201)
itemID := item["id"].(string)
if len(alice.getJSON("/api/collections/"+cid+"/items", 200)) != 1 {
t.Fatal("shared bookmark item not listed")
}
for _, c := range alice.getJSON("/api/collections", 200) {
if c["id"] == cid && c["_count"].(map[string]any)["items"].(float64) != 1 {
t.Fatalf("authorized count wrong: %v", c["_count"])
}
}
bob.request("DELETE", "/api/bookmarks/"+shared+"/share/"+aliceID, nil, 200)
if len(alice.getJSON("/api/collections/"+cid+"/items", 200)) != 0 {
t.Fatal("revoked bookmark still listed in collection items")
}
for _, c := range alice.getJSON("/api/collections", 200) {
if c["id"] == cid && c["_count"].(map[string]any)["items"].(float64) != 0 {
t.Fatalf("revoked bookmark still counted: %v", c["_count"])
}
}
alice.request("PATCH", "/api/collections/"+cid+"/items/"+itemID, map[string]any{"version": 1, "position": 3}, 404)
alice.request("DELETE", "/api/collections/"+cid+"/items/"+itemID, nil, 404)
// Review COR-2: a hidden membership row must not wedge reorder. Alice
// adds one of her own bookmarks, then reorders the visible items — the
// revoked membership stays hidden at the tail instead of making every
// PUT /items fail "Order must contain every item exactly once".
own := collectionBookmark(t, alice, "https://127.0.0.1/alice-own")
ownItem := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": own}, 201)
ownID := ownItem["id"].(string)
visible := alice.getJSON("/api/collections/"+cid+"/items", 200)
if len(visible) != 1 || visible[0]["id"] != ownID {
t.Fatalf("expected only the own item visible, got %v", visible)
}
alice.request("PUT", "/api/collections/"+cid+"/items", map[string]any{"order": []string{ownID}}, 200)
// The membership row itself survives revocation: re-sharing restores it.
bob.request("POST", "/api/bookmarks/"+shared+"/share", map[string]any{"userIds": []string{aliceID}}, 200)
items := alice.getJSON("/api/collections/"+cid+"/items", 200)
if len(items) != 2 {
t.Fatal("re-sharing did not restore the existing membership")
}
}
// Review CR-09: indirect mutations advance revisions — reorder bumps every
// affected item, property deletion bumps the items whose values it dropped
// and only the views whose definitions it actually changed. Stale PATCHes
// based on the pre-reorder / pre-deletion state get 409s.
func TestCollectionIndirectMutationsAdvanceVersions(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "coll-rev@computer.test")
bm1 := collectionBookmark(t, alice, "https://127.0.0.1/r1")
bm2 := collectionBookmark(t, alice, "https://127.0.0.1/r2")
col := alice.request("POST", "/api/collections", map[string]any{"name": "Revisions"}, 201)
cid := col["id"].(string)
dvid := col["views"].([]any)[0].(map[string]any)["id"].(string)
prop := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Label", "type": "TEXT"}, 201)
pid := prop["id"].(string)
it1 := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1, "propertyValues": map[string]any{pid: "one"}}, 201)
it2 := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm2}, 201)
id1, id2 := it1["id"].(string), it2["id"].(string)
filtered := alice.request("POST", "/api/collections/"+cid+"/views", map[string]any{
"name": "Filtered", "type": "TABLE",
"filters": map[string]any{"operator": "AND", "clauses": []any{map[string]any{"propertyId": pid, "op": "eq", "value": "one"}}},
}, 201)
fvid := filtered["id"].(string)
itemVersion := func(id string) int {
t.Helper()
for _, it := range alice.getJSON("/api/collections/"+cid+"/items", 200) {
if it["id"] == id {
v, _ := it["version"].(float64)
return int(v)
}
}
t.Fatalf("item %s missing from list", id)
return -1
}
viewVersion := func(id string) int {
t.Helper()
detail := alice.request("GET", "/api/collections/"+cid, nil, 200)
for _, raw := range detail["views"].([]any) {
v := raw.(map[string]any)
if v["id"] == id {
n, _ := v["version"].(float64)
return int(n)
}
}
t.Fatalf("view %s missing", id)
return -1
}
// Baselines: collection entities start at version 1 (migration 002).
base1, base2 := itemVersion(id1), itemVersion(id2)
fv0, dv0 := viewVersion(fvid), viewVersion(dvid)
// Reorder advances every affected item's revision...
alice.request("PUT", "/api/collections/"+cid+"/items", map[string]any{"order": []string{id2, id1}}, 200)
if v := itemVersion(id1); v != base1+1 {
t.Fatalf("reorder left item1 at version %d, want %d", v, base1+1)
}
if v := itemVersion(id2); v != base2+1 {
t.Fatalf("reorder left item2 at version %d, want %d", v, base2+1)
}
// ...so a PATCH based on the pre-reorder snapshot is a 409, and the
// current base still succeeds.
alice.request("PATCH", "/api/collections/"+cid+"/items/"+id1, map[string]any{"position": 0, "version": base1}, 409)
alice.request("PATCH", "/api/collections/"+cid+"/items/"+id1, map[string]any{"position": 0, "version": base1 + 1}, 200)
if v := itemVersion(id1); v != base1+2 {
t.Fatalf("PATCH did not advance item1 to version %d: %d", base1+2, v)
}
// Property deletion: the item carrying a value advances; the item without
// one does not. The view filtering on the property advances (and its
// clause is scrubbed); the untouched default view keeps its revision.
alice.request("DELETE", "/api/collections/"+cid+"/properties/"+pid, nil, 200)
if v := itemVersion(id1); v != base1+3 {
t.Fatalf("property deletion left valued item1 at version %d, want %d", v, base1+3)
}
if v := itemVersion(id2); v != base2+1 {
t.Fatalf("property deletion bumped valueless item2 to %d, want %d", v, base2+1)
}
if v := viewVersion(fvid); v != fv0+1 {
t.Fatalf("property deletion left the filtered view at version %d, want %d", v, fv0+1)
}
if v := viewVersion(dvid); v != dv0 {
t.Fatalf("property deletion bumped the untouched default view to %d, want %d", v, dv0)
}
}