feat(12.2-03): defer relation work on unsaved records and add child file routes

- record id 0 with X-Session-Key manages deferrable relations: create, link, unlink, delete and pivot edits are held in deferred_bindings
- the record's create save applies relation bindings with the file bindings; an ineligible link is a 422 on the relation-manager field
- child forms upload files through .../records/{child}/files/{field} keyed by X-Child-Session-Key; the child save commits them
- boot refuses a deferrable relation with create whose related model no plugin lists in Models()
This commit is contained in:
Jakub Zych
2026-10-02 19:08:16 +02:00
parent afb05b6ee4
commit fe9e8baaf1
16 changed files with 3643 additions and 436 deletions

View File

@@ -236,6 +236,36 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/%d", e.gadgetID, e.partID), nil, true)
}, into[cabana.RecordEnvelope](), nil},
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}", 201, "cabana.Envelope-cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.childUpload(t, e.gadgetID, e.partID, "images", "part.png", conformPNG(t), e.childKey)
e.partFileID = dataID(t, rec.Body.Bytes())
return rec
}, into[cabana.Envelope[cabana.FileItem]](), nil},
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}", 200, "cabana.Envelope-array_cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.sendWith(t, http.MethodGet, e.childFilePath(""), nil, "", map[string]string{cabana.ChildSessionKeyHeader: e.childKey})
var body cabana.Envelope[[]cabana.FileItem]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body.Data) != 1 || !body.Data[0].Pending || body.Data[0].URL != "" {
t.Fatalf("pending child file list = %s (%v)", rec.Body.String(), err)
}
return rec
}, into[cabana.Envelope[[]cabana.FileItem]](), nil},
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/reorder", 200, "cabana.Envelope-array_cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]any{"ids": []uint{e.partFileID}})
return e.sendWith(t, http.MethodPost, e.childFilePath("/reorder"), body, "application/json", map[string]string{cabana.ChildSessionKeyHeader: e.childKey})
}, into[cabana.Envelope[[]cabana.FileItem]](), nil},
{"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}", 200, "cabana.Envelope-cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]any{"title": "Part " + e.stamp})
return e.sendWith(t, http.MethodPut, e.childFilePath(fmt.Sprintf("/%d", e.partFileID)), body, "application/json", map[string]string{cabana.ChildSessionKeyHeader: e.childKey})
}, into[cabana.Envelope[cabana.FileItem]](), nil},
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/download", 200, "", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.sendWith(t, http.MethodGet, e.childFilePath(fmt.Sprintf("/%d/download", e.partFileID)), nil, "", map[string]string{cabana.ChildSessionKeyHeader: e.childKey})
}, nil, binaryFile("image/png")},
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/thumb", 200, "", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.sendWith(t, http.MethodGet, e.childFilePath(fmt.Sprintf("/%d/thumb", e.partFileID)), nil, "", map[string]string{cabana.ChildSessionKeyHeader: e.childKey})
}, nil, binaryFile("image/png")},
{"DELETE /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}", 200, "cabana.Envelope-cabana_FileMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.sendWith(t, http.MethodDelete, e.childFilePath(fmt.Sprintf("/%d", e.partFileID)), nil, "", map[string]string{cabana.ChildSessionKeyHeader: e.childKey})
}, into[cabana.Envelope[cabana.FileMutationResult]](), nil},
{"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/%d", e.gadgetID, e.partID), map[string]any{"label": "part-" + e.stamp + "-renamed"}, true)
}, into[cabana.RecordEnvelope](), nil},
@@ -362,6 +392,8 @@ type conformEnv struct {
memberID uint
gadgetID uint
partID uint
partFileID uint
childKey string
}
// sendWith sends a raw body with extra headers through the assembled router.
@@ -402,6 +434,35 @@ func (e *conformEnv) upload(t *testing.T, id uint, field, name string, data []by
return e.sendWith(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/files/%s", id, field), buf.Bytes(), mw.FormDataContentType(), headers)
}
// childFilePath is the conformance part's file route under the gadget's
// parts relation, with rest appended.
func (e *conformEnv) childFilePath(rest string) string {
return fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/%d/files/images%s", e.gadgetID, e.partID, rest)
}
// childUpload posts one multipart file_data part to a part's file field
// under a gadget's parts relation (child 0 is the part being created).
func (e *conformEnv) childUpload(t *testing.T, gadget, part uint, field, name string, data []byte, childKey string, extra ...string) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
w, err := mw.CreateFormFile("file_data", name)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(data); err != nil {
t.Fatal(err)
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
headers := map[string]string{cabana.ChildSessionKeyHeader: childKey}
if len(extra) > 0 {
headers[cabana.SessionKeyHeader] = extra[0]
}
return e.sendWith(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/%d/files/%s", gadget, part, field), buf.Bytes(), mw.FormDataContentType(), headers)
}
// conformPNG is a small valid PNG.
func conformPNG(t *testing.T) []byte {
t.Helper()
@@ -504,10 +565,10 @@ func newConformEnv(t *testing.T) *conformEnv {
}
// Gadget ids restart with the recreated table: drop the files and
// bindings an earlier run left for them.
if err := gdb.Exec(`DELETE FROM system_files WHERE attachment_type = 'acme.conform.gadget' OR attachment_id IS NULL OR attachment_id = ''`).Error; err != nil {
if err := gdb.Exec(`DELETE FROM system_files WHERE attachment_type IN ('acme.conform.gadget', 'acme.conform.part') OR attachment_id IS NULL OR attachment_id = ''`).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Exec(`DELETE FROM deferred_bindings WHERE master_type = 'acme.conform.gadget'`).Error; err != nil {
if err := gdb.Exec(`DELETE FROM deferred_bindings WHERE master_type IN ('acme.conform.gadget', 'acme.conform.part')`).Error; err != nil {
t.Fatal(err)
}
stamp := fmt.Sprintf("c%d", time.Now().UnixNano())
@@ -552,7 +613,7 @@ func newConformEnv(t *testing.T) *conformEnv {
if err != nil {
t.Fatal(err)
}
return &conformEnv{h: h, db: gdb, bucket: bucket, login: login, stamp: stamp, sessionKey: newSessionKey(t), groupID: group.ID, memberID: member.ID}
return &conformEnv{h: h, db: gdb, bucket: bucket, login: login, stamp: stamp, sessionKey: newSessionKey(t), childKey: newSessionKey(t), groupID: group.ID, memberID: member.ID}
}
type conformGadget struct {
@@ -622,9 +683,15 @@ type conformPart struct {
}
func (conformPart) TableName() string { return "cabana_conform_parts" }
func (conformPart) MorphName() string { return "acme.conform.part" }
func (conformPart) Fillable() []string { return []string{"label"} }
func (conformPart) Rules() map[string]string { return map[string]string{"label": "required"} }
// AttachRelations declares the part form's protected attachMany images.
func (conformPart) AttachRelations() []attach.Relation {
return []attach.Relation{{Name: "images", Many: true}}
}
type conformSettings struct {
ID uint `gorm:"column:id;primaryKey"`
Enabled bool `gorm:"column:enabled"`
@@ -643,6 +710,11 @@ func (conformPlugin) Boot(*backpack.App) error { return nil }
func (p conformPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{conformController{stamp: p.stamp}}
}
// Models lists the models deferred:purge may delete; the parts relation
// creates parts under deferral, so cabana's boot requires conformPart here.
func (conformPlugin) Models() []any { return []any{&conformGadget{}, &conformPart{}} }
func (conformPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.conform.access", Roles: []string{"developer"}}}
}
@@ -670,6 +742,17 @@ func (conformController) AdminRelationContracts() []cabana.RelationContract {
Name: "members", NewRelated: func() any { return &conformMember{} }, NewPivot: func() any { return &conformGadgetMember{} },
ParentForeignKey: "gadget_id", RelatedForeignKey: "member_id", Columns: map[string]string{"email": "email"},
HookPivotColumns: []string{"stamp"},
// A gadget named exclude-<id>-... may not link member <id>, so a
// link the create form deferred can turn ineligible at save.
ExcludedRelatedIDs: func(parent any) ([]uint, error) {
var id uint
if g, ok := parent.(*conformGadget); ok && g != nil {
if _, err := fmt.Sscanf(g.Name, "exclude-%d-", &id); err == nil {
return []uint{id}, nil
}
}
return nil, nil
},
}, {
Name: "parts", Kind: cabana.RelationHasMany, NewRelated: func() any { return &conformPart{} },
ForeignKey: "gadget_id", Columns: map[string]string{"label": "label"},
@@ -852,7 +935,6 @@ parts:
members:
type: relation-manager
relation: members
context: [update]
parts:
type: relation-manager
relation: parts
@@ -902,6 +984,11 @@ parts:
label: Label
type: text
required: true
images:
label: Images
type: fileupload
mode: image
useCaption: true
`),
"models/settings/fields.yaml": file(`fields:
enabled: