Files
summercms/modules/cabana/catalog_smoke_test.go
Jakub Zych 9edff8890f feat(18.1-01): task 3 flat reorder on the server
- 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
2026-10-14 15:56:57 +02:00

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