package cabana_test import ( "bytes" "context" "encoding/json" "errors" "fmt" "io/fs" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "sync" "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/lagoon/attach" "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/surf" "gocloud.dev/blob" "gocloud.dev/blob/memblob" "gorm.io/gorm" ) // The acme.deferred fixture plugin backs the Phase 12.2 unit, integration // and security tests. It is defined only in this test file, with its YAML // tree under testdata/deferred, and is never registered by an application. // // Controllers: // - acme.deferred.gadgets: datepicker fields (date, datetime, time), a // public attachMany `photos` (image, maxFiles 3) and a protected // attachOne `manual` (file mode), a belongsToMany `members` relation // with manage and pivot forms (RelationBeforeLink stamps `role`), and a // deferrable hasMany `parts` relation whose manage form has a fileupload // and a datepicker field. Every toolbar button is declared. // - acme.deferred.locked: the same model and relations with a reduced // toolbar (parts: link; members: unlink) and a required fileupload. // // FormExtendQuery hides gadgets whose `hidden` column is true. Every Form // and Relation hook records its calls in the env's recorder, which can also // make a named hook fail. const ( dfGadgetMorph = "acme.deferred.gadget" dfPartMorph = "acme.deferred.part" ) type dfGadget struct { ID uint `gorm:"column:id;primaryKey"` Name string `gorm:"column:name"` Hidden bool `gorm:"column:hidden;not null;default:false"` ReleasedOn lagoon.Date `gorm:"column:released_on;type:date"` StartsAt *time.Time `gorm:"column:starts_at"` OpensAt lagoon.TimeOfDay `gorm:"column:opens_at;type:time without time zone"` Members []dfMember `gorm:"-"` Parts []dfPart `gorm:"-"` // StrictParts backs a test-only hasMany on the NOT NULL dfStrictPart. StrictParts []dfStrictPart `gorm:"-"` CreatedAt time.Time `gorm:"column:created_at"` UpdatedAt time.Time `gorm:"column:updated_at"` } func (dfGadget) TableName() string { return "cabana_deferred_gadgets" } func (dfGadget) MorphName() string { return dfGadgetMorph } func (dfGadget) AttachRelations() []attach.Relation { return []attach.Relation{{Name: "photos", Many: true, Public: true}, {Name: "manual"}} } func (dfGadget) Fillable() []string { return []string{"name", "released_on", "starts_at", "opens_at"} } func (dfGadget) Rules() map[string]string { return map[string]string{"name": "required"} } // dfPart is the hasMany child; gadget_id is nullable so the relation is // deferrable, and DeletedAt makes a relation delete a soft delete. type dfPart struct { ID uint `gorm:"column:id;primaryKey"` GadgetID *uint `gorm:"column:gadget_id"` Label string `gorm:"column:label"` DueOn *lagoon.Date `gorm:"column:due_on;type:date"` CreatedAt time.Time `gorm:"column:created_at"` UpdatedAt time.Time `gorm:"column:updated_at"` DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index"` } func (dfPart) TableName() string { return "cabana_deferred_parts" } func (dfPart) MorphName() string { return dfPartMorph } func (dfPart) Fillable() []string { return []string{"label", "due_on"} } func (dfPart) Rules() map[string]string { return map[string]string{"label": "required"} } func (dfPart) AttachRelations() []attach.Relation { return []attach.Relation{{Name: "images", Many: true}, {Name: "sheet"}} } // dfPartHooks records the part model's delete hook calls; a relation // delete must go through the model so the hook runs. var dfPartHooks dfRecorder // BeforeDelete is the part's model hook. func (p *dfPart) BeforeDelete(*gorm.DB) error { dfPartHooks.add(fmt.Sprintf("BeforeDelete:%d", p.ID)) return nil } type dfMember struct { ID uint `gorm:"column:id;primaryKey"` Email string `gorm:"column:email"` } func (dfMember) TableName() string { return "cabana_deferred_members" } func (dfMember) Fillable() []string { return []string{"email"} } func (dfMember) Rules() map[string]string { return map[string]string{"email": "required"} } // dfGadgetMember is the members pivot: note comes from the pivot form, role // is the hook column RelationBeforeLink stamps. type dfGadgetMember struct { ID uint `gorm:"column:id;primaryKey"` GadgetID uint `gorm:"column:gadget_id"` MemberID uint `gorm:"column:member_id"` Note string `gorm:"column:note"` Role string `gorm:"column:role"` CreatedAt time.Time `gorm:"column:created_at"` } func (dfGadgetMember) TableName() string { return "cabana_deferred_gadget_members" } // dfRecorder records hook calls and can make a named hook fail. type dfRecorder struct { mu sync.Mutex calls []string fail map[string]bool // probe, when set, runs inside the Form hooks with the write's // transaction and its result is recorded after the hook name. probe func(tx *gorm.DB) string } func (r *dfRecorder) add(call string) { r.mu.Lock() defer r.mu.Unlock() r.calls = append(r.calls, call) } func (r *dfRecorder) reset() { r.mu.Lock() defer r.mu.Unlock() r.calls = nil r.fail = map[string]bool{} r.probe = nil } func (r *dfRecorder) failOn(name string) { r.mu.Lock() defer r.mu.Unlock() r.fail[name] = true } func (r *dfRecorder) snapshot() []string { r.mu.Lock() defer r.mu.Unlock() return append([]string(nil), r.calls...) } // hook records name (with the probe's result) and fails when asked to. func (r *dfRecorder) hook(ctx context.Context, name string) error { r.mu.Lock() probe := r.probe fail := r.fail[name] r.mu.Unlock() call := name if probe != nil { if tx, ok := cabana.TxFromContext(ctx); ok { call += ":" + probe(tx) } } r.add(call) if fail { return errors.New("fixture hook " + name + " failed") } return nil } // dfPlugin is the fixture plugin. The zero options give the testdata tree, // the gadgets and locked controllers and the full Models() list; a test // may replace the tree (dfOverlay), the controllers or the models. type dfPlugin struct { rec *dfRecorder fsys fs.FS controllers func(rec *dfRecorder) []pact.AdminController noModels bool } func (dfPlugin) ID() string { return "acme.deferred" } func (dfPlugin) Requires() []string { return nil } func (dfPlugin) Register(*backpack.App) error { return nil } func (dfPlugin) Boot(*backpack.App) error { return nil } func (p dfPlugin) AdminControllers() []pact.AdminController { if p.controllers != nil { return p.controllers(p.rec) } return []pact.AdminController{ dfController{rec: p.rec, id: "acme.deferred.gadgets", dir: "controllers/gadgets"}, dfController{rec: p.rec, id: "acme.deferred.locked", dir: "controllers/locked"}, } } // Models lists every fixture model deferred:purge may delete. func (p dfPlugin) Models() []any { if p.noModels { return nil } return []any{&dfGadget{}, &dfPart{}, &dfMember{}} } func (dfPlugin) Permissions() []pact.Permission { return []pact.Permission{{Code: "acme.deferred.access", Roles: []string{"developer"}}} } func (dfPlugin) Navigation() []pact.NavigationItem { return []pact.NavigationItem{{Code: "deferred", Label: "Deferred", Icon: "box", Order: 10, Controller: "acme.deferred.gadgets", Permissions: []string{"acme.deferred.access"}}} } // AdminFS is the YAML tree under testdata/deferred, or the test's overlay. func (p dfPlugin) AdminFS() fs.FS { if p.fsys != nil { return p.fsys } return os.DirFS(filepath.Join("testdata", "deferred")) } // dfOverlay is the testdata tree with files replaced; an empty value // removes the file. func dfOverlay(t *testing.T, files map[string]string) fs.FS { t.Helper() base := os.DirFS(filepath.Join("testdata", "deferred")) out := fstest.MapFS{} err := fs.WalkDir(base, ".", func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } data, err := fs.ReadFile(base, path) if err != nil { return err } out[path] = &fstest.MapFile{Data: data} return nil }) if err != nil { t.Fatal(err) } for name, data := range files { if data == "" { delete(out, name) continue } out[name] = &fstest.MapFile{Data: []byte(data)} } return out } // dfFile reads one testdata file, for a test that edits it. func dfFile(t *testing.T, name string) string { t.Helper() raw, err := os.ReadFile(filepath.Join("testdata", "deferred", filepath.FromSlash(name))) if err != nil { t.Fatal(err) } return string(raw) } // activateDeferred boots cabana with the fixture plugin p (no database) // and returns the boot error. func activateDeferred(t *testing.T, p dfPlugin, config map[string]any) error { t.Helper() dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-deferred-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) } for k, v := range config { if err := cfg.Set(k, v); err != nil { t.Fatal(err) } } if p.rec == nil { p.rec = &dfRecorder{fail: map[string]bool{}} } _, err = cabana.Activate(backpack.New(cfg), []party.Plugin{p}) return err } type dfController struct { rec *dfRecorder id string dir string // contracts, when set, replaces the relation contracts. contracts func() []cabana.RelationContract } func (c dfController) ID() string { return c.id } func (dfController) ModelName() string { return "Gadget" } func (c dfController) ConfigDir() string { return c.dir } func (dfController) RequiredPermissions() []string { return []string{"acme.deferred.access"} } func (dfController) NewRecord() any { return &dfGadget{} } func (c dfController) AdminRelationContracts() []cabana.RelationContract { if c.contracts != nil { return c.contracts() } return dfContracts() } // dfContracts are the fixture's relation contracts: members (belongsToMany // by the empty Kind) and parts (hasMany on the nullable gadget_id). func dfContracts() []cabana.RelationContract { return []cabana.RelationContract{{ Name: "members", NewRelated: func() any { return &dfMember{} }, NewPivot: func() any { return &dfGadgetMember{} }, ParentForeignKey: "gadget_id", RelatedForeignKey: "member_id", Columns: map[string]string{"email": "email"}, HookPivotColumns: []string{"role"}, }, { Name: "parts", Kind: cabana.RelationHasMany, NewRelated: func() any { return &dfPart{} }, ForeignKey: "gadget_id", Columns: map[string]string{"label": "label"}, }} } // AdminActions registers one widget action, so a relation form's widget // field reaches the D-23 refusal instead of failing on its action. func (dfController) AdminActions() []pact.AdminAction { return []pact.AdminAction{{Name: "lookup", Label: "Look up", Permissions: []string{"acme.deferred.access"}, Run: func(context.Context, pact.AdminActionInput) (pact.AdminActionResult, error) { return pact.AdminActionResult{}, nil }}} } // FormExtendQuery hides gadgets marked hidden. func (dfController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB { return db.Where("hidden = ?", false) } // RelationBeforeLink stamps the server-owned role of a members link. func (c dfController) RelationBeforeLink(ctx context.Context, relation string, _, _ any, pivot map[string]any) error { if relation == "members" { pivot["role"] = "linked" } return c.rec.hook(ctx, "RelationBeforeLink:"+relation) } func (c dfController) FormBeforeCreate(ctx context.Context, _ any) error { return c.rec.hook(ctx, "FormBeforeCreate") } func (c dfController) FormAfterCreate(ctx context.Context, _ any) error { return c.rec.hook(ctx, "FormAfterCreate") } func (c dfController) FormBeforeUpdate(ctx context.Context, _ any) error { return c.rec.hook(ctx, "FormBeforeUpdate") } func (c dfController) FormAfterUpdate(ctx context.Context, _ any) error { return c.rec.hook(ctx, "FormAfterUpdate") } func (c dfController) RelationBeforeCreate(ctx context.Context, relation string, _, _ any) error { return c.rec.hook(ctx, "RelationBeforeCreate:"+relation) } func (c dfController) RelationAfterCreate(ctx context.Context, relation string, _, _ any) error { return c.rec.hook(ctx, "RelationAfterCreate:"+relation) } func (c dfController) RelationBeforeUpdate(ctx context.Context, relation string, _, _ any) error { return c.rec.hook(ctx, "RelationBeforeUpdate:"+relation) } func (c dfController) RelationAfterUpdate(ctx context.Context, relation string, _, _ any) error { return c.rec.hook(ctx, "RelationAfterUpdate:"+relation) } func (c dfController) RelationBeforeDelete(ctx context.Context, relation string, _, _ any) error { return c.rec.hook(ctx, "RelationBeforeDelete:"+relation) } func (c dfController) RelationAfterDelete(ctx context.Context, relation string, _, _ any) error { return c.rec.hook(ctx, "RelationAfterDelete:"+relation) } // dfEnv is the assembled router over the acme.deferred fixture on the // cabana Postgres harness, with two admins (A and B) holding the // controller permission. type dfEnv struct { h http.Handler db *gorm.DB bucket *blob.Bucket rec *dfRecorder stamp string a, b dfClient } // dfClient sends requests as one admin. type dfClient struct { env *dfEnv id uint token string } var dfModels = []any{&dfGadget{}, &dfPart{}, &dfMember{}, &dfGadgetMember{}} func newDeferredEnv(t *testing.T) *dfEnv { t.Helper() return newDeferredEnvWith(t, dfPlugin{}) } // newDeferredEnvWith assembles the router over the plugin p (its recorder // is replaced by the env's). func newDeferredEnvWith(t *testing.T, p dfPlugin) *dfEnv { t.Helper() gdb := adminGorm(t) if err := gdb.Migrator().DropTable(dfModels...); err != nil { t.Fatal(err) } if err := gdb.AutoMigrate(dfModels...); err != nil { t.Fatal(err) } // Ids restart with the recreated tables: drop the files and bindings an // earlier run left for them. if err := gdb.Exec(`DELETE FROM system_files WHERE attachment_type IN (?, ?) OR attachment_id IS NULL OR attachment_id = ''`, dfGadgetMorph, dfPartMorph).Error; err != nil { t.Fatal(err) } if err := gdb.Exec(`DELETE FROM deferred_bindings WHERE master_type IN (?, ?)`, dfGadgetMorph, dfPartMorph).Error; err != nil { t.Fatal(err) } stamp := fmt.Sprintf("d%d", time.Now().UnixNano()) dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-deferred\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) } bucket := memblob.OpenBucket(nil) t.Cleanup(func() { _ = bucket.Close() }) if err := attach.Publish(app, bucket); err != nil { t.Fatal(err) } rec := &dfRecorder{fail: map[string]bool{}} p.rec = rec plugins := []party.Plugin{p} if err := phrasebook.Activate(app, plugins); err != nil { t.Fatal(err) } h, err := surf.Assemble(app, plugins) if err != nil { t.Fatal(err) } env := &dfEnv{h: h, db: gdb, bucket: bucket, rec: rec, stamp: stamp} env.a = env.login(t, "dfa-"+stamp) env.b = env.login(t, "dfb-"+stamp) return env } // login inserts an admin and logs it in. func (e *dfEnv) login(t *testing.T, login string) dfClient { t.Helper() user := insertAdmin(t, e.db, login, login+"@example.test", adminTestPassword, true, false) raw, _ := json.Marshal(map[string]string{"login": login, "password": adminTestPassword}) req := httptest.NewRequest(http.MethodPost, adminAPI("/auth/login"), bytes.NewReader(raw)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() e.h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("login %s status=%d body=%s", login, rec.Code, rec.Body.String()) } return dfClient{env: e, id: user.ID, token: accessToken(t, rec.Body.Bytes())} } // do sends a request as the client. body is nil, raw []byte (with // contentType from headers["Content-Type"]) or a value sent as JSON. func (c dfClient) do(t *testing.T, method, rel string, body any, headers map[string]string) *httptest.ResponseRecorder { t.Helper() var payload []byte contentType := "" switch b := body.(type) { case nil: case []byte: payload = b default: raw, err := json.Marshal(b) if err != nil { t.Fatal(err) } payload = raw contentType = "application/json" } req := httptest.NewRequest(method, adminAPI(rel), bytes.NewReader(payload)) if contentType != "" { req.Header.Set("Content-Type", contentType) } req.Header.Set("Accept-Language", "en") req.Header.Set("Authorization", "Bearer "+c.token) for k, v := range headers { req.Header.Set(k, v) } rec := httptest.NewRecorder() c.env.h.ServeHTTP(rec, req) return rec } // upload posts one multipart file_data part to rel. func (c dfClient) upload(t *testing.T, rel, name string, data []byte, headers map[string]string) *httptest.ResponseRecorder { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) part, err := mw.CreateFormFile("file_data", name) if err != nil { t.Fatal(err) } if _, err := part.Write(data); err != nil { t.Fatal(err) } if err := mw.Close(); err != nil { t.Fatal(err) } h := map[string]string{"Content-Type": mw.FormDataContentType()} for k, v := range headers { h[k] = v } return c.do(t, http.MethodPost, rel, buf.Bytes(), h) } // dfPath is a gadgets controller path: gadget id and the rest. func dfPath(gadget uint, rest string) string { return fmt.Sprintf("/acme/deferred/gadgets/%d%s", gadget, rest) } // dfLockedPath is a locked controller path. func dfLockedPath(gadget uint, rest string) string { return fmt.Sprintf("/acme/deferred/locked/%d%s", gadget, rest) } // sk is the X-Session-Key header map; ck adds X-Child-Session-Key. func sk(key string) map[string]string { return map[string]string{cabana.SessionKeyHeader: key} } func ck(key string) map[string]string { return map[string]string{cabana.ChildSessionKeyHeader: key} } // want fails the test unless rec has the status. func want(t *testing.T, what string, rec *httptest.ResponseRecorder, status int) { t.Helper() if rec.Code != status { t.Fatalf("%s: status=%d want %d body=%s", what, rec.Code, status, rec.Body.String()) } } // errorCode is the error envelope's code. func errorCode(t *testing.T, rec *httptest.ResponseRecorder) string { t.Helper() var body struct { Error struct { Code string `json:"code"` } `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("error envelope: %v\n%s", err, rec.Body.String()) } return body.Error.Code } // errorDetails is the error envelope's details. func errorDetails(t *testing.T, rec *httptest.ResponseRecorder) map[string][]string { t.Helper() var body struct { Error struct { Details map[string][]string `json:"details"` } `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("error envelope: %v\n%s", err, rec.Body.String()) } return body.Error.Details } // gadget inserts a gadget straight into the table. func (e *dfEnv) gadget(t *testing.T, name string, hidden bool) uint { t.Helper() g := dfGadget{Name: name, Hidden: hidden} if err := e.db.Create(&g).Error; err != nil { t.Fatal(err) } if hidden { if err := e.db.Model(&g).Update("hidden", true).Error; err != nil { t.Fatal(err) } } return g.ID } // member inserts a member straight into the table. func (e *dfEnv) member(t *testing.T, email string) uint { t.Helper() m := dfMember{Email: email} if err := e.db.Create(&m).Error; err != nil { t.Fatal(err) } return m.ID } // part inserts a part owned by gadget (0 for none) straight into the table. func (e *dfEnv) part(t *testing.T, gadget uint, label string) uint { t.Helper() p := dfPart{Label: label} if gadget > 0 { p.GadgetID = &gadget } if err := e.db.Create(&p).Error; err != nil { t.Fatal(err) } return p.ID } // pivot links a member to a gadget straight in the pivot table. func (e *dfEnv) pivot(t *testing.T, gadget, member uint, note string) { t.Helper() if err := e.db.Create(&dfGadgetMember{GadgetID: gadget, MemberID: member, Note: note, Role: "seed"}).Error; err != nil { t.Fatal(err) } } // partRow reads a part, soft-deleted rows included; ok is false when the // row is gone. func (e *dfEnv) partRow(t *testing.T, id uint) (dfPart, bool) { t.Helper() var p dfPart err := e.db.Unscoped().Where("id = ?", id).Take(&p).Error if errors.Is(err, gorm.ErrRecordNotFound) { return dfPart{}, false } if err != nil { t.Fatal(err) } return p, true } // storeFile stores data as a file of the owner (attached when ownerID is // set) through attach.Store, bypassing the routes. func (e *dfEnv) storeFile(t *testing.T, morph string, ownerID uint, field, name string, data []byte, public bool) attach.File { t.Helper() f, err := attach.Store(context.Background(), e.db, e.bucket, attach.Upload{FileName: name, Body: bytes.NewReader(data), Public: public}, attach.Limits{Extensions: []string{"png", "gif", "jpg", "webp", "svg", "html", "txt", "pdf"}}) if err != nil { t.Fatalf("store %s: %v", name, err) } if ownerID > 0 { if err := e.db.Model(&attach.File{}).Where("id = ?", f.ID). Updates(map[string]any{"attachment_type": morph, "attachment_id": fmt.Sprint(ownerID), "field": field}).Error; err != nil { t.Fatal(err) } } var out attach.File if err := e.db.Where("id = ?", f.ID).Take(&out).Error; err != nil { t.Fatal(err) } return out } // dfState is a snapshot of every fixture table, the files and the // bindings, compared before and after a refused request. type dfState struct { Gadgets []dfGadget Parts []dfPart Members []dfMember Pivots []dfGadgetMember Files []attach.File Bindings []lagoon.DeferredBinding Blobs int } func (e *dfEnv) state(t *testing.T) dfState { t.Helper() var s dfState for _, q := range []struct { dest any order string }{{&s.Gadgets, "id"}, {&s.Members, "id"}, {&s.Pivots, "id"}} { if err := e.db.Order(q.order).Find(q.dest).Error; err != nil { t.Fatal(err) } } // Other tests share the database: only the fixture's files (and the // unattached ones, cleared when the env starts) and bindings count. if err := e.db.Where("attachment_type IN ? OR attachment_id IS NULL OR attachment_id = ''", []string{dfGadgetMorph, dfPartMorph}).Order("id").Find(&s.Files).Error; err != nil { t.Fatal(err) } if err := e.db.Where("master_type IN ?", []string{dfGadgetMorph, dfPartMorph}).Order("id").Find(&s.Bindings).Error; err != nil { t.Fatal(err) } if err := e.db.Unscoped().Order("id").Find(&s.Parts).Error; err != nil { t.Fatal(err) } iter := e.bucket.List(nil) for { if _, err := iter.Next(context.Background()); err != nil { break } s.Blobs++ } return s } // unchanged fails unless the state equals before. func (e *dfEnv) unchanged(t *testing.T, what string, before dfState) { t.Helper() after := e.state(t) a, _ := json.Marshal(before) b, _ := json.Marshal(after) if !bytes.Equal(a, b) { t.Fatalf("%s changed the database:\nbefore %s\nafter %s", what, a, b) } } // dfIDs lists the data[].id values of a list response. func dfIDs(t *testing.T, rec *httptest.ResponseRecorder) []uint { t.Helper() if rec.Code != http.StatusOK { t.Fatalf("list status=%d body=%s", rec.Code, rec.Body.String()) } var body struct { Data []struct { ID uint `json:"id"` } `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } out := make([]uint, 0, len(body.Data)) for _, row := range body.Data { out = append(out, row.ID) } return out }