From fec23e21c2142e1840ce1ba48b8667497eefe187 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 18 Sep 2026 18:45:46 +0200 Subject: [PATCH] test(05-01): add failing tests for Fill and lifecycle cascade - Fill allow-list, once-per-key dropped log, production silence - BeforeCreate fires on real Postgres; cascade rollback on error --- lagoon/fill_test.go | 105 ++++++++++++++++++++++ lagoon/lifecycle_test.go | 183 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 lagoon/fill_test.go create mode 100644 lagoon/lifecycle_test.go diff --git a/lagoon/fill_test.go b/lagoon/fill_test.go new file mode 100644 index 0000000..2381049 --- /dev/null +++ b/lagoon/fill_test.go @@ -0,0 +1,105 @@ +package lagoon + +import ( + "bytes" + "log/slog" + "strings" + "testing" +) + +type fillFixture struct { + Name string `gorm:"column:name"` + CollectionID uint `gorm:"column:collection_id"` + Notes *string `gorm:"column:notes"` +} + +func (fillFixture) Fillable() []string { return []string{"name", "notes"} } +func (fillFixture) Hidden() []string { return []string{"collection_id"} } + +var ( + _ HasFillable = fillFixture{} + _ HasHidden = fillFixture{} +) + +func TestFillAllowList(t *testing.T) { + var row fillFixture + row.CollectionID = 3 + err := Fill(&row, []string{"name"}, map[string]any{ + "name": "x", + "collection_id": uint(9), + }, true) + if err != nil { + t.Fatal(err) + } + if row.Name != "x" { + t.Fatalf("name = %q", row.Name) + } + if row.CollectionID != 3 { + t.Fatalf("collection_id mutated to %d", row.CollectionID) + } +} + +func TestFillDroppedKeyLogsOnce(t *testing.T) { + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}))) + defer slog.SetDefault(prev) + + var row fillFixture + requested := map[string]any{"name": "once", "collection_id": uint(1)} + if err := Fill(&row, []string{"name"}, requested, false); err != nil { + t.Fatal(err) + } + if err := Fill(&row, []string{"name"}, requested, false); err != nil { + t.Fatal(err) + } + log := buf.String() + if strings.Count(log, "collection_id") != 1 { + t.Fatalf("dropped key should log once, got %q", log) + } + if !strings.Contains(log, "lagoon: dropped non-fillable key") { + t.Fatalf("missing warn message: %q", log) + } +} + +func TestFillProductionSilent(t *testing.T) { + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}))) + defer slog.SetDefault(prev) + + var row fillFixture + if err := Fill(&row, []string{"name"}, map[string]any{ + "name": "prod", + "unknown_field": true, + }, true); err != nil { + t.Fatal(err) + } + if buf.Len() != 0 { + t.Fatalf("production fill must be silent, got %q", buf.String()) + } + if row.Name != "prod" { + t.Fatalf("name = %q", row.Name) + } +} + +func TestFillNilPointerClearsNullable(t *testing.T) { + existing := "keep" + row := fillFixture{Notes: &existing} + if err := Fill(&row, []string{"notes"}, map[string]any{"notes": nil}, true); err != nil { + t.Fatal(err) + } + if row.Notes != nil { + t.Fatalf("notes = %v, want nil", row.Notes) + } +} + +func TestFillDroppedKeyNeverErrors(t *testing.T) { + var row fillFixture + if err := Fill(&row, []string{"name"}, map[string]any{"collection_id": uint(9), "nope": 1}, true); err != nil { + t.Fatalf("dropped keys must not error: %v", err) + } + if row.CollectionID != 0 { + t.Fatalf("collection_id = %d", row.CollectionID) + } +} diff --git a/lagoon/lifecycle_test.go b/lagoon/lifecycle_test.go new file mode 100644 index 0000000..40eecba --- /dev/null +++ b/lagoon/lifecycle_test.go @@ -0,0 +1,183 @@ +package lagoon + +import ( + "strings" + "testing" + + "gorm.io/gorm" +) + +type hookRow struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` + Flag bool `gorm:"column:flag"` +} + +func (hookRow) TableName() string { return "lagoon_hook_rows" } + +func (h *hookRow) BeforeCreate(tx *gorm.DB) error { + h.Flag = true + return nil +} + +type cascadeParent struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` +} + +func (cascadeParent) TableName() string { return "lagoon_cascade_parents" } + +func (p *cascadeParent) BeforeDelete(tx *gorm.DB) error { + return WithSoftDeleteCascade(tx, func(tx *gorm.DB) error { + return tx.Where("parent_id = ?", p.ID).Delete(&cascadeChild{}).Error + }) +} + +type cascadeChild struct { + ID uint `gorm:"column:id;primaryKey"` + ParentID uint `gorm:"column:parent_id"` + Name string `gorm:"column:name"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` +} + +func (cascadeChild) TableName() string { return "lagoon_cascade_children" } + +type failingParent struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` +} + +func (failingParent) TableName() string { return "lagoon_cascade_parents" } + +func (p *failingParent) BeforeDelete(tx *gorm.DB) error { + return WithSoftDeleteCascade(tx, func(tx *gorm.DB) error { + if err := tx.Where("parent_id = ?", p.ID).Delete(&cascadeChild{}).Error; err != nil { + return err + } + return gorm.ErrInvalidData + }) +} + +func TestLifecycleBeforeCreate(t *testing.T) { + sqlDB, _ := dedicatedDB(t, "lagoon_lifecycle_create") + gdb, err := Use(t.Context(), sqlDB) + if err != nil { + t.Fatal(err) + } + if err := gdb.Exec(` +CREATE TABLE lagoon_hook_rows ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + flag BOOLEAN NOT NULL DEFAULT FALSE +)`).Error; err != nil { + t.Fatal(err) + } + row := hookRow{Name: "created"} + if err := gdb.Create(&row).Error; err != nil { + t.Fatal(err) + } + if !row.Flag { + t.Fatal("BeforeCreate did not run") + } + var stored hookRow + if err := gdb.Take(&stored, row.ID).Error; err != nil { + t.Fatal(err) + } + if !stored.Flag { + t.Fatal("BeforeCreate flag was not persisted") + } +} + +func TestWithSoftDeleteCascadeSameTransaction(t *testing.T) { + gdb := cascadeSchema(t, "lagoon_lifecycle_cascade") + parent := cascadeParent{Name: "p"} + if err := gdb.Create(&parent).Error; err != nil { + t.Fatal(err) + } + child := cascadeChild{ParentID: parent.ID, Name: "c"} + if err := gdb.Create(&child).Error; err != nil { + t.Fatal(err) + } + if err := gdb.Delete(&parent).Error; err != nil { + t.Fatal(err) + } + var parents, children int64 + if err := gdb.Unscoped().Model(&cascadeParent{}).Where("id = ? AND deleted_at IS NOT NULL", parent.ID).Count(&parents).Error; err != nil { + t.Fatal(err) + } + if err := gdb.Unscoped().Model(&cascadeChild{}).Where("id = ? AND deleted_at IS NOT NULL", child.ID).Count(&children).Error; err != nil { + t.Fatal(err) + } + if parents != 1 || children != 1 { + t.Fatalf("want both soft-deleted, parents=%d children=%d", parents, children) + } +} + +func TestWithSoftDeleteCascadeRollback(t *testing.T) { + gdb := cascadeSchema(t, "lagoon_lifecycle_rollback") + parent := failingParent{Name: "p"} + if err := gdb.Create(&parent).Error; err != nil { + t.Fatal(err) + } + child := cascadeChild{ParentID: parent.ID, Name: "c"} + if err := gdb.Create(&child).Error; err != nil { + t.Fatal(err) + } + err := gdb.Delete(&parent).Error + if err == nil { + t.Fatal("want cascade error") + } + if !strings.Contains(err.Error(), gorm.ErrInvalidData.Error()) { + t.Fatalf("got %v", err) + } + var parents, children int64 + if err := gdb.Model(&cascadeParent{}).Where("id = ?", parent.ID).Count(&parents).Error; err != nil { + t.Fatal(err) + } + if err := gdb.Model(&cascadeChild{}).Where("id = ?", child.ID).Count(&children).Error; err != nil { + t.Fatal(err) + } + if parents != 1 || children != 1 { + t.Fatalf("cascade error must roll back both, parents=%d children=%d", parents, children) + } +} + +func TestWithSoftDeleteCascadeNilTx(t *testing.T) { + err := WithSoftDeleteCascade(nil, func(tx *gorm.DB) error { return nil }) + if err == nil { + t.Fatal("want nil tx error") + } + if !strings.Contains(err.Error(), "nil") { + t.Fatalf("got %v", err) + } +} + +func cascadeSchema(t *testing.T, dbName string) *gorm.DB { + t.Helper() + sqlDB, _ := dedicatedDB(t, dbName) + gdb, err := Use(t.Context(), sqlDB) + if err != nil { + t.Fatal(err) + } + stmts := []string{ + `CREATE TABLE lagoon_cascade_parents ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + deleted_at TIMESTAMPTZ +)`, + `CREATE TABLE lagoon_cascade_children ( + id SERIAL PRIMARY KEY, + parent_id INTEGER NOT NULL, + name TEXT NOT NULL, + deleted_at TIMESTAMPTZ +)`, + } + for _, stmt := range stmts { + if err := gdb.Exec(stmt).Error; err != nil { + t.Fatal(err) + } + } + return gdb +}