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