421 lines
20 KiB
Go
421 lines
20 KiB
Go
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)
|
|
}
|
|
}
|