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) } } // TestPendingPivotFillError: a deferred pivot value that no longer fits // the column is an error, not a silent zero (WR-04). func TestPendingPivotFillError(t *testing.T) { env := newDeferredEnv(t) key := newSessionKey(t) h := sk(key) m := env.member(t, "p-"+env.stamp+"@example.test") want(t, "link", env.a.do(t, http.MethodPost, dfPath(0, "/relations/members/link"), map[string]any{"ids": []uint{m}, "pivot": map[string]any{"note": "ok"}}, h), http.StatusOK) bad := `{"created":false,"pivot":{"note":true}}` if err := env.db.Model(&lagoon.DeferredBinding{}).Where("session_key = ? AND master_field = ?", key, "members").Update("pivot_data", bad).Error; err != nil { t.Fatal(err) } shown := env.a.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/members/pivot/%d", m)), nil, h) if shown.Code < 400 { t.Fatalf("show bad pivot: status=%d body=%s", shown.Code, shown.Body.String()) } edited := env.a.do(t, http.MethodPut, dfPath(0, fmt.Sprintf("/relations/members/pivot/%d", m)), map[string]any{"note": "x"}, h) if edited.Code < 400 { t.Fatalf("update bad pivot: status=%d body=%s", edited.Code, edited.Body.String()) } }