Files
summercms/modules/cabana/reorder_test.go
Jakub Zych 91593da1f7 test(17-06): framework and admin SPA unit tests for the blog companions
- signpost: activation refusals, host overrides, D-02 pattern layers
  incl. PatternFrom, ReferenceOptions depths
- seaglass: removal report, bluemonday bypass corpus, Check
- pact Option.Depth omitempty and the menu item type contracts; party
  activates menu item types after Register and before the first Boot
- cabana: repeater compile and decode, dependsOn compile and recheck,
  richeditor keys and notices, reorder and singleton boot checks
  (Docker-free); repeater save, dependent recheck, richeditor save,
  reorder and singleton routes on Postgres; conformance cases for the
  documented 404, 409 and 422 answers of the new routes
- admin: richeditor value contract, link popover, notices and chunk
  failure; repeater bounds, remove and reorder, collapsed rows;
  dependent dropdown states and last request wins; reorder
  announcements, partition switch and tree rules; SingletonForm spec
- gate: required names in the go, admin and postgres stages
2026-10-12 19:29:37 +02:00

266 lines
13 KiB
Go

package cabana_test
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestReorderConfigCompile pins the boot checks of config_reorder.yaml
// (D-09), Docker-free through cabana.Activate: unknown keys, a modelClass
// mismatch, columns that are not columns of the record, a negative maxDepth,
// a partition over a column without a belongsTo relation field, a partition
// label column the related record lacks, and the reorder toolbar button
// without the file.
func TestReorderConfigCompile(t *testing.T) {
const cfg = "controllers/links/config_reorder.yaml"
reorder := func(t *testing.T, old, new string) map[string]string {
t.Helper()
src := companionsFile(t, cfg)
if !strings.Contains(src, old) {
t.Fatalf("fixture lacks %q", old)
}
return map[string]string{cfg: strings.Replace(src, old, new, 1)}
}
t.Run("the fixture compiles, and defaults fill parentColumn and sortColumn", func(t *testing.T) {
if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, nil)}); err != nil {
t.Fatal(err)
}
files := reorder(t, "parentColumn: parent_id\nsortColumn: sort_order\n", "")
if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, files)}); err != nil {
t.Fatalf("defaults: %v", err)
}
files = reorder(t, "maxDepth: 3\n", "maxDepth: 0\n")
if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, files)}); err != nil {
t.Fatalf("unlimited depth: %v", err)
}
files = reorder(t, "partition:\n column: group_id\n relation: group\n label: Group\n", "")
if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, files)}); err != nil {
t.Fatalf("without a partition: %v", err)
}
files = reorder(t, " label: Group\n", " label: Group\n nameFrom: title\n")
if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, files)}); err != nil {
t.Fatalf("partition nameFrom: %v", err)
}
})
for _, tc := range []struct {
name string
files func(t *testing.T) map[string]string
want []string
}{
{"unknown key", func(t *testing.T) map[string]string {
return reorder(t, "title: Reorder links\n", "title: Reorder links\nshowTree: true\n")
}, []string{cfg, "showTree"}},
{"unknown partition key", func(t *testing.T) map[string]string {
return reorder(t, " label: Group\n", " label: Group\n scope: x\n")
}, []string{cfg, "scope"}},
{"modelClass mismatch", func(t *testing.T) map[string]string { return reorder(t, "modelClass: Link\n", "modelClass: Other\n") }, []string{cfg, `modelClass "Other" does not match "Link"`}},
{"nameFrom not a column", func(t *testing.T) map[string]string { return reorder(t, "nameFrom: title\n", "nameFrom: label\n") }, []string{"nameFrom label is not a column of the record"}},
{"nameFrom not an identifier", func(t *testing.T) map[string]string {
return reorder(t, "nameFrom: title\n", "nameFrom: \"title; drop\"\n")
}, []string{"nameFrom", "is not a column name"}},
{"parentColumn not a column", func(t *testing.T) map[string]string {
return reorder(t, "parentColumn: parent_id\n", "parentColumn: parent\n")
}, []string{"parentColumn parent is not a column of the record"}},
{"sortColumn not a column", func(t *testing.T) map[string]string {
return reorder(t, "sortColumn: sort_order\n", "sortColumn: position\n")
}, []string{"sortColumn position is not a column of the record"}},
{"negative maxDepth", func(t *testing.T) map[string]string { return reorder(t, "maxDepth: 3\n", "maxDepth: -1\n") }, []string{"maxDepth must be 0 (unlimited) or more"}},
{"partition column not a column", func(t *testing.T) map[string]string { return reorder(t, "column: group_id\n", "column: menu_id\n") }, []string{"partition.column menu_id is not a column of the record"}},
{"partition relation unknown", func(t *testing.T) map[string]string { return reorder(t, "relation: group\n", "relation: menu\n") }, []string{"partition.relation menu must be a belongsTo relation field of the form over group_id"}},
{"partition relation over another column", func(t *testing.T) map[string]string { return reorder(t, "column: group_id\n", "column: parent_id\n") }, []string{"partition.relation group must be a belongsTo relation field of the form over parent_id"}},
{"partition nameFrom not a related column", func(t *testing.T) map[string]string {
return reorder(t, " label: Group\n", " label: Group\n nameFrom: name\n")
}, []string{"partition.nameFrom name is not a column of the related record"}},
{"reorder button without the file", func(t *testing.T) map[string]string { return map[string]string{cfg: ""} }, []string{"config_list.yaml", "toolbar.buttons: reorder needs config_reorder.yaml"}},
{"a reorder page needs a list", func(t *testing.T) map[string]string {
return map[string]string{"controllers/profiles/config_reorder.yaml": "title: Profiles\nnameFrom: title\nparentColumn: id\nsortColumn: id\n"}
}, []string{"a reorder page needs config_list.yaml"}},
} {
t.Run(tc.name, func(t *testing.T) {
err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, tc.files(t))})
if err == nil {
t.Fatal("boot accepted the reorder config")
}
for _, part := range tc.want {
if !strings.Contains(err.Error(), part) {
t.Fatalf("err = %v, want %q", err, part)
}
}
})
}
}
// TestReorderPostgres pins the D-09 routes on Postgres: the GET scopes and
// orders the partition's records, the POST writes both columns, and every
// refused request (stale set, foreign partition node, unknown id, duplicate,
// self parent, parent outside the set, cycle, depth above maxDepth, missing
// or unknown partition) answers its status and writes nothing.
func TestReorderPostgres(t *testing.T) {
e := newCompanionsEnv(t)
alpha, beta := companionsGroup{Title: "Beta"}, companionsGroup{Title: "Alpha"}
for _, g := range []*companionsGroup{&alpha, &beta} {
if err := e.db.Create(g).Error; err != nil {
t.Fatal(err)
}
}
// Records of alpha ("Beta"): sort order ties break by id; one record of
// beta ("Alpha") is the foreign node.
links := []companionsLink{
{Title: "A", GroupID: &alpha.ID, SortOrder: 2},
{Title: "B", GroupID: &alpha.ID, SortOrder: 1},
{Title: "C", GroupID: &alpha.ID, SortOrder: 2},
{Title: "D", GroupID: &alpha.ID, SortOrder: 3},
{Title: "E", GroupID: &alpha.ID, SortOrder: 4},
{Title: "X", GroupID: &beta.ID, SortOrder: 1},
{Title: "Free"},
}
for i := range links {
if err := e.db.Create(&links[i]).Error; err != nil {
t.Fatal(err)
}
}
a, b, c, d, x := links[0].ID, links[1].ID, links[2].ID, links[3].ID, links[5].ID
ee := links[4].ID
t.Run("GET", func(t *testing.T) {
tree := reorderTree(t, e.send(t, http.MethodGet, fmt.Sprintf("/acme/companions/links/reorder?partition=%d", alpha.ID), nil, true))
var names []string
for _, n := range tree.Nodes {
names = append(names, n.Name)
}
if strings.Join(names, ",") != "B,A,C,D,E" || tree.Title != "Reorder links" || tree.Partition.Label != "Group" {
t.Fatalf("tree = %+v (names %v)", tree, names)
}
// Without a partition the first one by label ("Alpha") is shown.
first := reorderTree(t, e.send(t, http.MethodGet, "/acme/companions/links/reorder", nil, true))
if first.Partition == nil || first.Partition.Value == nil || *first.Partition.Value != beta.ID || len(first.Nodes) != 1 || first.Nodes[0].ID != x {
t.Fatalf("default partition tree = %+v", first)
}
if opts := first.Partition.Options; len(opts) != 2 || opts[0].Label != "Alpha" || opts[1].Label != "Beta" {
t.Fatalf("partition options = %+v", opts)
}
for path, status := range map[string]int{
"/acme/companions/links/reorder?partition=999999": http.StatusNotFound,
"/acme/companions/links/reorder?partition=abc": http.StatusUnprocessableEntity,
"/acme/companions/links/reorder?partition=0": http.StatusUnprocessableEntity,
"/acme/companions/profiles/reorder": http.StatusNotFound,
} {
if rec := e.send(t, http.MethodGet, path, nil, true); rec.Code != status {
t.Fatalf("%s status=%d want %d body=%s", path, rec.Code, status, rec.Body.String())
}
}
if rec := e.send(t, http.MethodGet, "/acme/companions/links/reorder", nil, false); rec.Code != http.StatusUnauthorized {
t.Fatalf("anonymous status=%d", rec.Code)
}
})
post := func(partition any, nodes ...map[string]any) *httptest.ResponseRecorder {
body := map[string]any{"nodes": nodes}
if partition != nil {
body["partition"] = partition
}
return e.send(t, http.MethodPost, "/acme/companions/links/reorder", body, true)
}
n := reorderNode
t.Run("POST writes both columns", func(t *testing.T) {
tree := reorderTree(t, post(alpha.ID, n(a, nil, 1), n(b, &a, 1), n(c, &b, 1), n(d, nil, 2), n(ee, &a, 2)))
got := companionsLinkRows(t, e)
check := func(id uint, parent *uint, sort int) {
t.Helper()
row := got[id]
if (parent == nil) != (row.ParentID == nil) || (parent != nil && *row.ParentID != *parent) || row.SortOrder != sort {
t.Fatalf("row %d = parent %v sort %d, want %v %d", id, row.ParentID, row.SortOrder, parent, sort)
}
}
check(a, nil, 1)
check(b, &a, 1)
check(c, &b, 1)
check(d, nil, 2)
check(ee, &a, 2)
check(x, nil, 1)
if len(tree.Nodes) != 5 || tree.Nodes[0].ID != a {
t.Fatalf("answered tree = %+v", tree)
}
})
before := companionsLinkRows(t, e)
for _, tc := range []struct {
name string
rec func() *httptest.ResponseRecorder
status int
code string
detail string
}{
{"a node left out", func() *httptest.ResponseRecorder {
return post(alpha.ID, n(a, nil, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4))
}, http.StatusConflict, "reorder_stale", ""},
{"a node of another partition", func() *httptest.ResponseRecorder {
return post(alpha.ID, n(a, nil, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(ee, nil, 5), n(x, nil, 6))
}, http.StatusConflict, "reorder_stale", ""},
{"a foreign node in place of one", func() *httptest.ResponseRecorder {
return post(alpha.ID, n(a, nil, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(x, nil, 5))
}, http.StatusConflict, "reorder_stale", ""},
{"an unknown id", func() *httptest.ResponseRecorder {
return post(alpha.ID, n(a, nil, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(999999, nil, 5))
}, http.StatusConflict, "reorder_stale", ""},
{"a duplicate id", func() *httptest.ResponseRecorder {
return post(alpha.ID, n(a, nil, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(ee, nil, 5), n(a, nil, 6))
}, http.StatusUnprocessableEntity, "validation_failed", "nodes"},
{"id zero", func() *httptest.ResponseRecorder {
return post(alpha.ID, n(0, nil, 1), n(b, nil, 2))
}, http.StatusUnprocessableEntity, "validation_failed", "nodes"},
{"its own parent", func() *httptest.ResponseRecorder {
return post(alpha.ID, n(a, &a, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(ee, nil, 5))
}, http.StatusUnprocessableEntity, "validation_failed", "nodes"},
{"a parent outside the set", func() *httptest.ResponseRecorder {
return post(alpha.ID, n(a, &x, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(ee, nil, 5))
}, http.StatusUnprocessableEntity, "validation_failed", "nodes"},
{"a cycle", func() *httptest.ResponseRecorder {
return post(alpha.ID, n(a, &c, 1), n(b, &a, 1), n(c, &b, 1), n(d, nil, 2), n(ee, nil, 3))
}, http.StatusUnprocessableEntity, "validation_failed", "nodes"},
{"deeper than maxDepth", func() *httptest.ResponseRecorder {
return post(alpha.ID, n(a, nil, 1), n(b, &a, 1), n(c, &b, 1), n(d, &c, 1), n(ee, nil, 2))
}, http.StatusUnprocessableEntity, "validation_failed", "nodes"},
{"no partition", func() *httptest.ResponseRecorder {
return post(nil, n(a, nil, 1))
}, http.StatusUnprocessableEntity, "validation_failed", "partition"},
{"an unknown partition", func() *httptest.ResponseRecorder {
return post(999999, n(a, nil, 1))
}, http.StatusNotFound, "", ""},
{"a singleton controller", func() *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/acme/companions/profiles/reorder", map[string]any{"nodes": []any{}}, true)
}, http.StatusNotFound, "", ""},
{"anonymous", func() *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/acme/companions/links/reorder", map[string]any{"partition": alpha.ID, "nodes": []any{}}, false)
}, http.StatusUnauthorized, "", ""},
} {
t.Run(tc.name, func(t *testing.T) {
rec := tc.rec()
if rec.Code != tc.status {
t.Fatalf("status=%d want %d body=%s", rec.Code, tc.status, rec.Body.String())
}
if tc.code != "" {
code, details := companionsError(t, rec.Body.Bytes())
if code != tc.code || (tc.detail != "" && len(details[tc.detail]) != 1) {
t.Fatalf("error = %s %v, want %s on %q", code, details, tc.code, tc.detail)
}
if tc.code == "reorder_stale" && !strings.Contains(rec.Body.String(), "Rekordy zmieniły się w międzyczasie") {
t.Fatalf("stale message = %s", rec.Body.String())
}
}
sameLinkRows(t, e, before, tc.name)
})
}
t.Run("exactly maxDepth is accepted", func(t *testing.T) {
reorderTree(t, post(alpha.ID, n(a, nil, 1), n(b, &a, 1), n(c, &b, 1), n(d, nil, 2), n(ee, nil, 3)))
if row := companionsLinkRows(t, e)[c]; row.ParentID == nil || *row.ParentID != b {
t.Fatalf("c = %+v", row)
}
})
}