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