package cabana_test import ( "net/http" "strings" "testing" "git.golem15.com/golem15/summercms/modules/cabana" ) // clearOnChangeContracts is dfContracts with ClearOnChange columns on the // parts hasMany. func clearOnChangeContracts(columns ...string) func() []cabana.RelationContract { return func() []cabana.RelationContract { cs := dfContracts() cs[1].ClearOnChange = columns return cs } } // TestClearOnChangeRelationBoot: ClearOnChange on a relation contract is // refused at boot on a belongsToMany, and for a column the child lacks, a // column that is not nullable, the ForeignKey and the primary key; the // error names the controller, the relation and the column. func TestClearOnChangeRelationBoot(t *testing.T) { member := func() []cabana.RelationContract { cs := dfContracts() cs[0].ClearOnChange = []string{"email"} return cs } for name, tc := range map[string]struct { contracts func() []cabana.RelationContract want string }{ "belongsToMany": {member, "relation members: ClearOnChange is only valid on a hasMany contract"}, "missing column": {clearOnChangeContracts("rank"), "relation parts: ClearOnChange column rank is not a column of the model"}, "not nullable": {clearOnChangeContracts("label"), "relation parts: ClearOnChange column label is not nullable"}, "foreign key": {clearOnChangeContracts("gadget_id"), "relation parts: ClearOnChange column gadget_id is the foreign key"}, "primary key": {clearOnChangeContracts("id"), "relation parts: ClearOnChange column id is the primary key"}, } { t.Run(name, func(t *testing.T) { err := activateDeferred(t, withContracts(nil, tc.contracts)(t), nil) if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "acme.deferred.gadgets") { t.Fatalf("err = %v, want %q naming the controller", err, tc.want) } }) } if err := activateDeferred(t, withContracts(nil, clearOnChangeContracts("due_on"))(t), nil); err != nil { t.Fatalf("a valid ClearOnChange: %v", err) } } // TestClearOnChangeHasMany: a hasMany link and unlink in the relation // manager clear the contract's ClearOnChange columns of the child, a // deferred link committed by the parent's first save too, and a contract // without ClearOnChange leaves them. func TestClearOnChangeHasMany(t *testing.T) { env := newDeferredEnvWith(t, withContracts(nil, clearOnChangeContracts("due_on"))(t)) g := env.gadget(t, "g-"+env.stamp, false) parts := func(gadget uint, rest string) string { return dfPath(gadget, "/relations/parts"+rest) } dated := func(t *testing.T, owner uint, label string) uint { t.Helper() id := env.part(t, owner, label) if err := env.db.Exec(`UPDATE cabana_deferred_parts SET due_on = '2026-11-01' WHERE id = ?`, id).Error; err != nil { t.Fatal(err) } return id } dueOn := func(t *testing.T, id uint) bool { t.Helper() p, ok := env.partRow(t, id) if !ok { t.Fatalf("part %d is gone", id) } return p.DueOn != nil } t.Run("link clears", func(t *testing.T) { free := dated(t, 0, "free") want(t, "link", env.a.do(t, http.MethodPost, parts(g, "/link"), map[string]any{"ids": []uint{free}}, nil), http.StatusOK) if p, _ := env.partRow(t, free); p.GadgetID == nil || *p.GadgetID != g || p.Label != "free" || dueOn(t, free) { t.Fatalf("linked part %+v, want owned by %d with due_on NULL", p, g) } }) t.Run("unlink clears", func(t *testing.T) { owned := dated(t, g, "owned") want(t, "unlink", env.a.do(t, http.MethodPost, parts(g, "/unlink"), map[string]any{"ids": []uint{owned}}, nil), http.StatusOK) if p, _ := env.partRow(t, owned); p.GadgetID != nil || dueOn(t, owned) { t.Fatalf("unlinked part %+v, want a NULL key and due_on", p) } }) t.Run("deferred link clears on the parent's first save", func(t *testing.T) { h := sk(newSessionKey(t)) free := dated(t, 0, "deferred") want(t, "deferred link", env.a.do(t, http.MethodPost, parts(0, "/link"), map[string]any{"ids": []uint{free}}, h), http.StatusOK) if !dueOn(t, free) { t.Fatal("a deferred link wrote the child before the save") } saved := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "d-" + env.stamp}, h) want(t, "save", saved, http.StatusCreated) parent := dataID(t, saved.Body.Bytes()) if p, _ := env.partRow(t, free); p.GadgetID == nil || *p.GadgetID != parent || dueOn(t, free) { t.Fatalf("committed part %+v, want owned by %d with due_on NULL", p, parent) } }) t.Run("without ClearOnChange nothing is cleared", func(t *testing.T) { plain := newDeferredEnv(t) pg := plain.gadget(t, "p-"+plain.stamp, false) free := plain.part(t, 0, "plain") if err := plain.db.Exec(`UPDATE cabana_deferred_parts SET due_on = '2026-11-01' WHERE id = ?`, free).Error; err != nil { t.Fatal(err) } want(t, "link", plain.a.do(t, http.MethodPost, dfPath(pg, "/relations/parts/link"), map[string]any{"ids": []uint{free}}, nil), http.StatusOK) want(t, "unlink", plain.a.do(t, http.MethodPost, dfPath(pg, "/relations/parts/unlink"), map[string]any{"ids": []uint{free}}, nil), http.StatusOK) if p, _ := plain.partRow(t, free); p.DueOn == nil { t.Fatalf("a contract without ClearOnChange cleared due_on: %+v", p) } }) }