- cabana: config_reorder.yaml (title, nameFrom, modelClass, parentColumn,
sortColumn, maxDepth, partition) with fail-loud boot checks and the
built-in reorder toolbar button; list schema carries reorder
- GET/POST {controller}/reorder: scoped set (ListExtendQuery plus the
partition) locked FOR UPDATE, 409 reorder_stale on any set difference,
422 for duplicates, foreign parents, cycles and maxDepth, both columns
written without model hooks
- pact.AdminSingleton: GET {controller}/singleton (first or create, form
scope checked), form schema singleton, list/create/bulk/delete 404,
no config_list.yaml needed
- swag, inventories, token classes, CSRF walk, conformance cases, backend
lang reorder.stale and load_failed, reorder and singleton smoke tests, docs
566 lines
24 KiB
Go
566 lines
24 KiB
Go
package cabana_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"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"
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
"git.golem15.com/golem15/summercms/modules/signpost"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// companionsDir is the neutral acme.companions fixture plugin tree of the
|
|
// Phase 17 cabana features: a dependent reference dropdown fed by the menu
|
|
// item type registry.
|
|
const companionsDir = "testdata/companions"
|
|
|
|
// companionsLink is the fixture model of the links controller.
|
|
type companionsLink struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Title string `gorm:"column:title"`
|
|
Type *string `gorm:"column:type"`
|
|
Reference *string `gorm:"column:reference"`
|
|
// Description is the richeditor field.
|
|
Description *string `gorm:"column:description"`
|
|
// Rows is the repeater field.
|
|
Rows lagoon.Jsonable[[]map[string]any] `gorm:"column:rows"`
|
|
// GroupID is the reorder partition (the group relation field), ParentID
|
|
// and SortOrder the reorder tree columns.
|
|
GroupID *uint `gorm:"column:group_id"`
|
|
ParentID *uint `gorm:"column:parent_id"`
|
|
SortOrder int `gorm:"column:sort_order"`
|
|
}
|
|
|
|
// companionsGroup is the belongsTo target of the links' group field and the
|
|
// reorder partition.
|
|
type companionsGroup struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Title string `gorm:"column:title"`
|
|
}
|
|
|
|
func (companionsGroup) TableName() string { return "cabana_companions_groups" }
|
|
|
|
// companionsProfile is the one record of the singleton profiles controller.
|
|
type companionsProfile struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Key string `gorm:"column:key;uniqueIndex"`
|
|
Title string `gorm:"column:title"`
|
|
}
|
|
|
|
func (companionsProfile) TableName() string { return "cabana_companions_profiles" }
|
|
func (companionsProfile) Fillable() []string { return []string{"title"} }
|
|
func (companionsProfile) Rules() map[string]string { return map[string]string{} }
|
|
|
|
func (companionsLink) TableName() string { return "cabana_companions_links" }
|
|
func (companionsLink) Fillable() []string {
|
|
return []string{"title", "type", "reference", "description", "rows"}
|
|
}
|
|
func (companionsLink) Rules() map[string]string { return map[string]string{"title": "required"} }
|
|
|
|
// companionsModels are the fixture tables, dropped and recreated per env.
|
|
func companionsModels() []any {
|
|
return []any{&companionsLink{}, &companionsGroup{}, &companionsProfile{}}
|
|
}
|
|
|
|
// companionsEcho resolves an item to its layered pattern.
|
|
func companionsEcho(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
|
return &pact.ResolvedMenuItem{Title: in.Type, URL: in.Pattern, Alternates: []pact.MenuItemAlternate{{Locale: "en", URL: in.Pattern}}}, nil
|
|
}
|
|
|
|
// companionsPlugin registers two menu item types (acme-page with a two-level
|
|
// reference tree, acme-list without references) and the links controller.
|
|
type companionsPlugin struct{ app *backpack.App }
|
|
|
|
func (companionsPlugin) ID() string { return "acme.companions" }
|
|
func (companionsPlugin) Requires() []string { return nil }
|
|
func (companionsPlugin) Register(*backpack.App) error { return nil }
|
|
func (companionsPlugin) Boot(*backpack.App) error { return nil }
|
|
func (companionsPlugin) AdminFS() fs.FS { return os.DirFS(companionsDir) }
|
|
func (p companionsPlugin) AdminControllers() []pact.AdminController {
|
|
return []pact.AdminController{companionsLinks{app: p.app}, companionsProfiles{}}
|
|
}
|
|
|
|
// Settings links the singleton profiles controller from the settings page.
|
|
func (companionsPlugin) Settings() []pact.SettingsItem {
|
|
return []pact.SettingsItem{{Code: "companions_profile", Label: "Site profile", Category: "Acme", Icon: "settings",
|
|
Permissions: []string{"acme.companions.access"}, Controller: "acme.companions.profiles"}}
|
|
}
|
|
func (companionsPlugin) Permissions() []pact.Permission {
|
|
return []pact.Permission{{Code: "acme.companions.access", Roles: []string{"developer"}}}
|
|
}
|
|
func (companionsPlugin) MenuItemTypes() []pact.MenuItemType {
|
|
return []pact.MenuItemType{
|
|
{Key: "acme-page", Label: "Page", DefaultPattern: "/:slug", Resolve: companionsEcho,
|
|
References: func(context.Context) ([]pact.MenuItemReference, error) {
|
|
return []pact.MenuItemReference{{Value: "1", Label: "Home", Items: []pact.MenuItemReference{{Value: "2", Label: "About"}}}}, nil
|
|
}},
|
|
{Key: "acme-list", Label: "All pages", DefaultPattern: "/:slug", PatternFrom: "acme-page", Resolve: companionsEcho},
|
|
}
|
|
}
|
|
|
|
var (
|
|
_ pact.AdminSingleton = companionsProfiles{}
|
|
_ cabana.FieldRelationProvider = companionsLinks{}
|
|
_ pact.HasMenuItemTypes = companionsPlugin{}
|
|
_ pact.DependentOptionsProvider = companionsLinks{}
|
|
_ pact.DropdownOptionsProvider = companionsLinks{}
|
|
)
|
|
|
|
// companionsLinks is the links controller: its type dropdown offers the
|
|
// built-in url plus every registered menu item type, and its reference
|
|
// dropdown depends on type through signpost.ReferenceOptions.
|
|
type companionsLinks struct{ app *backpack.App }
|
|
|
|
func (companionsLinks) ID() string { return "acme.companions.links" }
|
|
func (companionsLinks) ModelName() string { return "Link" }
|
|
func (companionsLinks) ConfigDir() string { return "controllers/links" }
|
|
func (companionsLinks) RequiredPermissions() []string { return []string{"acme.companions.access"} }
|
|
func (companionsLinks) NewRecord() any { return &companionsLink{} }
|
|
|
|
// AdminFieldRelations binds the group relation field, the reorder partition.
|
|
func (companionsLinks) AdminFieldRelations() []cabana.FieldRelationContract {
|
|
return []cabana.FieldRelationContract{{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &companionsGroup{} }, ForeignKey: "group_id"}}
|
|
}
|
|
|
|
// companionsProfiles is a singleton controller: one profile record, created
|
|
// on first use.
|
|
type companionsProfiles struct{}
|
|
|
|
func (companionsProfiles) ID() string { return "acme.companions.profiles" }
|
|
func (companionsProfiles) ModelName() string { return "Profile" }
|
|
func (companionsProfiles) ConfigDir() string { return "controllers/profiles" }
|
|
func (companionsProfiles) RequiredPermissions() []string { return []string{"acme.companions.access"} }
|
|
func (companionsProfiles) NewRecord() any { return &companionsProfile{} }
|
|
|
|
// SingletonRecord is first or create on the unique key column.
|
|
func (companionsProfiles) SingletonRecord(_ context.Context, db *gorm.DB) (any, error) {
|
|
profile := &companionsProfile{}
|
|
err := db.Where(companionsProfile{Key: "default"}).Attrs(companionsProfile{Title: "Default"}).FirstOrCreate(profile).Error
|
|
return profile, err
|
|
}
|
|
|
|
func (c companionsLinks) registry() *signpost.Registry {
|
|
if c.app == nil {
|
|
return nil
|
|
}
|
|
reg, _ := c.app.Lookup[*signpost.Registry]()
|
|
return reg
|
|
}
|
|
|
|
func (c companionsLinks) DropdownOptions(string) []pact.Option {
|
|
out := []pact.Option{{Value: "url", Label: "URL"}}
|
|
for _, t := range c.registry().Types() {
|
|
out = append(out, pact.Option{Value: t.Key, Label: t.Label})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (c companionsLinks) DependentOptions(ctx context.Context, _ string, values map[string]any) (pact.DependentOptionSet, error) {
|
|
typ, _ := values["type"].(string)
|
|
return signpost.ReferenceOptions(ctx, c.registry(), typ)
|
|
}
|
|
|
|
// newCompanionsEnv boots the acme.companions fixture alone on Postgres, with
|
|
// the menu item type registry activated, and signs an administrator in.
|
|
func newCompanionsEnv(t *testing.T) *conformEnv {
|
|
t.Helper()
|
|
gdb := adminGorm(t)
|
|
resetCompanions(t, gdb)
|
|
login := fmt.Sprintf("companions-%d", time.Now().UnixNano())
|
|
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-companions\nlocale: en\nfallback_locale: en\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)
|
|
}
|
|
for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 1048576} {
|
|
if err := cfg.Set(key, value); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
app := backpack.New(cfg)
|
|
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plugins := []party.Plugin{companionsPlugin{app: app}}
|
|
if err := phrasebook.Activate(app, plugins); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := signpost.Activate(app, plugins); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h, err := surf.Assemble(app, plugins)
|
|
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
|
|
}
|
|
|
|
// resetCompanions drops and recreates the fixture tables.
|
|
func resetCompanions(t *testing.T, gdb *gorm.DB) {
|
|
t.Helper()
|
|
if err := gdb.Migrator().DropTable(companionsModels()...); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := gdb.AutoMigrate(companionsModels()...); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// dependentOptionsPath is the dependent-options route of a links field for
|
|
// one value of its type sibling.
|
|
func dependentOptionsPath(field, typ string) string {
|
|
path := "/acme/companions/links/fields/" + field + "/dependent-options"
|
|
if typ != "" {
|
|
path += "?" + url.Values{"values[type]": {typ}}.Encode()
|
|
}
|
|
return path
|
|
}
|
|
|
|
// TestCompanionsDependentSmoke checks the D-08 binding end to end: the
|
|
// reference dropdown of the links form lists the references of the menu item
|
|
// type chosen in its type sibling, served from the registry with depths.
|
|
func TestCompanionsDependentSmoke(t *testing.T) {
|
|
e := newCompanionsEnv(t)
|
|
decode := func(t *testing.T, field, typ string) cabana.AdminDependentOptionsResult {
|
|
t.Helper()
|
|
rec := e.send(t, http.MethodGet, dependentOptionsPath(field, typ), nil, true)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var body cabana.Envelope[cabana.AdminDependentOptionsResult]
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return body.Data
|
|
}
|
|
page := decode(t, "reference", "acme-page")
|
|
if !page.Applies || len(page.Options) != 2 || page.Options[0] != (cabana.AdminDependentOption{Value: "1", Label: "Home", Depth: 0}) || page.Options[1] != (cabana.AdminDependentOption{Value: "2", Label: "About", Depth: 1}) {
|
|
t.Fatalf("acme-page options = %+v", page)
|
|
}
|
|
list := decode(t, "reference", "acme-list")
|
|
if list.Applies || list.Options == nil || len(list.Options) != 0 {
|
|
t.Fatalf("acme-list options = %+v", list)
|
|
}
|
|
if none := decode(t, "reference", ""); none.Applies {
|
|
t.Fatalf("no type options = %+v", none)
|
|
}
|
|
if rec := e.send(t, http.MethodGet, dependentOptionsPath("title", "acme-page"), nil, true); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("non-dependent field status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := e.send(t, http.MethodGet, dependentOptionsPath("reference", "acme-page"), nil, false); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("anonymous status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
schema := e.send(t, http.MethodGet, "/acme/companions/links/schema/form", nil, true)
|
|
var form cabana.Envelope[cabana.FormView]
|
|
if err := json.Unmarshal(schema.Body.Bytes(), &form); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found := false
|
|
for _, field := range form.Data.Fields {
|
|
if field.Name == "reference" {
|
|
found = field.DependsOn == "type" && len(field.Options) == 0
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("form schema does not carry dependsOn: %s", schema.Body.String())
|
|
}
|
|
}
|
|
|
|
// companionsRecord is a create or update answer of the links controller.
|
|
type companionsRecord struct {
|
|
Data map[string]any `json:"data"`
|
|
Meta struct {
|
|
Notices []cabana.FieldNotice `json:"notices"`
|
|
} `json:"meta"`
|
|
}
|
|
|
|
func decodeCompanionsRecord(t *testing.T, raw []byte) companionsRecord {
|
|
t.Helper()
|
|
var rec companionsRecord
|
|
if err := json.Unmarshal(raw, &rec); err != nil {
|
|
t.Fatalf("record: %v\n%s", err, raw)
|
|
}
|
|
return rec
|
|
}
|
|
|
|
// TestCompanionsRichEditorSmoke checks the D-10 save gate: a richeditor value
|
|
// is stored sanitized with notices naming what was removed, clean HTML is
|
|
// stored byte for byte with no notice, and a value that is not a string is a
|
|
// 422 on the field.
|
|
func TestCompanionsRichEditorSmoke(t *testing.T) {
|
|
e := newCompanionsEnv(t)
|
|
dirty := `<p onclick="steal()">Answer</p><script>alert(1)</script>`
|
|
rec := e.send(t, http.MethodPost, "/acme/companions/links", map[string]any{"title": "Dirty", "description": dirty}, true)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
created := decodeCompanionsRecord(t, rec.Body.Bytes())
|
|
id := dataID(t, rec.Body.Bytes())
|
|
var stored companionsLink
|
|
if err := e.db.First(&stored, id).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stored.Description == nil || *stored.Description != "<p>Answer</p>" {
|
|
t.Fatalf("stored description = %v", stored.Description)
|
|
}
|
|
var fields []string
|
|
for _, n := range created.Meta.Notices {
|
|
fields = append(fields, n.Field)
|
|
if n.Field != "description" || n.Message == "" {
|
|
t.Fatalf("notice = %+v", n)
|
|
}
|
|
}
|
|
if len(fields) != 2 {
|
|
t.Fatalf("notices = %+v, want removed elements and attributes on description", created.Meta.Notices)
|
|
}
|
|
|
|
clean := "<p>It's <strong>fine</strong> & \"quoted\"</p>\n<ul><li>one</li></ul>"
|
|
rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/companions/links/%d", id), map[string]any{"title": "Clean", "description": clean}, true)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if updated := decodeCompanionsRecord(t, rec.Body.Bytes()); len(updated.Meta.Notices) != 0 {
|
|
t.Fatalf("clean HTML reported notices: %+v", updated.Meta.Notices)
|
|
}
|
|
if err := e.db.First(&stored, id).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stored.Description == nil || *stored.Description != clean {
|
|
t.Fatalf("clean description not stored byte for byte: %v", stored.Description)
|
|
}
|
|
|
|
rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/companions/links/%d", id), map[string]any{"title": "Clean", "description": []any{"<p>x</p>"}}, true)
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"description"`) {
|
|
t.Fatalf("non-string description status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCompanionsRepeaterSmoke checks the D-07 repeater and the D-08 save
|
|
// recheck: rows keep only their nested fields, required applies per row,
|
|
// nested richeditor values are sanitized, and a dependent value outside its
|
|
// options is a 422, in a row and at the top level.
|
|
func TestCompanionsRepeaterSmoke(t *testing.T) {
|
|
e := newCompanionsEnv(t)
|
|
rows := []any{
|
|
map[string]any{"title": "First", "body": `<p onclick="x()">One</p>`, "type": "acme-page", "reference": "2", "extra": "dropped"},
|
|
map[string]any{"title": "Second", "type": "acme-list", "reference": "1"},
|
|
}
|
|
rec := e.send(t, http.MethodPost, "/acme/companions/links", map[string]any{"title": "Rows", "type": "acme-page", "reference": "1", "rows": rows}, true)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
created := decodeCompanionsRecord(t, rec.Body.Bytes())
|
|
id := dataID(t, rec.Body.Bytes())
|
|
var stored companionsLink
|
|
if err := e.db.First(&stored, id).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := stored.Rows.Get()
|
|
if len(got) != 2 {
|
|
t.Fatalf("stored rows = %#v", got)
|
|
}
|
|
for _, row := range got {
|
|
if _, extra := row["extra"]; extra || len(row) != 4 {
|
|
t.Fatalf("row keeps keys outside the nested form: %#v", row)
|
|
}
|
|
}
|
|
if got[0]["body"] != "<p>One</p>" || got[0]["reference"] != "2" || got[1]["reference"] != nil {
|
|
t.Fatalf("stored rows = %#v (body sanitized, reference kept, reference of a type without references null)", got)
|
|
}
|
|
noticed := false
|
|
for _, n := range created.Meta.Notices {
|
|
noticed = noticed || n.Field == "rows.0.body"
|
|
}
|
|
if !noticed {
|
|
t.Fatalf("no notice under rows.0.body: %+v", created.Meta.Notices)
|
|
}
|
|
if list, ok := created.Data["rows"].([]any); !ok || len(list) != 2 {
|
|
t.Fatalf("record response rows = %#v", created.Data["rows"])
|
|
}
|
|
|
|
expect422 := func(t *testing.T, body map[string]any, key string) {
|
|
t.Helper()
|
|
rec := e.send(t, http.MethodPut, fmt.Sprintf("/acme/companions/links/%d", id), body, true)
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+key+`"`) {
|
|
t.Fatalf("want 422 on %s, status=%d body=%s", key, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
expect422(t, map[string]any{"title": "Rows", "rows": []any{map[string]any{"title": "ok"}, map[string]any{"body": "<p>x</p>"}}}, "rows.1.title")
|
|
expect422(t, map[string]any{"title": "Rows", "rows": []any{map[string]any{"title": "ok", "type": "acme-page", "reference": "99"}}}, "rows.0.reference")
|
|
rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/companions/links/%d", id), map[string]any{"title": "Rows", "type": "acme-page", "reference": "99"}, true)
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"reference":["Choose one of the listed options."]`) && !strings.Contains(rec.Body.String(), `"reference":["Wybierz jedną z dostępnych opcji."]`) {
|
|
t.Fatalf("top-level dependent value status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
expect422(t, map[string]any{"title": "Rows", "rows": "not a list"}, "rows")
|
|
six := make([]any, 6)
|
|
for i := range six {
|
|
six[i] = map[string]any{"title": fmt.Sprint(i)}
|
|
}
|
|
expect422(t, map[string]any{"title": "Rows", "rows": six}, "rows")
|
|
|
|
shown := e.send(t, http.MethodGet, fmt.Sprintf("/acme/companions/links/%d", id), nil, true)
|
|
if shown.Code != http.StatusOK || !strings.Contains(shown.Body.String(), `"rows":[{`) {
|
|
t.Fatalf("show rows status=%d body=%s", shown.Code, shown.Body.String())
|
|
}
|
|
opts := e.send(t, http.MethodGet, dependentOptionsPath("rows.reference", "acme-page"), nil, true)
|
|
if opts.Code != http.StatusOK || !strings.Contains(opts.Body.String(), `"applies":true`) {
|
|
t.Fatalf("nested dependent options status=%d body=%s", opts.Code, opts.Body.String())
|
|
}
|
|
}
|
|
|
|
// reorderTree is the decoded data of the links reorder routes.
|
|
func reorderTree(t *testing.T, rec *httptest.ResponseRecorder) cabana.AdminReorderTree {
|
|
t.Helper()
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("reorder status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var body cabana.Envelope[cabana.AdminReorderTree]
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return body.Data
|
|
}
|
|
|
|
func reorderNode(id uint, parent *uint, sort int64) map[string]any {
|
|
node := map[string]any{"id": id, "parent_id": nil, "sort_order": sort}
|
|
if parent != nil {
|
|
node["parent_id"] = *parent
|
|
}
|
|
return node
|
|
}
|
|
|
|
// TestCompanionsReorderSmoke checks the D-09 tree reorder: the GET lists only
|
|
// the partition's records, a save nests a record and writes both columns, and
|
|
// a stale set (a record left out, or one of another partition), a cycle and
|
|
// a missing partition are refused without writing.
|
|
func TestCompanionsReorderSmoke(t *testing.T) {
|
|
e := newCompanionsEnv(t)
|
|
one, two := companionsGroup{Title: "One"}, companionsGroup{Title: "Two"}
|
|
if err := e.db.Create(&one).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := e.db.Create(&two).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
links := []companionsLink{
|
|
{Title: "A", GroupID: &one.ID, SortOrder: 1},
|
|
{Title: "B", GroupID: &one.ID, SortOrder: 2},
|
|
{Title: "C", GroupID: &one.ID, SortOrder: 3},
|
|
{Title: "D", GroupID: &two.ID, SortOrder: 1},
|
|
}
|
|
for i := range links {
|
|
if err := e.db.Create(&links[i]).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
a, b, c, d := links[0].ID, links[1].ID, links[2].ID, links[3].ID
|
|
path := fmt.Sprintf("/acme/companions/links/reorder?partition=%d", one.ID)
|
|
tree := reorderTree(t, e.send(t, http.MethodGet, path, nil, true))
|
|
if tree.Partition == nil || tree.Partition.Value == nil || *tree.Partition.Value != one.ID || len(tree.Partition.Options) != 2 || len(tree.Nodes) != 3 || tree.Nodes[0].Name != "A" {
|
|
t.Fatalf("tree = %+v", tree)
|
|
}
|
|
|
|
save := func(nodes ...map[string]any) *httptest.ResponseRecorder {
|
|
return e.send(t, http.MethodPost, "/acme/companions/links/reorder", map[string]any{"partition": one.ID, "nodes": nodes}, true)
|
|
}
|
|
reorderTree(t, save(reorderNode(a, nil, 1), reorderNode(b, &a, 1), reorderNode(c, nil, 2)))
|
|
var storedB, storedC companionsLink
|
|
if err := e.db.First(&storedB, b).Error; err != nil || storedB.ParentID == nil || *storedB.ParentID != a || storedB.SortOrder != 1 {
|
|
t.Fatalf("b = %+v (%v), want parent a and sort 1", storedB, err)
|
|
}
|
|
if err := e.db.First(&storedC, c).Error; err != nil || storedC.ParentID != nil || storedC.SortOrder != 2 {
|
|
t.Fatalf("c = %+v (%v), want top level and sort 2", storedC, err)
|
|
}
|
|
|
|
unchanged := func(t *testing.T) {
|
|
t.Helper()
|
|
var again companionsLink
|
|
if err := e.db.First(&again, b).Error; err != nil || again.ParentID == nil || *again.ParentID != a || again.SortOrder != 1 {
|
|
t.Fatalf("a refused save wrote b: %+v", again)
|
|
}
|
|
}
|
|
for name, rec := range map[string]*httptest.ResponseRecorder{
|
|
"omitted node": save(reorderNode(a, nil, 1), reorderNode(b, nil, 2)),
|
|
"node of a partition": save(reorderNode(a, nil, 1), reorderNode(b, nil, 2), reorderNode(d, nil, 3)),
|
|
} {
|
|
if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), `"code":"reorder_stale"`) {
|
|
t.Fatalf("%s status=%d body=%s", name, rec.Code, rec.Body.String())
|
|
}
|
|
unchanged(t)
|
|
}
|
|
cycle := save(reorderNode(a, &b, 1), reorderNode(b, &a, 1), reorderNode(c, nil, 2))
|
|
if cycle.Code != http.StatusUnprocessableEntity || !strings.Contains(cycle.Body.String(), `"nodes"`) {
|
|
t.Fatalf("cycle status=%d body=%s", cycle.Code, cycle.Body.String())
|
|
}
|
|
unchanged(t)
|
|
missing := e.send(t, http.MethodPost, "/acme/companions/links/reorder", map[string]any{"nodes": []any{}}, true)
|
|
if missing.Code != http.StatusUnprocessableEntity || !strings.Contains(missing.Body.String(), `"partition"`) {
|
|
t.Fatalf("missing partition status=%d body=%s", missing.Code, missing.Body.String())
|
|
}
|
|
schema := e.send(t, http.MethodGet, "/acme/companions/links/schema/list", nil, true)
|
|
if !strings.Contains(schema.Body.String(), `"reorder":{"title":"Reorder links","partition":{"column":"group_id","label":"Group"}}`) {
|
|
t.Fatalf("list schema reorder = %s", schema.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCompanionsSingletonSmoke checks the D-11 singleton controller: the
|
|
// first GET creates the record, the second answers the same id, and the
|
|
// list, create and delete routes answer 404.
|
|
func TestCompanionsSingletonSmoke(t *testing.T) {
|
|
e := newCompanionsEnv(t)
|
|
first := e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true)
|
|
id := dataID(t, first.Body.Bytes())
|
|
second := e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true)
|
|
if again := dataID(t, second.Body.Bytes()); again != id {
|
|
t.Fatalf("second id = %d, want %d", again, id)
|
|
}
|
|
var count int64
|
|
if err := e.db.Model(&companionsProfile{}).Count(&count).Error; err != nil || count != 1 {
|
|
t.Fatalf("profiles = %d (%v)", count, err)
|
|
}
|
|
for _, rec := range []*httptest.ResponseRecorder{
|
|
e.send(t, http.MethodGet, "/acme/companions/profiles", nil, true),
|
|
e.send(t, http.MethodDelete, fmt.Sprintf("/acme/companions/profiles/%d", id), nil, true),
|
|
e.send(t, http.MethodPost, "/acme/companions/profiles", map[string]any{"title": "x"}, true),
|
|
} {
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("singleton route status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
if rec := e.send(t, http.MethodPut, fmt.Sprintf("/acme/companions/profiles/%d", id), map[string]any{"title": "Renamed"}, true); rec.Code != http.StatusOK {
|
|
t.Fatalf("singleton update status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
schema := e.send(t, http.MethodGet, "/acme/companions/profiles/schema/form", nil, true)
|
|
if !strings.Contains(schema.Body.String(), `"singleton":true`) {
|
|
t.Fatalf("form schema = %s", schema.Body.String())
|
|
}
|
|
if rec := e.send(t, http.MethodGet, "/acme/companions/links/singleton", nil, true); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("non-singleton status=%d", rec.Code)
|
|
}
|
|
}
|