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 := `

Answer

` 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 != "

Answer

" { 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 := "

It's fine & \"quoted\"

\n" 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{"

x

"}}, 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": `

One

`, "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"] != "

One

" || 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": "

x

"}}}, "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) } }