- config_reorder.yaml without parentColumn on a record with no parent_id column compiles as a flat (WinterCMS Sortable) reorder; maxDepth above 0 on it fails boot, an explicit parentColumn still means a tree - ListReorder.Flat and AdminReorderTree.Flat (flat, omitempty); flat nodes carry parent_id null, a save writes only the sort column, a parent is a 422 on nodes (reorder.flat_parent); 409 and duplicate rules unchanged - swag descriptions, OpenAPI and schema.d.ts regenerated - acme.catalog badges reorder fixture, TestReorderFlatCompile and TestReorderFlatSmoke; docs Flat reorder section and cabana README
375 lines
14 KiB
Go
375 lines
14 KiB
Go
package cabana_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
)
|
|
|
|
// catalogDir is the neutral acme.catalog fixture plugin tree of the Phase
|
|
// 18.1 cabana features (D-17): the taglist field and the flat reorder. No
|
|
// application names appear in framework tests.
|
|
const catalogDir = "testdata/catalog"
|
|
|
|
// catalogBadge is the fixture model of the badges controller: a name, a
|
|
// taglist in array mode over a JSON column, a taglist in string mode over a
|
|
// string column and a sort column. The table has no parent_id column.
|
|
type catalogBadge struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Name string `gorm:"column:name"`
|
|
Tags lagoon.Jsonable[[]string] `gorm:"column:tags;type:text"`
|
|
Keywords string `gorm:"column:keywords"`
|
|
SortOrder int `gorm:"column:sort_order"`
|
|
}
|
|
|
|
func (catalogBadge) TableName() string { return "acme_catalog_badges" }
|
|
func (catalogBadge) Fillable() []string {
|
|
return []string{"name", "tags", "keywords", "sort_order"}
|
|
}
|
|
func (catalogBadge) Rules() map[string]string { return map[string]string{"name": "required"} }
|
|
|
|
// catalogModels are the fixture tables, dropped and recreated per env.
|
|
func catalogModels() []any { return []any{&catalogBadge{}} }
|
|
|
|
// catalogPlugin registers the badges controller.
|
|
type catalogPlugin struct{}
|
|
|
|
func (catalogPlugin) ID() string { return "acme.catalog" }
|
|
func (catalogPlugin) Requires() []string { return nil }
|
|
func (catalogPlugin) Register(*backpack.App) error { return nil }
|
|
func (catalogPlugin) Boot(*backpack.App) error { return nil }
|
|
func (catalogPlugin) AdminFS() fs.FS { return os.DirFS(catalogDir) }
|
|
func (catalogPlugin) AdminControllers() []pact.AdminController {
|
|
return []pact.AdminController{catalogBadges{}}
|
|
}
|
|
func (catalogPlugin) Permissions() []pact.Permission {
|
|
return []pact.Permission{{Code: "acme.catalog.access", Roles: []string{"developer"}}}
|
|
}
|
|
|
|
// catalogBadges is the badges controller.
|
|
type catalogBadges struct{}
|
|
|
|
func (catalogBadges) ID() string { return "acme.catalog.badges" }
|
|
func (catalogBadges) ModelName() string { return "Badge" }
|
|
func (catalogBadges) ConfigDir() string { return "controllers/badges" }
|
|
func (catalogBadges) RequiredPermissions() []string { return []string{"acme.catalog.access"} }
|
|
func (catalogBadges) NewRecord() any { return &catalogBadge{} }
|
|
|
|
// catalogFSPlugin is the acme.catalog fixture served from another tree,
|
|
// optionally with another record model and settings pages.
|
|
type catalogFSPlugin struct {
|
|
catalogPlugin
|
|
fsys fs.FS
|
|
newRecord func() any
|
|
settings []pact.SettingsItem
|
|
}
|
|
|
|
func (p catalogFSPlugin) Settings() []pact.SettingsItem { return p.settings }
|
|
|
|
func (p catalogFSPlugin) AdminFS() fs.FS { return p.fsys }
|
|
func (p catalogFSPlugin) AdminControllers() []pact.AdminController {
|
|
if p.newRecord != nil {
|
|
return []pact.AdminController{catalogBadgesWith{newRecord: p.newRecord}}
|
|
}
|
|
return p.catalogPlugin.AdminControllers()
|
|
}
|
|
|
|
// catalogBadgesWith is the badges controller over another record model.
|
|
type catalogBadgesWith struct {
|
|
catalogBadges
|
|
newRecord func() any
|
|
}
|
|
|
|
func (c catalogBadgesWith) NewRecord() any { return c.newRecord() }
|
|
|
|
// catalogOverlay is the acme.catalog fixture tree with files replaced; an
|
|
// empty value removes the file.
|
|
func catalogOverlay(t *testing.T, files map[string]string) fstest.MapFS {
|
|
t.Helper()
|
|
out := fstest.MapFS{}
|
|
err := fs.WalkDir(os.DirFS(catalogDir), ".", func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return err
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(catalogDir, path))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out[path] = &fstest.MapFile{Data: raw}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for path, data := range files {
|
|
if data == "" {
|
|
delete(out, path)
|
|
continue
|
|
}
|
|
out[path] = &fstest.MapFile{Data: []byte(data)}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// catalogFile is one file of the fixture tree.
|
|
func catalogFile(t *testing.T, path string) string {
|
|
t.Helper()
|
|
raw, err := os.ReadFile(filepath.Join(catalogDir, path))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(raw)
|
|
}
|
|
|
|
// activateCatalog boots the admin of p without a database.
|
|
func activateCatalog(t *testing.T, p party.Plugin) error {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-catalog-boot\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{p})
|
|
return err
|
|
}
|
|
|
|
// newCatalogEnv boots the acme.catalog fixture alone on Postgres and signs
|
|
// an administrator in.
|
|
func newCatalogEnv(t *testing.T) *conformEnv {
|
|
t.Helper()
|
|
gdb := adminGorm(t)
|
|
if err := gdb.Migrator().DropTable(catalogModels()...); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := gdb.AutoMigrate(catalogModels()...); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
login := fmt.Sprintf("catalog-%d", time.Now().UnixNano())
|
|
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
|
|
h, err := paymentsAssemble(t, gdb, []party.Plugin{catalogPlugin{}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
env := &conformEnv{h: h, db: gdb, login: login}
|
|
if rec := env.send(t, http.MethodPost, "/auth/login", map[string]string{"login": login, "password": adminTestPassword}, false); rec.Code != http.StatusOK {
|
|
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
return env
|
|
}
|
|
|
|
// storedTagsText is the tags column of badge id as the database holds it.
|
|
func storedTagsText(t *testing.T, e *conformEnv, id uint) *string {
|
|
t.Helper()
|
|
var text *string
|
|
if err := e.db.Raw("SELECT tags FROM acme_catalog_badges WHERE id = ?", id).Scan(&text).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return text
|
|
}
|
|
|
|
// expectCatalog422 posts body to the badges controller and wants a 422
|
|
// whose detail on field contains message.
|
|
func expectCatalog422(t *testing.T, e *conformEnv, body map[string]any, field, message string) {
|
|
t.Helper()
|
|
rec := e.send(t, http.MethodPost, "/acme/catalog/badges", body, true)
|
|
if rec.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("want 422 on %s, status=%d body=%s", field, rec.Code, rec.Body.String())
|
|
}
|
|
var out struct {
|
|
Error struct {
|
|
Details map[string][]string `json:"details"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("422 body: %v\n%s", err, rec.Body.String())
|
|
}
|
|
msgs := out.Error.Details[field]
|
|
if len(msgs) != 1 || !strings.Contains(msgs[0], message) {
|
|
t.Fatalf("422 details on %s = %v, want %q; body=%s", field, msgs, message, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestTaglistFieldSmoke checks the taglist save path end to end on
|
|
// Postgres: an array-mode list is trimmed, emptied of blanks and duplicates
|
|
// and stored as exactly that JSON array, the record GET serves the list,
|
|
// null stores [], and a value that is not a list of strings is a 422 on the
|
|
// field with taglist.invalid (Polish, the request locale of the env). A
|
|
// string-mode value is split on its separator, normalised and stored joined
|
|
// by it; null stores an empty string. With customTags false a tag outside
|
|
// the options is a 422 with taglist.not_allowed naming the tag.
|
|
func TestTaglistFieldSmoke(t *testing.T) {
|
|
e := newCatalogEnv(t)
|
|
rec := e.send(t, http.MethodPost, "/acme/catalog/badges", map[string]any{"name": "First", "tags": []any{" alpha ", "beta", "alpha", ""}}, true)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
id := dataID(t, rec.Body.Bytes())
|
|
if text := storedTagsText(t, e, id); text == nil || *text != `["alpha","beta"]` {
|
|
t.Fatalf("stored tags = %v, want [\"alpha\",\"beta\"]", text)
|
|
}
|
|
show := e.send(t, http.MethodGet, fmt.Sprintf("/acme/catalog/badges/%d", id), nil, true)
|
|
if show.Code != http.StatusOK {
|
|
t.Fatalf("show status=%d body=%s", show.Code, show.Body.String())
|
|
}
|
|
var record struct {
|
|
Data map[string]json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(show.Body.Bytes(), &record); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := string(record.Data["tags"]); got != `["alpha","beta"]` {
|
|
t.Fatalf("served tags = %s, want [\"alpha\",\"beta\"]", got)
|
|
}
|
|
|
|
expectCatalog422(t, e, map[string]any{"name": "Bad", "tags": "alpha"}, "tags", "Pole tags musi być listą tagów.")
|
|
expectCatalog422(t, e, map[string]any{"name": "Bad", "tags": []any{1}}, "tags", "Pole tags musi być listą tagów.")
|
|
|
|
rec = e.send(t, http.MethodPost, "/acme/catalog/badges", map[string]any{"name": "Empty", "tags": nil}, true)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("null create status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if text := storedTagsText(t, e, dataID(t, rec.Body.Bytes())); text == nil || *text != `[]` {
|
|
t.Fatalf("null stored %v, want []", text)
|
|
}
|
|
|
|
// String mode, separator space: split on whitespace, normalised, joined
|
|
// by one space, served as stored.
|
|
rec = e.send(t, http.MethodPost, "/acme/catalog/badges", map[string]any{"name": "Words", "keywords": "x y x"}, true)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("keywords create status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
id = dataID(t, rec.Body.Bytes())
|
|
var badge catalogBadge
|
|
if err := e.db.First(&badge, id).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if badge.Keywords != "x y" {
|
|
t.Fatalf("stored keywords = %q, want \"x y\"", badge.Keywords)
|
|
}
|
|
show = e.send(t, http.MethodGet, fmt.Sprintf("/acme/catalog/badges/%d", id), nil, true)
|
|
if err := json.Unmarshal(show.Body.Bytes(), &record); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := string(record.Data["keywords"]); got != `"x y"` {
|
|
t.Fatalf("served keywords = %s, want \"x y\"", got)
|
|
}
|
|
rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/catalog/badges/%d", id), map[string]any{"name": "Words", "keywords": nil}, true)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("keywords null status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if err := e.db.First(&badge, id).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if badge.Keywords != "" {
|
|
t.Fatalf("null keywords stored %q, want empty", badge.Keywords)
|
|
}
|
|
expectCatalog422(t, e, map[string]any{"name": "Bad", "keywords": []any{"x"}}, "keywords", "Pole keywords musi być listą tagów.")
|
|
|
|
// customTags false: only the options are accepted.
|
|
expectCatalog422(t, e, map[string]any{"name": "Bad", "tags": []any{"alpha", "delta"}}, "tags", "Tag delta jest niedozwolony.")
|
|
}
|
|
|
|
// catalogReorder is the data of the badges reorder routes.
|
|
type catalogReorder struct {
|
|
Data struct {
|
|
Flat bool `json:"flat"`
|
|
Nodes []struct {
|
|
ID uint `json:"id"`
|
|
ParentID *uint `json:"parent_id"`
|
|
SortOrder int64 `json:"sort_order"`
|
|
Name string `json:"name"`
|
|
} `json:"nodes"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
// TestReorderFlatSmoke checks the flat reorder routes on Postgres: the GET
|
|
// answers flat true with parent_id null on every node, the POST stores the
|
|
// submitted sort values (only the sort column) and answers the new order, a
|
|
// node with a parent is a 422 on nodes and a missing node a 409.
|
|
func TestReorderFlatSmoke(t *testing.T) {
|
|
e := newCatalogEnv(t)
|
|
ids := make([]uint, 0, 3)
|
|
for i, name := range []string{"One", "Two", "Three"} {
|
|
badge := catalogBadge{Name: name, SortOrder: i + 1}
|
|
if err := e.db.Create(&badge).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ids = append(ids, badge.ID)
|
|
}
|
|
rec := e.send(t, http.MethodGet, "/acme/catalog/badges/reorder", nil, true)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("reorder GET status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var got catalogReorder
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !got.Data.Flat || len(got.Data.Nodes) != 3 || got.Data.Nodes[0].Name != "One" {
|
|
t.Fatalf("reorder GET = %s", rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), `"flat":true`) {
|
|
t.Fatalf("reorder GET lacks flat: %s", rec.Body.String())
|
|
}
|
|
for _, node := range got.Data.Nodes {
|
|
if node.ParentID != nil {
|
|
t.Fatalf("flat node %d has parent %d", node.ID, *node.ParentID)
|
|
}
|
|
}
|
|
|
|
reversed := []map[string]any{
|
|
{"id": ids[2], "parent_id": nil, "sort_order": 1},
|
|
{"id": ids[1], "parent_id": nil, "sort_order": 2},
|
|
{"id": ids[0], "parent_id": nil, "sort_order": 3},
|
|
}
|
|
rec = e.send(t, http.MethodPost, "/acme/catalog/badges/reorder", map[string]any{"nodes": reversed}, true)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("reorder POST status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got.Data.Nodes) != 3 || got.Data.Nodes[0].ID != ids[2] || got.Data.Nodes[2].ID != ids[0] || !got.Data.Flat {
|
|
t.Fatalf("reorder POST answer = %s", rec.Body.String())
|
|
}
|
|
for i, id := range []uint{ids[2], ids[1], ids[0]} {
|
|
var badge catalogBadge
|
|
if err := e.db.First(&badge, id).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if badge.SortOrder != i+1 {
|
|
t.Fatalf("badge %d sort_order = %d, want %d", id, badge.SortOrder, i+1)
|
|
}
|
|
}
|
|
|
|
nested := []map[string]any{
|
|
{"id": ids[0], "parent_id": nil, "sort_order": 1},
|
|
{"id": ids[1], "parent_id": ids[0], "sort_order": 2},
|
|
{"id": ids[2], "parent_id": nil, "sort_order": 3},
|
|
}
|
|
rec = e.send(t, http.MethodPost, "/acme/catalog/badges/reorder", map[string]any{"nodes": nested}, true)
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"nodes"`) || !strings.Contains(rec.Body.String(), "Kolejność płaska nie ma elementów nadrzędnych.") {
|
|
t.Fatalf("flat parent status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
rec = e.send(t, http.MethodPost, "/acme/catalog/badges/reorder", map[string]any{"nodes": reversed[:2]}, true)
|
|
if rec.Code != http.StatusConflict {
|
|
t.Fatalf("missing node status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|