package cabana import ( "context" "reflect" "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) } }) } type nullableSlug string func (nullableSlug) String() string { return "decorated" } func TestHostScalarString(t *testing.T) { var nilString *string var nilBytes *[]byte inner := new("x") slug := nullableSlug("raw-slug") cases := []struct { name string in any want string }{ {"nil interface", nil, ""}, {"nil *string", (*string)(nil), ""}, {"nil *int", (*int)(nil), ""}, {"**string to nil", &nilString, ""}, {"nil *[]byte", nilBytes, ""}, {"pointer to empty", new(""), ""}, {"pointer to text", new("Opis"), "Opis"}, {"**string to text", &inner, "x"}, {"pointer to int", new(42), "42"}, {"pointer to int64", new(int64(-7)), "-7"}, {"pointer to uint", new(uint(9)), "9"}, {"pointer to float64", new(1.5), "1.5"}, {"pointer to bool", new(true), "true"}, {"named string value", slug, "raw-slug"}, {"pointer to named string", &slug, "raw-slug"}, {"byte slice", []byte("abc"), "abc"}, {"pointer to byte slice", new([]byte("abc")), "abc"}, {"plain string", "plain", "plain"}, {"plain int", 7, "7"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := hostScalarString(tc.in) assertNoPointerLeak(t, tc.name, got) if got != tc.want { t.Fatalf("hostScalarString(%#v) = %q, want %q", tc.in, got, tc.want) } }) } } func TestMLHydrationPointerHost(t *testing.T) { cc := mlCompiled(t) cases := []struct { name string in any want map[string]string }{ {"nil pointer", (*string)(nil), map[string]string{"en": "", "pl": ""}}, {"pointer to empty", new(""), map[string]string{"en": "", "pl": ""}}, {"pointer to text", new("Hello"), map[string]string{"en": "Hello", "pl": ""}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { writer := &recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}} data := map[string]any{"title": tc.in} if err := hydrateMLRecord(context.Background(), nil, cc, writer, nil, data, "update"); err != nil { t.Fatalf("hydrate: %v", err) } got := mlLocaleMap(t, tc.name, data, "title") if !reflect.DeepEqual(got, tc.want) { t.Fatalf("title = %#v, want %#v", got, tc.want) } }) } }