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

@@ -5,9 +5,17 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/party"
)
// linkedIDs lists the ids of a gadget relation's linked rows.
@@ -231,3 +239,168 @@ func TestRelationChildSmokePivot(t *testing.T) {
t.Fatalf("pivot row after update = %+v", row)
}
}
// sendJSON sends a JSON body with extra headers.
func (e *conformEnv) sendJSON(t *testing.T, method, rel string, body any, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
return e.sendWith(t, method, rel, raw, "application/json", headers)
}
// TestRelationChildSmokeDeferredCreate manages relations on a gadget that is
// not saved yet (D-03, D-04): a part created and a member linked with a
// pivot note under the session key are attached by the gadget's create save
// with the same key, and no binding is left.
func TestRelationChildSmokeDeferredCreate(t *testing.T) {
env := newConformEnv(t)
env.loginAs(t, env.login)
key := newSessionKey(t)
h := map[string]string{cabana.SessionKeyHeader: key}
expectStatus(t, "id 0 without a key", env.send(t, http.MethodPost, relationPath(0, "parts", "/records"), map[string]any{"label": "x"}, true), http.StatusNotFound)
created := env.sendJSON(t, http.MethodPost, relationPath(0, "parts", "/records"), map[string]any{"label": "pending-" + env.stamp}, h)
expectStatus(t, "deferred create", created, http.StatusCreated)
part := dataID(t, created.Body.Bytes())
if owner := env.partOwner(t, part); owner != nil {
t.Fatalf("pending part already owned by %d", *owner)
}
if got := env.linkedIDs(t, 0, "parts", h); !slices.Contains(got, part) {
t.Fatalf("pending parts = %v, want %d", got, part)
}
other := env.createGadget(t, "other-"+env.stamp, "")
candidates := env.send(t, http.MethodGet, relationPath(other, "parts", "/candidates"), nil, true)
expectStatus(t, "candidates of another gadget", candidates, http.StatusOK)
if strings.Contains(candidates.Body.String(), "pending-"+env.stamp) {
t.Fatalf("another gadget may adopt the pending part: %s", candidates.Body.String())
}
link := env.sendJSON(t, http.MethodPost, relationPath(0, "members", "/link"), map[string]any{"ids": []uint{env.memberID}, "pivot": map[string]any{"note": "deferred"}}, h)
expectStatus(t, "deferred link", link, http.StatusOK)
pivot := env.sendWith(t, http.MethodGet, relationPath(0, "members", fmt.Sprintf("/pivot/%d", env.memberID)), nil, "", h)
expectStatus(t, "pending pivot", pivot, http.StatusOK)
if !strings.Contains(pivot.Body.String(), `"note":"deferred"`) {
t.Fatalf("pending pivot = %s", pivot.Body.String())
}
// Unlinking a part the session created deletes it.
dropped := env.sendJSON(t, http.MethodPost, relationPath(0, "parts", "/records"), map[string]any{"label": "dropped-" + env.stamp}, h)
expectStatus(t, "second deferred create", dropped, http.StatusCreated)
droppedID := dataID(t, dropped.Body.Bytes())
expectStatus(t, "deferred unlink", env.sendJSON(t, http.MethodPost, relationPath(0, "parts", "/unlink"), map[string]any{"ids": []uint{droppedID}}, h), http.StatusOK)
var left int64
if err := env.db.Model(&conformPart{}).Where("id = ?", droppedID).Count(&left).Error; err != nil || left != 0 {
t.Fatalf("unlinked pending part rows = %d (%v)", left, err)
}
gadget := env.createGadget(t, "deferred-"+env.stamp, key)
if owner := env.partOwner(t, part); owner == nil || *owner != gadget {
t.Fatalf("part owner after save = %v, want %d", owner, gadget)
}
var row conformGadgetMember
if err := env.db.Where("gadget_id = ? AND member_id = ?", gadget, env.memberID).Take(&row).Error; err != nil {
t.Fatalf("pivot row after save: %v", err)
}
if row.Note != "deferred" || row.Stamp != "linked" {
t.Fatalf("pivot row = %+v, want note deferred and stamp linked", row)
}
if n := env.bindingCount(t, key); n != 0 {
t.Fatalf("bindings left after save = %d", n)
}
}
// TestRelationChildSmokeDeferredRollback links a member that the saved
// gadget excludes (ExcludedRelatedIDs sees the real parent only at save):
// the save answers 422 on the relation-manager field, nothing is created and
// the binding stays for the next attempt.
func TestRelationChildSmokeDeferredRollback(t *testing.T) {
env := newConformEnv(t)
env.loginAs(t, env.login)
key := newSessionKey(t)
h := map[string]string{cabana.SessionKeyHeader: key}
member := conformMember{Email: "excluded-" + env.stamp + "@example.test"}
if err := env.db.Create(&member).Error; err != nil {
t.Fatal(err)
}
expectStatus(t, "deferred link", env.sendJSON(t, http.MethodPost, relationPath(0, "members", "/link"), map[string]any{"ids": []uint{member.ID}}, h), http.StatusOK)
name := fmt.Sprintf("exclude-%d-%s", member.ID, env.stamp)
saved := env.sendJSON(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": name}, h)
expectStatus(t, "save with an ineligible link", saved, http.StatusUnprocessableEntity)
var body struct {
Error struct {
Details map[string][]string `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(saved.Body.Bytes(), &body); err != nil || len(body.Error.Details["members"]) == 0 {
t.Fatalf("422 details = %s (%v)", saved.Body.String(), err)
}
var n int64
if err := env.db.Model(&conformGadget{}).Where("name = ?", name).Count(&n).Error; err != nil || n != 0 {
t.Fatalf("rolled back gadget rows = %d (%v)", n, err)
}
if got := env.bindingCount(t, key); got != 1 {
t.Fatalf("bindings after the 422 = %d, want 1", got)
}
}
// TestRelationChildSmokeChildFile uploads an image to a part that is not
// created yet (child 0, X-Child-Session-Key) and creates the part with the
// same child key: the file is attached to the new part (D-17).
func TestRelationChildSmokeChildFile(t *testing.T) {
env := newConformEnv(t)
env.loginAs(t, env.login)
gadget := env.createGadget(t, "files-"+env.stamp, "")
childKey := newSessionKey(t)
expectStatus(t, "child 0 list without a child key", env.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/0/files/images", gadget), nil, "", nil), http.StatusNotFound)
up := env.childUpload(t, gadget, 0, "images", "part.png", conformPNG(t), childKey)
expectStatus(t, "child 0 upload", up, http.StatusCreated)
file := dataID(t, up.Body.Bytes())
created := env.sendJSON(t, http.MethodPost, relationPath(gadget, "parts", "/records"), map[string]any{"label": "with image"}, map[string]string{cabana.ChildSessionKeyHeader: childKey})
expectStatus(t, "create child with files", created, http.StatusCreated)
part := dataID(t, created.Body.Bytes())
var f attach.File
if err := env.db.Where("id = ?", file).Take(&f).Error; err != nil {
t.Fatal(err)
}
if f.AttachmentType != "acme.conform.part" || f.AttachmentID != fmt.Sprint(part) || f.Field != "images" {
t.Fatalf("file attached to %s/%s/%s, want acme.conform.part/%d/images", f.AttachmentType, f.AttachmentID, f.Field, part)
}
if n := env.bindingCount(t, childKey); n != 0 {
t.Fatalf("child bindings left = %d", n)
}
list := env.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/%d/files/images", gadget, part), nil, "", nil)
if got := fileList(t, list); len(got) != 1 || got[0].Pending || got[0].URL != "" {
t.Fatalf("child file list = %#v", got)
}
}
// noModelsPlugin is the conform plugin without its Models list.
type noModelsPlugin struct{ conformPlugin }
func (noModelsPlugin) Models() []any { return nil }
// TestRelationChildSmokePurgeModels: a deferrable relation that creates
// children needs its related model in some plugin's Models(), or
// deferred:purge could not remove abandoned children (Pitfall 9).
func TestRelationChildSmokePurgeModels(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-purge-models\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
if err != nil {
t.Fatal(err)
}
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{noModelsPlugin{}})
if err == nil || !strings.Contains(err.Error(), "relation parts creates acme.conform.part records under deferral") {
t.Fatalf("err = %v", err)
}
if _, err := cabana.Activate(backpack.New(cfg), []party.Plugin{conformPlugin{}}); err != nil {
t.Fatalf("listed model failed boot: %v", err)
}
}