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.
120 lines
5.1 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|