Files
summercms/modules/cabana/clear_on_change_field_test.go
Jakub Zych 9516a2b36f feat(cabana): ClearOnChange clears dependent columns when a relation changes a foreign key
RelationContract (hasMany) and FieldRelationContract (belongsTo) accept
ClearOnChange, a list of nullable columns set to NULL whenever the relation
changes the foreign key: a hasMany link and unlink (deferred ones included)
clear them on the child, and a form save that moves a belongsTo key to
another record or to none clears them on the record unless the same save
writes the column through its own field. Boot refuses the option on a
belongsToMany and for a missing, non-nullable, foreign-key or primary-key
column. The zero value changes nothing.
2026-10-22 03:30:35 +02:00

223 lines
7.9 KiB
Go

package cabana
import (
"context"
"strings"
"testing"
"testing/fstest"
)
// cocTeam is the related model of the ClearOnChange belongsTo fixture.
type cocTeam struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
}
func (cocTeam) TableName() string { return "cabana_coc_teams" }
// cocMember belongs to a team through the protected organisation_id; its
// role only makes sense inside that team.
type cocMember struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
OrganisationID *uint `gorm:"column:organisation_id"`
Role *string `gorm:"column:role"`
Note *string `gorm:"column:note"`
}
func (cocMember) TableName() string { return "cabana_coc_members" }
func (cocMember) Fillable() []string { return []string{"name", "role", "note"} }
func (cocMember) Rules() map[string]string { return map[string]string{"name": "required"} }
type cocController struct {
relations []FieldRelationContract
}
func (cocController) ID() string { return "acme.demo.members" }
func (cocController) ModelName() string { return "Member" }
func (cocController) ConfigDir() string { return "controllers/members" }
func (cocController) NewRecord() any { return &cocMember{} }
func (c cocController) AdminFieldRelations() []FieldRelationContract {
return c.relations
}
// cocTeamContract is the team field's contract with ClearOnChange role.
func cocTeamContract(clear ...string) FieldRelationContract {
return FieldRelationContract{
Field: "team", Kind: relationKindBelongsTo, NewRelated: func() any { return &cocTeam{} },
ForeignKey: "organisation_id", WritableForeignKey: true, ClearOnChange: clear,
}
}
func cocFS() fstest.MapFS {
return fstest.MapFS{
"controllers/members/config_list.yaml": &fstest.MapFile{Data: []byte("modelClass: Member\nlist: ~/plugins/acme/demo/models/member/columns.yaml\n")},
"controllers/members/config_form.yaml": &fstest.MapFile{Data: []byte("name: members\nform: ~/plugins/acme/demo/models/member/fields.yaml\nmodelClass: Member\n")},
"models/member/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n")},
"models/member/fields.yaml": &fstest.MapFile{Data: []byte(`fields:
name:
label: Name
type: text
team:
label: Team
type: relation
nameFrom: name
role:
label: Role
type: text
note:
label: Note
type: text
`)},
}
}
func cocCompile(relations ...FieldRelationContract) (*CompiledController, error) {
reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: cocFS()}, ctl: cocController{relations: relations}}})
if err != nil {
return nil, err
}
cc, _ := reg.Get("acme.demo.members")
return cc, nil
}
// TestClearOnChangeFieldBoot: ClearOnChange on a form relation field is
// refused at boot on a belongsToMany, and for a column the record lacks, a
// column that is not nullable, the foreign key and the primary key; the
// error names the controller, the field and the column.
func TestClearOnChangeFieldBoot(t *testing.T) {
if _, err := cocCompile(cocTeamContract("role", "note")); err != nil {
t.Fatalf("a valid ClearOnChange: %v", err)
}
many := cocTeamContract("role")
many.Kind, many.ForeignKey, many.WritableForeignKey = relationKindBelongsToMany, "", false
for name, tc := range map[string]struct {
contract FieldRelationContract
want string
}{
"belongsToMany": {many, "ClearOnChange is only valid on belongsTo"},
"missing column": {cocTeamContract("rank"), "ClearOnChange column rank is not a column of the model"},
"not nullable": {cocTeamContract("name"), "ClearOnChange column name is not nullable"},
"foreign key": {cocTeamContract("organisation_id"), "ClearOnChange column organisation_id is the foreign key"},
"primary key": {cocTeamContract("id"), "ClearOnChange column id is the primary key"},
} {
t.Run(name, func(t *testing.T) {
_, err := cocCompile(tc.contract)
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "acme.demo.members") || !strings.Contains(err.Error(), "field team") {
t.Fatalf("err = %v, want %q naming the controller and the field", err, tc.want)
}
})
}
}
// TestClearOnChangeBelongsTo: a save that changes a belongsTo key clears the
// contract's ClearOnChange columns unless the same save writes them; a save
// that keeps the key, and a contract without ClearOnChange, leave them.
func TestClearOnChangeBelongsTo(t *testing.T) {
_, db := newListService(t)
if err := db.Migrator().DropTable(&cocMember{}, &cocTeam{}); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&cocMember{}, &cocTeam{}); err != nil {
t.Fatal(err)
}
red, blue := cocTeam{Name: "Red"}, cocTeam{Name: "Blue"}
for _, team := range []*cocTeam{&red, &blue} {
if err := db.Create(team).Error; err != nil {
t.Fatal(err)
}
}
cc, err := cocCompile(cocTeamContract("role"))
if err != nil {
t.Fatal(err)
}
svc := CRUDService{DB: db}
ctx := context.Background()
str := func(s string) *string { return &s }
seed := func(t *testing.T, team *uint, role string) uint {
t.Helper()
m := cocMember{Name: "M", OrganisationID: team, Role: str(role), Note: str("note")}
if err := db.Create(&m).Error; err != nil {
t.Fatal(err)
}
return m.ID
}
load := func(t *testing.T, id uint) cocMember {
t.Helper()
var m cocMember
if err := db.Take(&m, id).Error; err != nil {
t.Fatal(err)
}
return m
}
update := func(t *testing.T, cc *CompiledController, id uint, body map[string]any) cocMember {
t.Helper()
body["name"] = "M"
if _, err := svc.Update(ctx, cc, id, RecordInput{Body: body}); err != nil {
t.Fatalf("update %v: %v", body, err)
}
return load(t, id)
}
roleIs := func(t *testing.T, m cocMember, want string) {
t.Helper()
switch {
case want == "" && m.Role != nil:
t.Fatalf("role = %q, want NULL", *m.Role)
case want != "" && (m.Role == nil || *m.Role != want):
t.Fatalf("role = %v, want %q", m.Role, want)
}
if m.Note == nil || *m.Note != "note" {
t.Fatalf("a column outside ClearOnChange changed: note = %v", m.Note)
}
}
t.Run("another record clears", func(t *testing.T) {
m := update(t, cc, seed(t, &red.ID, "owner"), map[string]any{"team": blue.ID})
if m.OrganisationID == nil || *m.OrganisationID != blue.ID {
t.Fatalf("organisation_id = %v", m.OrganisationID)
}
roleIs(t, m, "")
})
t.Run("none clears", func(t *testing.T) {
m := update(t, cc, seed(t, &red.ID, "owner"), map[string]any{"team": nil})
if m.OrganisationID != nil {
t.Fatalf("organisation_id = %v", *m.OrganisationID)
}
roleIs(t, m, "")
})
t.Run("the same key keeps", func(t *testing.T) {
roleIs(t, update(t, cc, seed(t, &red.ID, "owner"), map[string]any{"team": red.ID}), "owner")
})
t.Run("no relation value keeps", func(t *testing.T) {
roleIs(t, update(t, cc, seed(t, &red.ID, "owner"), map[string]any{}), "owner")
})
t.Run("a submitted column wins", func(t *testing.T) {
roleIs(t, update(t, cc, seed(t, &red.ID, "owner"), map[string]any{"team": blue.ID, "role": "member"}), "member")
})
t.Run("a create that sets the key", func(t *testing.T) {
rec, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "New", "team": red.ID, "note": "note"}})
if err != nil {
t.Fatal(err)
}
id, _ := asUint(rec["id"])
m := load(t, id)
if m.OrganisationID == nil || *m.OrganisationID != red.ID {
t.Fatalf("organisation_id = %v", m.OrganisationID)
}
roleIs(t, m, "")
rec, err = svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "New", "team": red.ID, "role": "admin", "note": "note"}})
if err != nil {
t.Fatal(err)
}
id, _ = asUint(rec["id"])
roleIs(t, load(t, id), "admin")
})
t.Run("without ClearOnChange nothing is cleared", func(t *testing.T) {
plain, err := cocCompile(cocTeamContract())
if err != nil {
t.Fatal(err)
}
roleIs(t, update(t, plain, seed(t, &red.ID, "owner"), map[string]any{"team": blue.ID}), "owner")
})
}