Files
summercms/modules/cabana/relation_child_test.go
Jakub Zych c8d41a68c6 test(12.2-05): cover every Go behaviour of phase 12.2
- lagoon: Date and TimeOfDay through JSON, text and real DATE/TIME
  columns; Fill text fallback without changing earlier conversions;
  required on zero dates; deferred_bindings shape, store isolation and
  envelope; PurgeDeferred cut-off, after-commit blobs, SKIP LOCKED,
  skipped types and the deferred:purge command
- attach: Store limits, extensions, MIME patterns, default lists, key
  shape and blob cleanup; IsAllowedImage formats, polyglots, ceiling
- conga and pact: framework purge schedule entry and forged jobs; the
  six relation child hook interfaces
- cabana: fileupload and datepicker compile, upload, remove, caption,
  reorder and bounds; deferred commit order, rollback, applied-only and
  concurrent saves; relation contracts, forms, CRUD, deferral, schema
2026-10-02 20:34:23 +02:00

589 lines
32 KiB
Go

package cabana_test
import (
"encoding/json"
"fmt"
"net/http"
"slices"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/pact"
)
// dfStrictPart is a part whose gadget_id is NOT NULL: its hasMany is not
// deferrable and cannot be unlinked.
type dfStrictPart struct {
ID uint `gorm:"column:id;primaryKey"`
GadgetID uint `gorm:"column:gadget_id"`
Label string `gorm:"column:label"`
DueOn *lagoon.Date `gorm:"column:due_on;type:date"`
CreatedAt time.Time `gorm:"column:created_at"`
}
func (dfStrictPart) TableName() string { return "cabana_deferred_parts" }
func (dfStrictPart) MorphName() string { return dfPartMorph }
func (dfStrictPart) Fillable() []string { return []string{"label", "due_on"} }
func (dfStrictPart) Rules() map[string]string { return map[string]string{} }
func (dfStrictPart) AttachRelations() []attach.Relation {
return []attach.Relation{{Name: "images", Many: true}, {Name: "sheet"}}
}
// withContracts is a plugin whose gadgets controller uses contracts.
func withContracts(fsys func(*testing.T) map[string]string, contracts func() []cabana.RelationContract) func(t *testing.T) dfPlugin {
return func(t *testing.T) dfPlugin {
p := dfPlugin{controllers: func(rec *dfRecorder) []pact.AdminController {
return []pact.AdminController{dfController{rec: rec, id: "acme.deferred.gadgets", dir: "controllers/gadgets", contracts: contracts}}
}}
if fsys != nil {
p.fsys = dfOverlay(t, fsys(t))
}
return p
}
}
// TestRelationChildContract: hasMany and belongsToMany contracts are
// validated at boot (D-11).
func TestRelationChildContract(t *testing.T) {
part := func(mut func(*cabana.RelationContract)) func() []cabana.RelationContract {
return func() []cabana.RelationContract {
cs := dfContracts()
mut(&cs[1])
return cs
}
}
member := func(mut func(*cabana.RelationContract)) func() []cabana.RelationContract {
return func() []cabana.RelationContract {
cs := dfContracts()
mut(&cs[0])
return cs
}
}
for name, tc := range map[string]struct {
contracts func() []cabana.RelationContract
want string
}{
"hasMany with a pivot": {part(func(c *cabana.RelationContract) { c.NewPivot = func() any { return &dfGadgetMember{} } }),
"relation parts: a hasMany contract cannot declare NewPivot, ParentForeignKey, RelatedForeignKey or HookPivotColumns"},
"hasMany with hook columns": {part(func(c *cabana.RelationContract) { c.HookPivotColumns = []string{"role"} }), "a hasMany contract cannot declare"},
"hasMany without ForeignKey": {part(func(c *cabana.RelationContract) { c.ForeignKey = "" }), "relation parts: hasMany needs a ForeignKey column"},
"hasMany text ForeignKey": {part(func(c *cabana.RelationContract) { c.ForeignKey = "label" }), "relation parts: ForeignKey label must be an integer column, found string"},
"hasMany missing ForeignKey": {part(func(c *cabana.RelationContract) { c.ForeignKey = "owner_id" }), "relation parts target is missing column owner_id"},
"unknown kind": {part(func(c *cabana.RelationContract) { c.Kind = "hasOne" }), `relation parts has unknown kind "hasOne"`},
"belongsToMany ForeignKey": {member(func(c *cabana.RelationContract) { c.ForeignKey = "gadget_id" }), "relation members: a belongsToMany contract cannot declare ForeignKey"},
"belongsToMany no pivot": {member(func(c *cabana.RelationContract) { c.NewPivot = nil }), "relation members requires target and pivot models"},
} {
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) {
t.Fatalf("err = %v, want %q", err, tc.want)
}
})
}
// A hasMany on a NOT NULL key (dfStrictPart) cannot declare unlink and
// is not deferrable.
strict := func() []cabana.RelationContract {
return append(dfContracts(), cabana.RelationContract{
Name: "strictparts", Kind: cabana.RelationHasMany, NewRelated: func() any { return &dfStrictPart{} },
ForeignKey: "gadget_id", Columns: map[string]string{"label": "label"},
})
}
strictFiles := func(buttons string) func(t *testing.T) map[string]string {
return func(t *testing.T) map[string]string {
return map[string]string{
"controllers/gadgets/config_relation.yaml": dfFile(t, "controllers/gadgets/config_relation.yaml") + `strictparts:
label: Strict parts
view:
list:
columns:
label:
label: Label
toolbarButtons: ` + buttons + `
manage:
form: $/acme/deferred/models/part/fields.yaml
`,
"models/gadget/fields.yaml": dfFile(t, "models/gadget/fields.yaml") + " strictparts:\n type: relation-manager\n relation: strictparts\n",
}
}
}
err := activateDeferred(t, withContracts(strictFiles("create|update|delete|link|unlink"), strict)(t), nil)
if err == nil || !strings.Contains(err.Error(), "relation strictparts: unlink needs a nullable ForeignKey gadget_id (a pointer field)") {
t.Fatalf("unlink on a NOT NULL key: %v", err)
}
env := newDeferredEnvWith(t, withContracts(strictFiles("create|update|delete|link"), strict)(t))
rec := env.a.do(t, http.MethodGet, "/acme/deferred/gadgets/schema/relation/strictparts", nil, nil)
var s cabana.Envelope[cabana.RelationSchema]
if err := json.Unmarshal(rec.Body.Bytes(), &s); err != nil || s.Data.Kind != cabana.RelationHasMany || s.Data.Deferrable {
t.Fatalf("NOT NULL hasMany schema = %s", rec.Body.String())
}
want(t, "create on id 0 of a non-deferrable relation", env.a.do(t, http.MethodPost, dfPath(0, "/relations/strictparts/records"), map[string]any{"label": "x"}, sk(newSessionKey(t))), http.StatusNotFound)
}
// TestRelationChildZeroKind: a contract with an empty Kind behaves exactly
// like an explicit belongsToMany: same schema kind, same linked and
// candidate lists, and link and unlink write the same pivot rows (D-11).
func TestRelationChildZeroKind(t *testing.T) {
explicit := func() []cabana.RelationContract {
cs := dfContracts()
cs[0].Kind = cabana.RelationBelongsToMany
return cs
}
env := newDeferredEnvWith(t, dfPlugin{controllers: func(rec *dfRecorder) []pact.AdminController {
return []pact.AdminController{
dfController{rec: rec, id: "acme.deferred.gadgets", dir: "controllers/gadgets"},
dfController{rec: rec, id: "acme.deferred.explicit", dir: "controllers/gadgets", contracts: explicit},
}
}})
if dfContracts()[0].Kind != "" {
t.Fatal("the fixture's members contract must use the empty Kind")
}
g := env.gadget(t, "g-"+env.stamp, false)
m1 := env.member(t, "a-"+env.stamp+"@example.test")
m2 := env.member(t, "b-"+env.stamp+"@example.test")
m3 := env.member(t, "c-"+env.stamp+"@example.test")
env.pivot(t, g, m1, "seed")
path := func(ctl, rest string) string {
return fmt.Sprintf("/acme/deferred/%s/%d/relations/members%s", ctl, g, rest)
}
body := func(ctl, rest string) string {
rec := env.a.do(t, http.MethodGet, path(ctl, rest), nil, nil)
want(t, ctl+rest, rec, http.StatusOK)
return rec.Body.String()
}
for _, rest := range []string{"", "/candidates", "?sort=email&dir=desc", "/candidates?search=b-"} {
if a, b := body("gadgets", rest), body("explicit", rest); a != b {
t.Fatalf("%s differs:\nzero kind %s\nexplicit %s", rest, a, b)
}
}
schema := func(ctl string) (kind string, deferrable bool) {
rec := env.a.do(t, http.MethodGet, "/acme/deferred/"+ctl+"/schema/relation/members", nil, nil)
var s cabana.Envelope[cabana.RelationSchema]
if err := json.Unmarshal(rec.Body.Bytes(), &s); err != nil {
t.Fatal(err)
}
return s.Data.Kind, s.Data.Deferrable
}
if k, d := schema("gadgets"); k != cabana.RelationBelongsToMany || !d {
t.Fatalf("zero kind schema %s %v", k, d)
}
if k, d := schema("explicit"); k != cabana.RelationBelongsToMany || !d {
t.Fatalf("explicit schema %s %v", k, d)
}
want(t, "link through the zero kind", env.a.do(t, http.MethodPost, path("gadgets", "/link"), map[string]any{"ids": []uint{m2}}, nil), http.StatusOK)
want(t, "link through the explicit kind", env.a.do(t, http.MethodPost, path("explicit", "/link"), map[string]any{"ids": []uint{m3}}, nil), http.StatusOK)
var rows []dfGadgetMember
if err := env.db.Where("gadget_id = ?", g).Order("member_id").Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 3 || rows[1].Role != "linked" || rows[2].Role != "linked" {
t.Fatalf("pivot rows %+v", rows)
}
want(t, "unlink through the explicit kind", env.a.do(t, http.MethodPost, path("explicit", "/unlink"), map[string]any{"ids": []uint{m2}}, nil), http.StatusOK)
want(t, "unlink through the zero kind", env.a.do(t, http.MethodPost, path("gadgets", "/unlink"), map[string]any{"ids": []uint{m3}}, nil), http.StatusOK)
if a, b := body("gadgets", ""), body("explicit", ""); a != b || !strings.Contains(a, fmt.Sprintf(`"id":%d`, m1)) || strings.Contains(a, fmt.Sprintf(`"id":%d,`, m2)) {
t.Fatalf("after unlink: %s / %s", a, b)
}
}
// relationYAML edits the gadgets config_relation.yaml.
func relationYAML(t *testing.T, edit func(string) string) map[string]string {
return map[string]string{"controllers/gadgets/config_relation.yaml": edit(dfFile(t, "controllers/gadgets/config_relation.yaml"))}
}
// TestRelationChildForms: relation forms compile from manage.form,
// view.form or the top-level form, resolve $/ paths inside the plugin
// only, refuse the D-23 field types and server-owned columns, and enforce
// the toolbar rules (D-11, D-12, D-16, D-23).
func TestRelationChildForms(t *testing.T) {
partFields := func(extra string) map[string]string {
return map[string]string{"models/part/fields.yaml": "fields:\n label:\n type: text\n" + extra}
}
pivotFields := func(extra string) map[string]string {
return map[string]string{"models/member/pivot_fields.yaml": "fields:\n pivot[note]:\n type: text\n" + extra}
}
replace := func(old, new string) func(t *testing.T) map[string]string {
return func(t *testing.T) map[string]string {
return relationYAML(t, func(s string) string {
if !strings.Contains(s, old) {
t.Fatalf("fixture lacks %q", old)
}
return strings.Replace(s, old, new, 1)
})
}
}
for name, tc := range map[string]struct {
files func(t *testing.T) map[string]string
want string
}{
"relation field": {func(*testing.T) map[string]string { return partFields(" owner:\n type: relation\n") }, "field owner: type relation is not supported in a relation form (manage)"},
"relation-manager field": {func(*testing.T) map[string]string {
return partFields(" more:\n type: relation-manager\n relation: x\n")
}, "type relation-manager is not supported in a relation form"},
"widget field": {func(*testing.T) map[string]string {
return partFields(" w:\n type: widget\n widget: acme-x\n action: lookup\n")
}, "field w: type widget is not supported in a relation form (manage)"},
"partial field": {func(*testing.T) map[string]string {
return partFields(" p:\n type: partial\n path: x\n")
}, "type partial is not supported in a relation form"},
"foreign key field": {func(*testing.T) map[string]string { return partFields(" gadget_id:\n type: number\n") }, "field gadget_id is the relation's ForeignKey; the server sets it"},
"pivot foreign key": {func(*testing.T) map[string]string {
return pivotFields(" pivot[gadget_id]:\n type: number\n")
}, "field gadget_id is a server-owned pivot column"},
"pivot hook column": {func(*testing.T) map[string]string { return pivotFields(" pivot[role]:\n type: text\n") }, "field role is a server-owned pivot column"},
"pivot fileupload": {func(*testing.T) map[string]string { return pivotFields(" pivot[doc]:\n type: fileupload\n") }, "type fileupload is not supported in a pivot form"},
"cross-plugin path": {replace("form: $/acme/deferred/models/part/fields.yaml", "form: $/acme/other/models/part/fields.yaml"), "relation parts manage form"},
"missing form file": {replace("form: $/acme/deferred/models/part/fields.yaml", "form: $/acme/deferred/models/part/nothere.yaml"), "relation parts manage form"},
"create without a form": {replace(" manage:\n form: $/acme/deferred/models/part/fields.yaml\n", " manage:\n"), "relation parts: toolbar buttons create and update need manage.form (or a top-level form)"},
"unknown button": {replace("create|update|delete|link|unlink\n manage:\n form: $/acme/deferred/models/part", "create|edit\n manage:\n form: $/acme/deferred/models/part"), "unsupported relation action edit"},
"duplicate button": {replace("create|update|delete|link|unlink\n manage:\n form: $/acme/deferred/models/part", "link|link\n manage:\n form: $/acme/deferred/models/part"), "duplicate relation action link"},
"manage panel button": {replace(" manage:\n form: $/acme/deferred/models/part/fields.yaml\n", " manage:\n form: $/acme/deferred/models/part/fields.yaml\n toolbarButtons: create\n"), "manage panel cannot declare create (only link)"},
"pivot on hasMany": {func(t *testing.T) map[string]string {
return relationYAML(t, func(s string) string { return s + " pivot:\n form: models/member/pivot_fields.yaml\n" })
}, "relation parts: pivot.form is only valid on a belongsToMany relation"},
"pivot without a form": {replace(" pivot:\n form: $/acme/deferred/models/member/pivot_fields.yaml\n", " pivot:\n form: ''\n"), "relation members: pivot needs a form"},
} {
t.Run(name, func(t *testing.T) {
err := activateDeferred(t, dfPlugin{fsys: dfOverlay(t, tc.files(t))}, nil)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want %q", err, tc.want)
}
})
}
// The top-level form is the manage and the view form; a view.form
// alone lets the relation preview its rows read-only.
fallback := relationYAML(t, func(s string) string {
s = strings.Replace(s, " manage:\n form: $/acme/deferred/models/part/fields.yaml\n", " form: $/acme/deferred/models/part/fields.yaml\n manage:\n", 1)
return strings.Replace(s, " toolbarButtons: create|update|delete|link|unlink\n showSearch: true\n manage:\n form: $/acme/deferred/models/member/fields.yaml\n",
" toolbarButtons: link|unlink\n showSearch: true\n form: $/acme/deferred/models/member/fields.yaml\n manage:\n", 1)
})
env := newDeferredEnvWith(t, dfPlugin{fsys: dfOverlay(t, fallback)})
rec := env.a.do(t, http.MethodGet, "/acme/deferred/gadgets/schema/relation/parts", nil, nil)
var s cabana.Envelope[cabana.RelationSchema]
if err := json.Unmarshal(rec.Body.Bytes(), &s); err != nil || len(s.Data.ManageForm) == 0 || len(s.Data.ViewForm) == 0 {
t.Fatalf("top-level form fallback: %s", rec.Body.String())
}
g := env.gadget(t, "g-"+env.stamp, false)
m := env.member(t, "m-"+env.stamp+"@example.test")
env.pivot(t, g, m, "n")
shown := env.a.do(t, http.MethodGet, dfPath(g, fmt.Sprintf("/relations/members/records/%d", m)), nil, nil)
want(t, "preview through view.form", shown, http.StatusOK)
if !strings.Contains(shown.Body.String(), `"email":"m-`) {
t.Fatalf("preview = %s", shown.Body.String())
}
want(t, "update without the update button", env.a.do(t, http.MethodPut, dfPath(g, fmt.Sprintf("/relations/members/records/%d", m)), map[string]any{"email": "x"}, nil), http.StatusForbidden)
}
// TestRelationChildCRUD walks create, show, update and delete for both
// kinds on a saved gadget: hasMany children get the parent's key from the
// server, hasMany link adopts free rows only and unlink clears the key, a
// hasMany delete runs the model's hooks and soft delete, a belongsToMany
// delete removes the pivot row before the related record, and the relation
// hooks run in order around each write (D-11, D-12, D-14, D-16).
func TestRelationChildCRUD(t *testing.T) {
env := newDeferredEnv(t)
g := env.gadget(t, "g-"+env.stamp, false)
other := env.gadget(t, "o-"+env.stamp, false)
parts := func(rest string) string { return dfPath(g, "/relations/parts"+rest) }
members := func(rest string) string { return dfPath(g, "/relations/members"+rest) }
t.Run("hasMany", func(t *testing.T) {
env.rec.reset()
created := env.a.do(t, http.MethodPost, parts("/records"), map[string]any{"label": "wheel", "due_on": "2026-11-01", "gadget_id": other}, nil)
want(t, "create", created, http.StatusCreated)
id := dataID(t, created.Body.Bytes())
if p, _ := env.partRow(t, id); p.GadgetID == nil || *p.GadgetID != g || p.DueOn == nil || p.DueOn.String() != "2026-11-01" {
t.Fatalf("created part %+v", p)
}
want(t, "create below the part's minDate", env.a.do(t, http.MethodPost, parts("/records"), map[string]any{"label": "old", "due_on": "2019-01-01"}, nil), http.StatusUnprocessableEntity)
shown := env.a.do(t, http.MethodGet, parts(fmt.Sprintf("/records/%d", id)), nil, nil)
want(t, "show", shown, http.StatusOK)
if !strings.Contains(shown.Body.String(), `"label":"wheel"`) {
t.Fatalf("show = %s", shown.Body.String())
}
want(t, "update", env.a.do(t, http.MethodPut, parts(fmt.Sprintf("/records/%d", id)), map[string]any{"label": "tyre"}, nil), http.StatusOK)
if p, _ := env.partRow(t, id); p.Label != "tyre" || *p.GadgetID != g {
t.Fatalf("updated part %+v", p)
}
free := env.part(t, 0, "free")
owned := env.part(t, other, "owned")
cands := dfIDs(t, env.a.do(t, http.MethodGet, parts("/candidates"), nil, nil))
if !slices.Contains(cands, free) || slices.Contains(cands, owned) || slices.Contains(cands, id) {
t.Fatalf("candidates %v (free %d owned %d own %d)", cands, free, owned, id)
}
want(t, "link an owned part", env.a.do(t, http.MethodPost, parts("/link"), map[string]any{"ids": []uint{owned}}, nil), http.StatusUnprocessableEntity)
want(t, "link a free part", env.a.do(t, http.MethodPost, parts("/link"), map[string]any{"ids": []uint{free}}, nil), http.StatusOK)
if p, _ := env.partRow(t, free); p.GadgetID == nil || *p.GadgetID != g {
t.Fatalf("linked part %+v", p)
}
want(t, "unlink", env.a.do(t, http.MethodPost, parts("/unlink"), map[string]any{"ids": []uint{free}}, nil), http.StatusOK)
if p, ok := env.partRow(t, free); !ok || p.GadgetID != nil || p.DeletedAt.Valid {
t.Fatalf("unlinked part %+v (present %v), want kept with a NULL key", p, ok)
}
dfPartHooks.reset()
want(t, "delete", env.a.do(t, http.MethodPost, parts("/delete"), map[string]any{"ids": []uint{id}}, nil), http.StatusOK)
if p, ok := env.partRow(t, id); !ok || !p.DeletedAt.Valid {
t.Fatalf("deleted part %+v (present %v), want soft deleted", p, ok)
}
if got := dfPartHooks.snapshot(); fmt.Sprint(got) != fmt.Sprintf("[BeforeDelete:%d]", id) {
t.Fatalf("model hooks %v", got)
}
wantCalls := []string{
"RelationBeforeCreate:parts", "RelationAfterCreate:parts",
"RelationBeforeUpdate:parts", "RelationAfterUpdate:parts",
"RelationBeforeDelete:parts", "RelationAfterDelete:parts",
}
if got := env.rec.snapshot(); fmt.Sprint(got) != fmt.Sprint(wantCalls) {
t.Fatalf("relation hooks %v\nwant %v", got, wantCalls)
}
want(t, "show a deleted part", env.a.do(t, http.MethodGet, parts(fmt.Sprintf("/records/%d", id)), nil, nil), http.StatusNotFound)
})
t.Run("belongsToMany", func(t *testing.T) {
// A real foreign key from the pivot to the member proves the delete
// order: deleting the member first would violate it.
if err := env.db.Exec(`ALTER TABLE cabana_deferred_gadget_members ADD CONSTRAINT df_pivot_member FOREIGN KEY (member_id) REFERENCES cabana_deferred_members (id)`).Error; err != nil {
t.Fatal(err)
}
env.rec.reset()
created := env.a.do(t, http.MethodPost, members("/records"), map[string]any{"email": "new-" + env.stamp + "@example.test"}, nil)
want(t, "create", created, http.StatusCreated)
id := dataID(t, created.Body.Bytes())
var row dfGadgetMember
if err := env.db.Where("gadget_id = ? AND member_id = ?", g, id).Take(&row).Error; err != nil || row.Role != "linked" {
t.Fatalf("pivot of a created member %+v (%v)", row, err)
}
want(t, "update", env.a.do(t, http.MethodPut, members(fmt.Sprintf("/records/%d", id)), map[string]any{"email": "renamed-" + env.stamp + "@example.test"}, nil), http.StatusOK)
want(t, "delete", env.a.do(t, http.MethodPost, members("/delete"), map[string]any{"ids": []uint{id}}, nil), http.StatusOK)
var n int64
env.db.Model(&dfMember{}).Where("id = ?", id).Count(&n)
var p int64
env.db.Model(&dfGadgetMember{}).Where("member_id = ?", id).Count(&p)
if n != 0 || p != 0 {
t.Fatalf("after delete: members %d pivots %d", n, p)
}
wantCalls := []string{
"RelationBeforeCreate:members", "RelationBeforeLink:members", "RelationAfterCreate:members",
"RelationBeforeUpdate:members", "RelationAfterUpdate:members",
"RelationBeforeDelete:members", "RelationAfterDelete:members",
}
if got := env.rec.snapshot(); fmt.Sprint(got) != fmt.Sprint(wantCalls) {
t.Fatalf("relation hooks %v\nwant %v", got, wantCalls)
}
m1 := env.member(t, "p1-"+env.stamp+"@example.test")
m2 := env.member(t, "p2-"+env.stamp+"@example.test")
want(t, "link two with pivot values", env.a.do(t, http.MethodPost, members("/link"), map[string]any{"ids": []uint{m1, m2}, "pivot": map[string]any{"note": "x"}}, nil), http.StatusUnprocessableEntity)
want(t, "link with an unknown pivot key", env.a.do(t, http.MethodPost, members("/link"), map[string]any{"ids": []uint{m1}, "pivot": map[string]any{"colour": "x"}}, nil), http.StatusUnprocessableEntity)
want(t, "link with a note", env.a.do(t, http.MethodPost, members("/link"), map[string]any{"ids": []uint{m1}, "pivot": map[string]any{"note": "hello"}}, nil), http.StatusOK)
var linked dfGadgetMember
if err := env.db.Where("gadget_id = ? AND member_id = ?", g, m1).Take(&linked).Error; err != nil || linked.Note != "hello" || linked.Role != "linked" {
t.Fatalf("pivot row %+v (%v)", linked, err)
}
pivot := env.a.do(t, http.MethodGet, members(fmt.Sprintf("/pivot/%d", m1)), nil, nil)
want(t, "pivot show", pivot, http.StatusOK)
var pv struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(pivot.Body.Bytes(), &pv); err != nil || pv.Data["note"] != "hello" || pv.Data["id"] != float64(m1) || len(pv.Data) != 2 {
t.Fatalf("pivot show = %s", pivot.Body.String())
}
want(t, "pivot of an unlinked member", env.a.do(t, http.MethodGet, members(fmt.Sprintf("/pivot/%d", m2)), nil, nil), http.StatusNotFound)
})
t.Run("hook failure rolls back", func(t *testing.T) {
env.rec.reset()
env.rec.failOn("RelationAfterCreate:parts")
before := env.state(t)
rec := env.a.do(t, http.MethodPost, parts("/records"), map[string]any{"label": "doomed"}, nil)
if rec.Code/100 == 2 {
t.Fatalf("a failing hook answered %d", rec.Code)
}
env.unchanged(t, "a create whose hook failed", before)
id := env.part(t, g, "stays")
env.rec.reset()
env.rec.failOn("RelationAfterUpdate:parts")
before = env.state(t)
if rec := env.a.do(t, http.MethodPut, parts(fmt.Sprintf("/records/%d", id)), map[string]any{"label": "changed"}, nil); rec.Code/100 == 2 {
t.Fatalf("a failing update hook answered %d", rec.Code)
}
env.unchanged(t, "an update whose hook failed", before)
env.rec.reset()
env.rec.failOn("RelationBeforeDelete:parts")
if rec := env.a.do(t, http.MethodPost, parts("/delete"), map[string]any{"ids": []uint{id}}, nil); rec.Code/100 == 2 {
t.Fatalf("a failing delete hook answered %d", rec.Code)
}
env.unchanged(t, "a delete whose hook failed", before)
env.rec.reset()
})
t.Run("body cap", func(t *testing.T) {
huge := strings.Repeat("x", 1100<<10)
want(t, "child body past default_bytes", env.a.do(t, http.MethodPost, parts("/records"), map[string]any{"label": huge}, nil), http.StatusRequestEntityTooLarge)
})
}
// TestRelationChildDeferred manages both relations on a gadget not saved
// yet (D-03, D-04, D-17): created, linked, unlinked and deleted children
// and pivot edits are held against the session key, a child form's files
// against its own child key, and the gadget's create save applies them all.
func TestRelationChildDeferred(t *testing.T) {
env := newDeferredEnv(t)
key := newSessionKey(t)
h := sk(key)
parts := func(rest string) string { return dfPath(0, "/relations/parts"+rest) }
members := func(rest string) string { return dfPath(0, "/relations/members"+rest) }
// A part created in the session, with an image uploaded to child 0
// under its own child key first.
childKey := newSessionKey(t)
both := map[string]string{cabana.SessionKeyHeader: key, cabana.ChildSessionKeyHeader: childKey}
up := env.a.upload(t, parts("/records/0/files/images"), "c.png", conformPNG(t), both)
want(t, "child 0 upload", up, http.StatusCreated)
sheet := env.a.upload(t, parts("/records/0/files/sheet"), "s.txt", []byte("sheet"), both)
want(t, "child 0 attachOne upload", sheet, http.StatusCreated)
want(t, "child 0 without the child key", env.a.upload(t, parts("/records/0/files/images"), "c.png", conformPNG(t), h), http.StatusUnprocessableEntity)
created := env.a.do(t, http.MethodPost, parts("/records"), map[string]any{"label": "pending"}, both)
want(t, "deferred create", created, http.StatusCreated)
pending := dataID(t, created.Body.Bytes())
for _, f := range []uint{dataID(t, up.Body.Bytes()), dataID(t, sheet.Body.Bytes())} {
if row, _ := env.fileRow(t, f); row.AttachmentType != dfPartMorph || row.AttachmentID != fmt.Sprint(pending) {
t.Fatalf("child file %d attached to %s/%s", f, row.AttachmentType, row.AttachmentID)
}
}
if p, _ := env.partRow(t, pending); p.GadgetID != nil {
t.Fatalf("pending part owned by %d", *p.GadgetID)
}
// An existing free part linked, another created then unlinked (it is
// deleted), a third created then deleted.
free := env.part(t, 0, "free")
stray := env.part(t, 0, "stray")
if got := dfIDs(t, env.a.do(t, http.MethodGet, parts("/candidates"), nil, h)); !slices.Contains(got, free) || slices.Contains(got, pending) {
t.Fatalf("candidates %v", got)
}
want(t, "deferred link", env.a.do(t, http.MethodPost, parts("/link"), map[string]any{"ids": []uint{free, stray}}, h), http.StatusOK)
want(t, "deferred unlink of a linked part", env.a.do(t, http.MethodPost, parts("/unlink"), map[string]any{"ids": []uint{stray}}, h), http.StatusOK)
if p, ok := env.partRow(t, stray); !ok || p.GadgetID != nil || p.DeletedAt.Valid {
t.Fatalf("unlinking a linked existing part touched it: %+v", p)
}
dropped := dataID(t, env.a.do(t, http.MethodPost, parts("/records"), map[string]any{"label": "dropped"}, h).Body.Bytes())
want(t, "deferred unlink of a created part", env.a.do(t, http.MethodPost, parts("/unlink"), map[string]any{"ids": []uint{dropped}}, h), http.StatusOK)
if _, ok := env.partRow(t, dropped); ok {
if p, _ := env.partRow(t, dropped); !p.DeletedAt.Valid {
t.Fatal("an unlinked created part is still live")
}
}
deleted := dataID(t, env.a.do(t, http.MethodPost, parts("/records"), map[string]any{"label": "deleted"}, h).Body.Bytes())
want(t, "deferred delete", env.a.do(t, http.MethodPost, parts("/delete"), map[string]any{"ids": []uint{deleted}}, h), http.StatusOK)
if p, ok := env.partRow(t, deleted); ok && !p.DeletedAt.Valid {
t.Fatal("a deleted pending part is still live")
}
if got := dfIDs(t, env.a.do(t, http.MethodGet, parts(""), nil, h)); len(got) != 2 || !slices.Contains(got, pending) || !slices.Contains(got, free) {
t.Fatalf("pending parts %v, want [%d %d]", got, pending, free)
}
// A member linked with a note, the note edited, then a server-owned
// key refused.
m := env.member(t, "m-"+env.stamp+"@example.test")
want(t, "deferred member link", env.a.do(t, http.MethodPost, members("/link"), map[string]any{"ids": []uint{m}, "pivot": map[string]any{"note": "a"}}, h), http.StatusOK)
want(t, "deferred pivot edit", env.a.do(t, http.MethodPut, members(fmt.Sprintf("/pivot/%d", m)), map[string]any{"note": "b"}, h), http.StatusOK)
want(t, "deferred pivot edit of a hook column", env.a.do(t, http.MethodPut, members(fmt.Sprintf("/pivot/%d", m)), map[string]any{"role": "admin"}, h), http.StatusUnprocessableEntity)
shown := env.a.do(t, http.MethodGet, members(fmt.Sprintf("/pivot/%d", m)), nil, h)
if !strings.Contains(shown.Body.String(), `"note":"b"`) {
t.Fatalf("pending pivot = %s", shown.Body.String())
}
// A member created in the session.
newMember := dataID(t, env.a.do(t, http.MethodPost, members("/records"), map[string]any{"email": "n-" + env.stamp + "@example.test"}, h).Body.Bytes())
saved := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "g-" + env.stamp}, h)
want(t, "save", saved, http.StatusCreated)
g := dataID(t, saved.Body.Bytes())
for _, id := range []uint{pending, free} {
if p, _ := env.partRow(t, id); p.GadgetID == nil || *p.GadgetID != g {
t.Fatalf("part %d owner %v, want %d", id, p.GadgetID, g)
}
}
if p, _ := env.partRow(t, stray); p.GadgetID != nil {
t.Fatal("the unlinked part was attached")
}
var rows []dfGadgetMember
if err := env.db.Where("gadget_id = ?", g).Order("member_id").Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 2 || rows[0].MemberID != m || rows[0].Note != "b" || rows[0].Role != "linked" || rows[1].MemberID != newMember || rows[1].Role != "linked" {
t.Fatalf("pivot rows %+v", rows)
}
if n := len(env.state(t).Bindings); n != 0 {
t.Fatalf("bindings left %d", n)
}
// On the saved gadget a child's files commit with the child's own save.
ck2 := newSessionKey(t)
up = env.a.upload(t, dfPath(g, "/relations/parts/records/0/files/images"), "d.png", conformPNG(t), ck(ck2))
want(t, "child 0 upload under a saved gadget", up, http.StatusCreated)
child := env.a.do(t, http.MethodPost, dfPath(g, "/relations/parts/records"), map[string]any{"label": "with image"}, ck(ck2))
want(t, "child create", child, http.StatusCreated)
if row, _ := env.fileRow(t, dataID(t, up.Body.Bytes())); row.AttachmentID != fmt.Sprint(dataID(t, child.Body.Bytes())) {
t.Fatalf("child file attached to %q", row.AttachmentID)
}
}
// TestRelationChildSchema: the relation schema carries kind and
// deferrable, the relation-manager form field carries deferrable, and a
// belongsToMany manager is deferrable on the create screen unless its
// field says context: update (user decision, 12.2-04).
func TestRelationChildSchema(t *testing.T) {
env := newDeferredEnv(t)
for name, wantKind := range map[string]string{"parts": cabana.RelationHasMany, "members": cabana.RelationBelongsToMany} {
rec := env.a.do(t, http.MethodGet, "/acme/deferred/gadgets/schema/relation/"+name, nil, nil)
var s cabana.Envelope[cabana.RelationSchema]
if err := json.Unmarshal(rec.Body.Bytes(), &s); err != nil || s.Data.Kind != wantKind || !s.Data.Deferrable || len(s.Data.ManageForm) == 0 {
t.Fatalf("%s schema = %s", name, rec.Body.String())
}
}
form := env.a.do(t, http.MethodGet, "/acme/deferred/gadgets/schema/form", nil, nil)
var f cabana.Envelope[cabana.FormView]
if err := json.Unmarshal(form.Body.Bytes(), &f); err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(f.Data)
for _, name := range []string{"parts", "members"} {
if !strings.Contains(string(raw), `"name":"`+name+`"`) {
t.Fatalf("form lacks %s", name)
}
}
if strings.Count(string(raw), `"deferrable":true`) < 2 {
t.Fatalf("relation-manager fields are not deferrable: %s", raw)
}
fields := strings.Replace(dfFile(t, "models/gadget/fields.yaml"), " relation: members\n", " relation: members\n context: update\n", 1)
upd := newDeferredEnvWith(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": fields})})
key := newSessionKey(t)
m := upd.member(t, "m-"+upd.stamp+"@example.test")
want(t, "members link on id 0 with context: update", upd.a.do(t, http.MethodPost, dfPath(0, "/relations/members/link"), map[string]any{"ids": []uint{m}}, sk(key)), http.StatusNotFound)
want(t, "parts still deferrable", upd.a.do(t, http.MethodPost, dfPath(0, "/relations/parts/records"), map[string]any{"label": "x"}, sk(key)), http.StatusCreated)
}
// TestRelationChildPurgeModels: a deferrable relation that creates children
// stops boot when no plugin lists its related model in Models(), so
// deferred:purge can always delete abandoned children (Pitfall 9).
func TestRelationChildPurgeModels(t *testing.T) {
err := activateDeferred(t, dfPlugin{noModels: true}, nil)
if err == nil || !strings.Contains(err.Error(), "records under deferral, but no activated plugin lists that model in Models()") {
t.Fatalf("err = %v", err)
}
if err := activateDeferred(t, dfPlugin{}, nil); err != nil {
t.Fatalf("listed models failed boot: %v", err)
}
}