Files
summercms/cabana/crud_test.go
Jakub Zych 247c3235f6 test(09-05): add failing tests for deterministic bulk delete
- Empty selections are 422 and duplicates run once in primary-key order
- A completed retry and an all-absent selection delete nothing and skip hooks
- Mixed, hook, cancel, and concurrent requests keep the batch atomic
2026-09-24 19:43:15 +02:00

472 lines
14 KiB
Go

package cabana
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"testing/fstest"
"time"
"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 := txContext(tx)
recordHook(ctx, "before_validate")
if clear, _ := ctx.Value(clearNameKey{}).(bool); clear {
if r.Name == "" {
return errors.New("fill did not run before validate")
}
r.Name = ""
}
return nil
}
func (r *crudRow) BeforeSave(tx *gorm.DB) error {
recordHook(txContext(tx), "before_save")
return nil
}
func (r *crudRow) BeforeCreate(tx *gorm.DB) error {
recordHook(txContext(tx), "before_create")
return nil
}
func (r *crudRow) AfterCreate(tx *gorm.DB) error {
recordHook(txContext(tx), "after_create")
return nil
}
func (r *crudRow) AfterSave(tx *gorm.DB) error {
recordHook(txContext(tx), "after_save")
return nil
}
func (r *crudRow) BeforeUpdate(tx *gorm.DB) error {
recordHook(txContext(tx), "before_update")
return nil
}
func (r *crudRow) AfterUpdate(tx *gorm.DB) error {
recordHook(txContext(tx), "after_update")
return nil
}
func (r *crudRow) BeforeDelete(tx *gorm.DB) error {
ctx := txContext(tx)
if _, ok := ctx.Value(bulkSlowKey{}).(bool); ok {
time.Sleep(300 * time.Millisecond)
}
recordHook(ctx, "before_delete")
appendDeletedID(ctx, r.ID)
if fail, _ := ctx.Value(failDeleteKey{}).(bool); fail {
return errors.New("secret-hook-boom")
}
if ids, ok := ctx.Value(failIDsKey{}).(map[uint]bool); ok && ids[r.ID] {
return errors.New("secret-hook-boom")
}
if id, ok := ctx.Value(cancelOnKey{}).(uint); ok && r.ID == id {
if cancel, ok := ctx.Value(cancelKey{}).(context.CancelFunc); ok {
cancel()
}
return context.Canceled
}
return nil
}
func (r *crudRow) AfterDelete(tx *gorm.DB) error {
recordHook(txContext(tx), "after_delete")
return nil
}
type hookSinkKey struct{}
type failDeleteKey struct{}
type failIDsKey struct{}
type idSinkKey struct{}
type bulkSlowKey struct{}
type cancelOnKey struct{}
type cancelKey struct{}
var hookMu sync.Mutex
func txContext(tx *gorm.DB) context.Context {
if tx != nil && tx.Statement != nil && tx.Statement.Context != nil {
return tx.Statement.Context
}
return context.Background()
}
func recordHook(ctx context.Context, name string) {
if ctx == nil {
return
}
hookMu.Lock()
defer hookMu.Unlock()
sink, _ := ctx.Value(hookSinkKey{}).(*[]string)
if sink == nil {
return
}
*sink = append(*sink, name)
}
func appendDeletedID(ctx context.Context, id uint) {
if ctx == nil {
return
}
hookMu.Lock()
defer hookMu.Unlock()
sink, _ := ctx.Value(idSinkKey{}).(*[]uint)
if sink == nil {
return
}
*sink = append(*sink, id)
}
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
}