test(09-05): add failing tests for writable fill and validate
- Create and update must Fill then Validate and return D-10 422 field errors - Schema bindings exclude protected, cased, nested, and unknown keys - Missing Fill or Validate capability fails closed with controller context
This commit is contained in:
@@ -56,12 +56,20 @@ func (BackendUser) TableName() string { return "backend_users" }
|
|||||||
// Option is a dropdown choice shared with later schema plans.
|
// Option is a dropdown choice shared with later schema plans.
|
||||||
type Option = pact.Option
|
type Option = pact.Option
|
||||||
|
|
||||||
|
// WritableField is one schema field bound to a model fill key at activation.
|
||||||
|
// FillKey comes from the model column, not from request JSON.
|
||||||
|
type WritableField struct {
|
||||||
|
Name string
|
||||||
|
FillKey string
|
||||||
|
}
|
||||||
|
|
||||||
// CompiledController is one admin controller after YAML compilation.
|
// CompiledController is one admin controller after YAML compilation.
|
||||||
type CompiledController struct {
|
type CompiledController struct {
|
||||||
PluginID string
|
PluginID string
|
||||||
Controller pact.AdminController
|
Controller pact.AdminController
|
||||||
List *ListSchema
|
List *ListSchema
|
||||||
Form *FormSchema
|
Form *FormSchema
|
||||||
|
Writable []WritableField
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registry is the immutable controller map keyed by controller ID.
|
// Registry is the immutable controller map keyed by controller ID.
|
||||||
|
|||||||
71
cabana/crud.go
Normal file
71
cabana/crud.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CRUDService runs schema-projected record and bulk operations.
|
||||||
|
type CRUDService struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordInput is a decoded JSON object. Keys are untrusted.
|
||||||
|
type RecordInput struct {
|
||||||
|
Body map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkDeleteInput is the bulk-delete body.
|
||||||
|
type BulkDeleteInput struct {
|
||||||
|
IDs []any `json:"ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkResult is the deleted-count payload.
|
||||||
|
type BulkResult struct {
|
||||||
|
Deleted int `json:"deleted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidationError is a D-10 validation_failed failure.
|
||||||
|
type ValidationError struct {
|
||||||
|
Details map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ValidationError) Error() string { return "validation_failed" }
|
||||||
|
|
||||||
|
// CapabilityError is a fail-closed Fill/Validate failure with controller context.
|
||||||
|
type CapabilityError struct {
|
||||||
|
ControllerID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *CapabilityError) Error() string {
|
||||||
|
return "cabana: controller " + e.ControllerID + ": missing Fill/Validate capability"
|
||||||
|
}
|
||||||
|
|
||||||
|
var errCRUDNotReady = errors.New("cabana: crud is not implemented")
|
||||||
|
|
||||||
|
// ProjectWritableFields copies only activation-bound writable keys.
|
||||||
|
func ProjectWritableFields(cc *CompiledController, body map[string]any) map[string]any {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindWritableFields records schema field names onto model fill keys.
|
||||||
|
func BindWritableFields(cc *CompiledController) error {
|
||||||
|
return errCRUDNotReady
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create persists a projected record after Fill and Validate.
|
||||||
|
func (s CRUDService) Create(ctx context.Context, cc *CompiledController, in RecordInput) (map[string]any, error) {
|
||||||
|
return nil, errCRUDNotReady
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update persists a projected change after Fill and Validate.
|
||||||
|
func (s CRUDService) Update(ctx context.Context, cc *CompiledController, id any, in RecordInput) (map[string]any, error) {
|
||||||
|
return nil, errCRUDNotReady
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCRUDError(w http.ResponseWriter, err error) {
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
}
|
||||||
373
cabana/crud_test.go
Normal file
373
cabana/crud_test.go
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const crudFormConfig = `name: records
|
||||||
|
form: ~/plugins/acme/demo/models/record/fields.yaml
|
||||||
|
modelClass: Record
|
||||||
|
`
|
||||||
|
|
||||||
|
const crudFields = `fields:
|
||||||
|
name:
|
||||||
|
label: Name
|
||||||
|
type: text
|
||||||
|
required: true
|
||||||
|
note:
|
||||||
|
label: Note
|
||||||
|
type: text
|
||||||
|
id:
|
||||||
|
label: ID
|
||||||
|
type: number
|
||||||
|
created_at:
|
||||||
|
label: Created
|
||||||
|
type: text
|
||||||
|
scope_id:
|
||||||
|
label: Scope
|
||||||
|
type: number
|
||||||
|
is_system:
|
||||||
|
label: System
|
||||||
|
type: switch
|
||||||
|
`
|
||||||
|
|
||||||
|
const crudListConfig = `modelClass: Record
|
||||||
|
list: ~/plugins/acme/demo/models/record/columns.yaml
|
||||||
|
recordsPerPage: 20
|
||||||
|
showSearch: true
|
||||||
|
`
|
||||||
|
|
||||||
|
const crudColumns = `columns:
|
||||||
|
name:
|
||||||
|
label: Name
|
||||||
|
searchable: true
|
||||||
|
`
|
||||||
|
|
||||||
|
type clearNameKey struct{}
|
||||||
|
|
||||||
|
var crudRuleSet = map[string]string{"name": "required"}
|
||||||
|
|
||||||
|
type crudRow struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
Note string `gorm:"column:note"`
|
||||||
|
ScopeID uint `gorm:"column:scope_id"`
|
||||||
|
IsSystem bool `gorm:"column:is_system"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (crudRow) TableName() string { return "cabana_crud_rows" }
|
||||||
|
|
||||||
|
func (crudRow) Fillable() []string { return []string{"name", "note"} }
|
||||||
|
|
||||||
|
func (crudRow) Rules() map[string]string { return crudRuleSet }
|
||||||
|
|
||||||
|
func (r *crudRow) BeforeValidate(tx *gorm.DB) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
if tx != nil && tx.Statement != nil && tx.Statement.Context != nil {
|
||||||
|
ctx = tx.Statement.Context
|
||||||
|
}
|
||||||
|
if clear, _ := ctx.Value(clearNameKey{}).(bool); clear {
|
||||||
|
if r.Name == "" {
|
||||||
|
return errors.New("fill did not run before validate")
|
||||||
|
}
|
||||||
|
r.Name = ""
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type crudNoRules struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (crudNoRules) TableName() string { return "cabana_crud_rows" }
|
||||||
|
func (crudNoRules) Fillable() []string { return []string{"name", "note"} }
|
||||||
|
|
||||||
|
type crudNoFill struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (crudNoFill) TableName() string { return "cabana_crud_rows" }
|
||||||
|
func (crudNoFill) Rules() map[string]string { return map[string]string{"name": "required"} }
|
||||||
|
|
||||||
|
type crudController struct{ rec func() any }
|
||||||
|
|
||||||
|
func (c crudController) ID() string { return "acme.demo.records" }
|
||||||
|
func (c crudController) ModelName() string { return "Record" }
|
||||||
|
func (c crudController) ConfigDir() string { return "controllers/records" }
|
||||||
|
func (c crudController) NewRecord() any {
|
||||||
|
if c.rec != nil {
|
||||||
|
return c.rec()
|
||||||
|
}
|
||||||
|
return &crudRow{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCRUDFillValidate(t *testing.T) {
|
||||||
|
svc, cc, db := crudFixture(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("empty name is 422 and does not persist", func(t *testing.T) {
|
||||||
|
_, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": ""}})
|
||||||
|
assertValidation(t, err, "name", "The name field is required.")
|
||||||
|
if n := countCrud(t, db); n != 0 {
|
||||||
|
t.Fatalf("rows=%d, validation must not persist", n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("yaml required merges when model rules are empty", func(t *testing.T) {
|
||||||
|
prev := crudRuleSet
|
||||||
|
crudRuleSet = map[string]string{}
|
||||||
|
t.Cleanup(func() { crudRuleSet = prev })
|
||||||
|
_, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"note": "only"}})
|
||||||
|
assertValidation(t, err, "name", "The name field is required.")
|
||||||
|
if n := countCrud(t, db); n != 0 {
|
||||||
|
t.Fatalf("rows=%d, yaml required was skipped", n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("validate runs after fill", func(t *testing.T) {
|
||||||
|
clearCtx := context.WithValue(ctx, clearNameKey{}, true)
|
||||||
|
_, err := svc.Create(clearCtx, cc, RecordInput{Body: map[string]any{"name": "Ada"}})
|
||||||
|
assertValidation(t, err, "name", "The name field is required.")
|
||||||
|
if n := countCrud(t, db); n != 0 {
|
||||||
|
t.Fatalf("rows=%d, validate ran before BeforeValidate mutated the model", n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
rec, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "Ada", "note": "kept"}})
|
||||||
|
if err != nil || rec["name"] != "Ada" || rec["note"] != "kept" {
|
||||||
|
t.Fatalf("create=%#v err=%v, want filled name and note", rec, err)
|
||||||
|
}
|
||||||
|
stored := loadCrud(t, db, "Ada")
|
||||||
|
if stored.Note != "kept" {
|
||||||
|
t.Fatalf("stored note=%q, Fill did not persist the writable field", stored.Note)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := svc.Update(ctx, cc, stored.ID, RecordInput{Body: map[string]any{"name": "Bea"}})
|
||||||
|
if err != nil || updated["name"] != "Bea" {
|
||||||
|
t.Fatalf("update=%#v err=%v", updated, err)
|
||||||
|
}
|
||||||
|
if _, err := svc.Update(ctx, cc, stored.ID, RecordInput{Body: map[string]any{"name": ""}}); err == nil {
|
||||||
|
t.Fatal("blank update name succeeded")
|
||||||
|
} else {
|
||||||
|
assertValidation(t, err, "name", "The name field is required.")
|
||||||
|
}
|
||||||
|
if got := loadCrud(t, db, "Bea"); got.Note != "kept" || got.Name != "Bea" {
|
||||||
|
t.Fatalf("row after rejected update = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCRUDWritableProjection(t *testing.T) {
|
||||||
|
cc := crudCompiled(t)
|
||||||
|
gotKeys := map[string]bool{}
|
||||||
|
for _, field := range cc.Writable {
|
||||||
|
gotKeys[field.Name] = true
|
||||||
|
if field.FillKey == "" || field.FillKey != field.Name {
|
||||||
|
t.Fatalf("binding %+v is not an explicit model fill key", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, banned := range []string{"id", "created_at", "scope_id", "is_system"} {
|
||||||
|
if gotKeys[banned] {
|
||||||
|
t.Fatalf("protected field %s was bound writable: %+v", banned, cc.Writable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !gotKeys["name"] || !gotKeys["note"] {
|
||||||
|
t.Fatalf("writable = %+v, want name and note", cc.Writable)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := crudBody(t, `{"name":"Ada","Name":"Nope","note":"n","id":9,"ID":8,"created_at":"1999-01-01T00:00:00Z","CreatedAt":"1999-01-01T00:00:00Z","scope_id":4,"is_system":true,"IsSystem":true,"unknown":"x","meta":{"name":"hidden","id":1,"is_system":true}}`)
|
||||||
|
got := ProjectWritableFields(cc, body)
|
||||||
|
if len(got) != 2 || got["name"] != "Ada" || got["note"] != "n" {
|
||||||
|
t.Fatalf("projected = %#v, want only name and note", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCRUDRejectsProtectedFields(t *testing.T) {
|
||||||
|
svc, cc, db := crudFixture(t)
|
||||||
|
body := crudBody(t, `{"name":"Ada","note":"kept","id":424242,"ID":7,"Id":7,"created_at":"1999-01-01T00:00:00Z","CreatedAt":"1999-01-01T00:00:00Z","createdAt":"1999-01-01T00:00:00Z","scope_id":9,"ScopeID":9,"is_system":true,"IsSystem":true,"isSystem":true,"meta":{"id":1,"scope_id":3,"is_system":true,"name":"hidden"},"nested":{"created_at":"1999-01-01T00:00:00Z"}}`)
|
||||||
|
rec, err := svc.Create(context.Background(), cc, RecordInput{Body: body})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create err=%v", err)
|
||||||
|
}
|
||||||
|
if rec["name"] != "Ada" {
|
||||||
|
t.Fatalf("create=%#v", rec)
|
||||||
|
}
|
||||||
|
stored := loadCrud(t, db, "Ada")
|
||||||
|
if stored.ID == 0 || stored.ID == 424242 || stored.ID == 7 || stored.ScopeID != 0 || stored.IsSystem || stored.CreatedAt.Year() == 1999 || stored.Note != "kept" {
|
||||||
|
t.Fatalf("protected fields mutated the row: %+v", stored)
|
||||||
|
}
|
||||||
|
if _, err := svc.Update(context.Background(), cc, stored.ID, RecordInput{Body: crudBody(t, `{"note":"still","id":424242,"scope_id":9,"is_system":true,"created_at":"1999-01-01T00:00:00Z","meta":{"is_system":true}}`)}); err != nil {
|
||||||
|
t.Fatalf("update err=%v", err)
|
||||||
|
}
|
||||||
|
stored = loadCrud(t, db, "Ada")
|
||||||
|
if stored.ID == 424242 || stored.ScopeID != 0 || stored.IsSystem || stored.CreatedAt.Year() == 1999 || stored.Note != "still" || stored.Name != "Ada" {
|
||||||
|
t.Fatalf("update mutated protected fields: %+v", stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCRUDCapabilityFailure(t *testing.T) {
|
||||||
|
svc, cc, db := crudFixture(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("missing rules", func(t *testing.T) {
|
||||||
|
bare := *cc
|
||||||
|
bare.Controller = crudController{rec: func() any { return &crudNoRules{} }}
|
||||||
|
_, err := svc.Create(ctx, &bare, RecordInput{Body: map[string]any{"name": "Ada"}})
|
||||||
|
assertCapability(t, err, bare.Controller.ID())
|
||||||
|
if n := countCrud(t, db); n != 0 {
|
||||||
|
t.Fatalf("rows=%d, missing Rules persisted", n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing fillable", func(t *testing.T) {
|
||||||
|
bare := *cc
|
||||||
|
bare.Controller = crudController{rec: func() any { return &crudNoFill{} }}
|
||||||
|
_, err := svc.Create(ctx, &bare, RecordInput{Body: map[string]any{"name": "Ada"}})
|
||||||
|
assertCapability(t, err, bare.Controller.ID())
|
||||||
|
if n := countCrud(t, db); n != 0 {
|
||||||
|
t.Fatalf("rows=%d, missing Fillable persisted", n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("provider error", func(t *testing.T) {
|
||||||
|
prev := crudRuleSet
|
||||||
|
crudRuleSet = map[string]string{"name": "nope"}
|
||||||
|
t.Cleanup(func() { crudRuleSet = prev })
|
||||||
|
_, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "Ada"}})
|
||||||
|
assertCapability(t, err, cc.Controller.ID())
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
writeCRUDError(rec, err)
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), "nope") || strings.Contains(rec.Body.String(), "unrecognized") {
|
||||||
|
t.Fatalf("response leaked provider error: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if n := countCrud(t, db); n != 0 {
|
||||||
|
t.Fatalf("rows=%d, provider error persisted", n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func crudFixture(t *testing.T) (CRUDService, *CompiledController, *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
crudRuleSet = map[string]string{"name": "required"}
|
||||||
|
_, db := newListService(t)
|
||||||
|
if err := db.Migrator().DropTable(&crudRow{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&crudRow{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return CRUDService{DB: db}, crudCompiled(t), db
|
||||||
|
}
|
||||||
|
|
||||||
|
func crudCompiled(t *testing.T) *CompiledController {
|
||||||
|
t.Helper()
|
||||||
|
reg, err := compileRegistry([]controllerRef{{
|
||||||
|
plugin: formPlugin{fsys: crudFS()},
|
||||||
|
ctl: crudController{},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("registry: %v", err)
|
||||||
|
}
|
||||||
|
cc, ok := reg.Get("acme.demo.records")
|
||||||
|
if !ok || cc.Form == nil {
|
||||||
|
t.Fatalf("compiled controller missing form: %+v", cc)
|
||||||
|
}
|
||||||
|
return cc
|
||||||
|
}
|
||||||
|
|
||||||
|
func crudFS() fstest.MapFS {
|
||||||
|
return fstest.MapFS{
|
||||||
|
"controllers/records/config_list.yaml": &fstest.MapFile{Data: []byte(crudListConfig)},
|
||||||
|
"controllers/records/config_form.yaml": &fstest.MapFile{Data: []byte(crudFormConfig)},
|
||||||
|
"models/record/columns.yaml": &fstest.MapFile{Data: []byte(crudColumns)},
|
||||||
|
"models/record/fields.yaml": &fstest.MapFile{Data: []byte(crudFields)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertValidation(t *testing.T, err error, field, message string) {
|
||||||
|
t.Helper()
|
||||||
|
var ve *ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("err=%v, want validation_failed", err)
|
||||||
|
}
|
||||||
|
got, _ := ve.Details[field].([]string)
|
||||||
|
if len(got) != 1 || got[0] != message {
|
||||||
|
t.Fatalf("details[%s]=%#v, want %q", field, ve.Details[field], message)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
writeCRUDError(rec, err)
|
||||||
|
if rec.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("status=%d body=%s, want 422", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Error struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Details map[string]any `json:"details"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body.Error.Code != "validation_failed" || body.Error.Message != "Validation failed" {
|
||||||
|
t.Fatalf("envelope=%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
msgs, _ := body.Error.Details[field].([]any)
|
||||||
|
if len(msgs) != 1 || msgs[0] != message {
|
||||||
|
t.Fatalf("http details=%#v", body.Error.Details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCapability(t *testing.T, err error, controllerID string) {
|
||||||
|
t.Helper()
|
||||||
|
var ce *CapabilityError
|
||||||
|
if !errors.As(err, &ce) || ce.ControllerID != controllerID || !strings.Contains(err.Error(), controllerID) {
|
||||||
|
t.Fatalf("err=%v, want capability failure for %s", err, controllerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func countCrud(t *testing.T, db *gorm.DB) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var n int64
|
||||||
|
if err := db.Model(&crudRow{}).Count(&n).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCrud(t *testing.T, db *gorm.DB, name string) crudRow {
|
||||||
|
t.Helper()
|
||||||
|
var row crudRow
|
||||||
|
if err := db.Where("name = ?", name).Take(&row).Error; err != nil {
|
||||||
|
t.Fatalf("load %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
func crudBody(t *testing.T, raw string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
dec := json.NewDecoder(strings.NewReader(raw))
|
||||||
|
dec.UseNumber()
|
||||||
|
var body map[string]any
|
||||||
|
if err := dec.Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user