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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user