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. The // partitioned subtest runs the same rules within one partition. 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()) } t.Run("partitioned", reorderFlatPartition) } // shelfPin is the record of the partitioned flat reorder fixture // (acme.shelf): a name, the shelf it belongs to (the partition) and a sort // column, and no parent_id column, so its reorder is flat. type shelfPin struct { ID uint `gorm:"column:id;primaryKey"` Name string `gorm:"column:name"` ShelfID *uint `gorm:"column:shelf_id"` SortOrder int `gorm:"column:sort_order"` } func (shelfPin) TableName() string { return "acme_shelf_pins" } func (shelfPin) Fillable() []string { return []string{"name", "sort_order"} } func (shelfPin) Rules() map[string]string { return map[string]string{"name": "required"} } // shelfShelf is the belongsTo target of the pins' shelf field. type shelfShelf struct { ID uint `gorm:"column:id;primaryKey"` Title string `gorm:"column:title"` } func (shelfShelf) TableName() string { return "acme_shelf_shelves" } // shelfFS is the acme.shelf fixture tree: the pins controller with a flat // reorder partitioned by shelf. var shelfFS = fstest.MapFS{ "controllers/pins/config_list.yaml": {Data: []byte("list: ~/plugins/acme/shelf/models/pin/columns.yaml\nmodelClass: Pin\ntitle: Pins\nrecordUrl: acme/shelf/pins/update/:id\nrecordsPerPage: 20\ntoolbar:\n buttons: [create, reorder]\n")}, "controllers/pins/config_form.yaml": {Data: []byte("name: Pin\nform: ~/plugins/acme/shelf/models/pin/fields.yaml\nmodelClass: Pin\ndefaultRedirect: acme/shelf/pins\n")}, "controllers/pins/config_reorder.yaml": {Data: []byte("title: Reorder pins\nnameFrom: name\nmodelClass: Pin\npartition:\n column: shelf_id\n relation: shelf\n label: Shelf\n")}, "models/pin/columns.yaml": {Data: []byte("columns:\n name:\n label: Name\n sort_order:\n label: Order\n")}, "models/pin/fields.yaml": {Data: []byte("fields:\n name:\n label: Name\n type: text\n shelf:\n label: Shelf\n type: relation\n nameFrom: title\n")}, } // shelfPlugin registers the pins controller. type shelfPlugin struct{} func (shelfPlugin) ID() string { return "acme.shelf" } func (shelfPlugin) Requires() []string { return nil } func (shelfPlugin) Register(*backpack.App) error { return nil } func (shelfPlugin) Boot(*backpack.App) error { return nil } func (shelfPlugin) AdminFS() fs.FS { return shelfFS } func (shelfPlugin) AdminControllers() []pact.AdminController { return []pact.AdminController{shelfPins{}} } func (shelfPlugin) Permissions() []pact.Permission { return []pact.Permission{{Code: "acme.shelf.access", Roles: []string{"developer"}}} } // shelfPins is the pins controller. type shelfPins struct{} func (shelfPins) ID() string { return "acme.shelf.pins" } func (shelfPins) ModelName() string { return "Pin" } func (shelfPins) ConfigDir() string { return "controllers/pins" } func (shelfPins) RequiredPermissions() []string { return []string{"acme.shelf.access"} } func (shelfPins) NewRecord() any { return &shelfPin{} } // AdminFieldRelations binds the shelf relation field, the reorder partition. func (shelfPins) AdminFieldRelations() []cabana.FieldRelationContract { return []cabana.FieldRelationContract{{Field: "shelf", Kind: "belongsTo", NewRelated: func() any { return &shelfShelf{} }, ForeignKey: "shelf_id"}} } // newShelfEnv is a logged-in admin over the acme.shelf fixture with fresh // tables. func newShelfEnv(t *testing.T) *conformEnv { t.Helper() gdb := adminGorm(t) models := []any{&shelfPin{}, &shelfShelf{}} if err := gdb.Migrator().DropTable(models...); err != nil { t.Fatal(err) } if err := gdb.AutoMigrate(models...); err != nil { t.Fatal(err) } login := fmt.Sprintf("shelf-%d", time.Now().UnixNano()) insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false) h, err := paymentsAssemble(t, gdb, []party.Plugin{shelfPlugin{}}) 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 } // reorderFlatPartition checks a partitioned flat reorder on Postgres (plan // 18.1-06, a subtest of TestReorderFlatSmoke): the GET of one shelf answers flat true with only // that shelf's pins and the partition options, the POST writes only the sort // column (the shelf and the other shelf's rows are untouched), a pin of the // other shelf is the 409 stale set and a parent is a 422 on nodes. func reorderFlatPartition(t *testing.T) { e := newShelfEnv(t) top, low := shelfShelf{Title: "Top"}, shelfShelf{Title: "Low"} for _, s := range []*shelfShelf{&top, &low} { if err := e.db.Create(s).Error; err != nil { t.Fatal(err) } } pins := []shelfPin{{Name: "A", ShelfID: &top.ID, SortOrder: 1}, {Name: "B", ShelfID: &top.ID, SortOrder: 2}, {Name: "X", ShelfID: &low.ID, SortOrder: 7}} for i := range pins { if err := e.db.Create(&pins[i]).Error; err != nil { t.Fatal(err) } } a, b, x := pins[0].ID, pins[1].ID, pins[2].ID rec := e.send(t, http.MethodGet, fmt.Sprintf("/acme/shelf/pins/reorder?partition=%d", top.ID), nil, true) if rec.Code != http.StatusOK { t.Fatalf("GET status=%d body=%s", rec.Code, rec.Body.String()) } var got struct { Data struct { Flat bool `json:"flat"` Partition *struct { Label string `json:"label"` Value *uint `json:"value"` Options []struct { Label string `json:"label"` } `json:"options"` } `json:"partition"` Nodes []struct { ID uint `json:"id"` ParentID *uint `json:"parent_id"` } `json:"nodes"` } `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatal(err) } d := got.Data if !d.Flat || d.Partition == nil || d.Partition.Label != "Shelf" || d.Partition.Value == nil || *d.Partition.Value != top.ID || len(d.Partition.Options) != 2 { t.Fatalf("GET = %s", rec.Body.String()) } if len(d.Nodes) != 2 || d.Nodes[0].ID != a || d.Nodes[1].ID != b || d.Nodes[0].ParentID != nil || d.Nodes[1].ParentID != nil { t.Fatalf("GET nodes = %s", rec.Body.String()) } post := func(nodes ...map[string]any) int { return e.send(t, http.MethodPost, "/acme/shelf/pins/reorder", map[string]any{"partition": top.ID, "nodes": nodes}, true).Code } n := func(id uint, parent any, sort int) map[string]any { return map[string]any{"id": id, "parent_id": parent, "sort_order": sort} } if code := post(n(b, nil, 1), n(a, nil, 2)); code != http.StatusOK { t.Fatalf("POST status=%d", code) } rows := map[uint]shelfPin{} var all []shelfPin if err := e.db.Find(&all).Error; err != nil { t.Fatal(err) } for _, p := range all { rows[p.ID] = p } if rows[b].SortOrder != 1 || rows[a].SortOrder != 2 || *rows[a].ShelfID != top.ID || *rows[b].ShelfID != top.ID || rows[x].SortOrder != 7 || *rows[x].ShelfID != low.ID { t.Fatalf("rows after POST = %+v", rows) } if code := post(n(b, nil, 1), n(a, nil, 2), n(x, nil, 3)); code != http.StatusConflict { t.Fatalf("pin of the other shelf status=%d, want 409", code) } if code := post(n(b, nil, 1), n(a, b, 2)); code != http.StatusUnprocessableEntity { t.Fatalf("parent status=%d, want 422", code) } }