Files
summercms/modules/cabana/ml_nullable_test.go
Jakub Zych 4f7e69fd2b test(cabana): cover pointer scalars in ML host hydration
- table-driven hostScalarString cases for nil and live pointers to string, ints, uint, float, bool, []byte, named string and double pointers
- DB-free hydrateMLRecord cases for nil, empty and filled *string hosts
2026-10-06 20:36:59 +02:00

255 lines
7.7 KiB
Go

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, "<nil>") || 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)
}
})
}
}