Files
summercms/modules/cabana/clear_on_change_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

120 lines
5.1 KiB
Go

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