package cabana import ( "context" "strings" "testing" "testing/fstest" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" ) const mlNullableFields = `fields: title: label: Title type: mltext required: true description: label: Description type: mltext ` type mlNullablePost struct { ID uint `gorm:"column:id;primaryKey"` Title string `gorm:"column:title"` Description *string `gorm:"column:description"` } func (mlNullablePost) TableName() string { return "cabana_ml_nullable_posts" } func (mlNullablePost) Fillable() []string { return []string{"title", "description"} } func (mlNullablePost) Rules() map[string]string { return map[string]string{"title": "required"} } type mlNullableController struct{} func (mlNullableController) ID() string { return "acme.demo.posts" } func (mlNullableController) ModelName() string { return "Post" } func (mlNullableController) ConfigDir() string { return "controllers/posts" } func (mlNullableController) NewRecord() any { return &mlNullablePost{} } func (mlNullableController) FormExtendQuery(ctx context.Context, q *gorm.DB) *gorm.DB { return q } var ( _ pact.AdminController = mlNullableController{} _ pact.AdminRecordSource = mlNullableController{} _ pact.FormExtendQuery = mlNullableController{} ) func mlNullableFS() fstest.MapFS { fsys := mlFS() fsys["models/post/fields.yaml"] = &fstest.MapFile{Data: []byte(mlNullableFields)} return fsys } func mlNullableCompiled(t *testing.T) *CompiledController { t.Helper() reg, err := compileRegistry([]controllerRef{{ plugin: formPlugin{fsys: mlNullableFS()}, ctl: mlNullableController{}, }}) if err != nil { t.Fatalf("registry: %v", err) } cc, ok := reg.Get("acme.demo.posts") if !ok || cc.Form == nil { t.Fatalf("compiled controller missing form: %+v", cc) } return cc } // assertNoPointerLeak fails when a hydrated value carries the text of a Go // pointer instead of the value it points at. func assertNoPointerLeak(t *testing.T, where, got string) { t.Helper() if strings.Contains(got, "") || strings.HasPrefix(got, "0x") { t.Fatalf("%s = %q, leaked a pointer instead of its value", where, got) } } // mlLocaleMap asserts data[field] is a hydrated locale map with no pointer // leak in any locale and returns it. func mlLocaleMap(t *testing.T, where string, data map[string]any, field string) map[string]string { t.Helper() got, ok := data[field].(map[string]string) if !ok { t.Fatalf("%s %s = %#v, want hydrated locale map", where, field, data[field]) } for code, text := range got { assertNoPointerLeak(t, where+" "+field+"."+code, text) } return got } func TestMLNullablePointerHost(t *testing.T) { _, db := newListService(t) if err := db.Migrator().DropTable(&mlNullablePost{}); err != nil { t.Fatal(err) } if err := db.AutoMigrate(&mlNullablePost{}); err != nil { t.Fatal(err) } writer := &recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}} svc := CRUDService{DB: db, writer: writer} cc := mlNullableCompiled(t) ctx := context.Background() row := mlNullablePost{Title: "Hello"} if err := db.Create(&row).Error; err != nil { t.Fatal(err) } title := map[string]any{"en": "Hello", "pl": "Witaj"} t.Run("nil pointer host hydrates to empty strings", func(t *testing.T) { shown, err := svc.ShowRecord(ctx, cc, row.ID) if err != nil { t.Fatalf("show: %v", err) } got := mlLocaleMap(t, "show", shown.Data, "description") if got["en"] != "" || got["pl"] != "" || len(got) != 2 { t.Fatalf("nil description = %#v, want en and pl empty", got) } }) t.Run("cleared value round-trips as empty string", func(t *testing.T) { saved, err := svc.UpdateRecord(ctx, cc, row.ID, RecordInput{Body: map[string]any{ "title": title, "description": map[string]any{"en": "", "pl": ""}, }}) if err != nil { t.Fatalf("update: %v", err) } if got := mlLocaleMap(t, "save", saved.Data, "description"); got["en"] != "" { t.Fatalf("saved description = %#v, want en empty", got) } shown, err := svc.ShowRecord(ctx, cc, row.ID) if err != nil { t.Fatalf("show: %v", err) } if got := mlLocaleMap(t, "show", shown.Data, "description"); got["en"] != "" || got["pl"] != "" { t.Fatalf("shown description = %#v, want en and pl empty", got) } var stored mlNullablePost if err := db.First(&stored, row.ID).Error; err != nil { t.Fatal(err) } if stored.Description == nil || *stored.Description != "" { t.Fatalf("stored description = %v, want pointer to empty string", stored.Description) } }) t.Run("filled value stores host text and writes translation", func(t *testing.T) { saved, err := svc.UpdateRecord(ctx, cc, row.ID, RecordInput{Body: map[string]any{ "title": title, "description": map[string]any{"en": "Opis kategorii", "pl": "Opis"}, }}) if err != nil { t.Fatalf("update: %v", err) } if got := mlLocaleMap(t, "save", saved.Data, "description"); got["en"] != "Opis kategorii" { t.Fatalf("saved description = %#v", got) } shown, err := svc.ShowRecord(ctx, cc, row.ID) if err != nil { t.Fatalf("show: %v", err) } got := mlLocaleMap(t, "show", shown.Data, "description") if got["en"] != "Opis kategorii" || got["pl"] != "Opis" { t.Fatalf("shown description = %#v", got) } var stored mlNullablePost if err := db.First(&stored, row.ID).Error; err != nil { t.Fatal(err) } if stored.Description == nil || *stored.Description != "Opis kategorii" { t.Fatalf("stored description = %v, want Opis kategorii", stored.Description) } if writer.attrs["pl"]["description"] != "Opis" { t.Fatalf("Polish attributes = %#v", writer.attrs) } }) }