Files
pad/internal/server/bug3530_share_order_test.go
T
xarmian a53160ec79 fix(server): a shared collection lists items in the owner's arranged order (BUG-3530) (#1990)
* fix(server): a shared collection lists items in the owner's arranged order (BUG-3530)

The public share resolve called ListItems with no sort, so readers got
its default, pinned and then most recently updated: every edit reshuffled
a shared collection, and the owner's manual order never showed. It now
orders by sort_order, then oldest first, then id, the manual order every
collection page opens in (the web comparator, and the child lists since
BUG-3527). The share page renders the payload in the order it arrives.

buildItemSort gains `_id` for the last tie-break; spelled with an
underscore because a field may be keyed `id` and no field can be keyed
`_id`.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* docs(web): the share page's order note says what the server now sends (BUG-3530)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 20:32:51 -04:00

106 lines
3.3 KiB
Go

package server
import (
"net/http"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// BUG-3530: a shared collection lists its items in the owner's arranged
// (manual) order: sort_order, then oldest first, then id. It used to be
// ListItems' default, pinned and then most recently updated, so every edit
// reshuffled what a reader saw.
func TestBUG3530_SharedCollectionKeepsTheManualOrder(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSForTest(t, srv)
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections", map[string]interface{}{
"name": "Shelf",
"prefix": "SHLF",
"schema": `{"fields":[{"key":"status","label":"Status","type":"select","options":["open","done"]}]}`,
})
if rr.Code != http.StatusCreated {
t.Fatalf("create collection: %d %s", rr.Code, rr.Body.String())
}
var coll models.Collection
parseJSON(t, rr, &coll)
create := func(title string) models.Item {
t.Helper()
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/shelf/items", map[string]interface{}{
"title": title, "fields": map[string]interface{}{"status": "open"},
})
if rr.Code != http.StatusCreated {
t.Fatalf("create %s: %d %s", title, rr.Code, rr.Body.String())
}
var it models.Item
parseJSON(t, rr, &it)
return it
}
a, b, c, d, e := create("A"), create("B"), create("C"), create("D"), create("E")
// Arranged order: B (-2048), C (-1024), D and E tied at 0 with D older,
// then A (1024).
for _, w := range []struct {
it models.Item
v int
}{{a, 1024}, {b, -2048}, {c, -1024}} {
v := w.v
if _, err := srv.store.UpdateItem(w.it.ID, models.ItemUpdate{SortOrder: &v}); err != nil {
t.Fatalf("set sort_order of %s: %v", w.it.Title, err)
}
}
// created_at has one-second resolution, so set the tie's order explicitly.
if _, err := srv.store.DB().Exec(srv.store.D().Rebind(`UPDATE items SET created_at = ? WHERE id = ?`), "2026-01-01T00:00:00Z", d.ID); err != nil {
t.Fatal(err)
}
if _, err := srv.store.DB().Exec(srv.store.D().Rebind(`UPDATE items SET created_at = ? WHERE id = ?`), "2026-01-02T00:00:00Z", e.ID); err != nil {
t.Fatal(err)
}
// Make the old default order differ: A is the most recently updated and
// pinned, so the default would list it first.
pinned := true
if _, err := srv.store.UpdateItem(a.ID, models.ItemUpdate{Pinned: &pinned}); err != nil {
t.Fatalf("pin A: %v", err)
}
owner, err := srv.store.CreateUser(models.UserCreate{Email: "owner3530@test.com", Name: "Owner", Password: "pw-owner"})
if err != nil {
t.Fatal(err)
}
ws, err := srv.store.GetWorkspaceBySlug(slug)
if err != nil || ws == nil {
t.Fatalf("get workspace: %v", err)
}
link, err := srv.store.CreateShareLink(ws.ID, "collection", coll.ID, "view", owner.ID, nil)
if err != nil {
t.Fatal(err)
}
rr = doRequest(srv, "GET", "/api/v1/s/"+link.Token, nil)
if rr.Code != http.StatusOK {
t.Fatalf("resolve: %d %s", rr.Code, rr.Body.String())
}
var resp struct {
Items []struct {
Title string `json:"title"`
} `json:"items"`
}
parseJSON(t, rr, &resp)
var got []string
for _, it := range resp.Items {
got = append(got, it.Title)
}
want := []string{"B", "C", "D", "E", "A"}
if len(got) != len(want) {
t.Fatalf("items %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("shared order %v, want the arranged order %v", got, want)
}
}
}