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) } }) }