Files
summercms/modules/cabana/companions_smoke_test.go
Jakub Zych 23f33c3911 feat(17-01): tree reorder page and singleton admin controllers
- 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
2026-10-12 15:35:43 +02:00

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> &amp; \"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)
}
}