- 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
266 lines
13 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|