- 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
262 lines
12 KiB
Go
262 lines
12 KiB
Go
package cabana_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// pendingWork fills a create session: one photo upload, one created part
|
|
// and one member linked with a pivot note. It returns the photo and part
|
|
// ids and the member id.
|
|
func (e *dfEnv) pendingWork(t *testing.T, c dfClient, key string) (photo, part, member uint) {
|
|
t.Helper()
|
|
up := c.upload(t, photosPath(0, ""), "p.png", conformPNG(t), sk(key))
|
|
want(t, "pending upload", up, http.StatusCreated)
|
|
created := c.do(t, http.MethodPost, dfPath(0, "/relations/parts/records"), map[string]any{"label": "pending"}, sk(key))
|
|
want(t, "pending part", created, http.StatusCreated)
|
|
member = e.member(t, fmt.Sprintf("m%d-%s@example.test", len(e.state(t).Members), e.stamp))
|
|
want(t, "pending link", c.do(t, http.MethodPost, dfPath(0, "/relations/members/link"), map[string]any{"ids": []uint{member}, "pivot": map[string]any{"note": "n"}}, sk(key)), http.StatusOK)
|
|
return dataID(t, up.Body.Bytes()), dataID(t, created.Body.Bytes()), member
|
|
}
|
|
|
|
// formCalls keeps the Form hook calls of the recorder.
|
|
func formCalls(calls []string) []string {
|
|
var out []string
|
|
for _, c := range calls {
|
|
if strings.HasPrefix(c, "Form") {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestDeferredCommitOrder: the session's bindings are applied inside the
|
|
// create and update transactions, after FormBefore* and before FormAfter*
|
|
// (D-04). The hooks see the transaction: before the commit the bindings
|
|
// are there and nothing is attached, after it the reverse.
|
|
func TestDeferredCommitOrder(t *testing.T) {
|
|
env := newDeferredEnv(t)
|
|
key := newSessionKey(t)
|
|
photo, part, member := env.pendingWork(t, env.a, key)
|
|
env.rec.reset()
|
|
env.rec.probe = func(tx *gorm.DB) string {
|
|
var bindings, files, parts, pivots int64
|
|
tx.Model(&lagoon.DeferredBinding{}).Where("session_key = ?", key).Count(&bindings)
|
|
tx.Model(&attach.File{}).Where("id = ? AND attachment_id <> ''", photo).Count(&files)
|
|
tx.Model(&dfPart{}).Where("id = ? AND gadget_id IS NOT NULL", part).Count(&parts)
|
|
tx.Model(&dfGadgetMember{}).Where("member_id = ?", member).Count(&pivots)
|
|
return fmt.Sprintf("bindings=%d,files=%d,parts=%d,pivots=%d", bindings, files, parts, pivots)
|
|
}
|
|
saved := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "g-" + env.stamp}, sk(key))
|
|
want(t, "create", saved, http.StatusCreated)
|
|
got := formCalls(env.rec.snapshot())
|
|
wantCalls := []string{"FormBeforeCreate:bindings=3,files=0,parts=0,pivots=0", "FormAfterCreate:bindings=0,files=1,parts=1,pivots=1"}
|
|
if fmt.Sprint(got) != fmt.Sprint(wantCalls) {
|
|
t.Fatalf("form hooks = %v\nwant %v", got, wantCalls)
|
|
}
|
|
if !slices.ContainsFunc(env.rec.snapshot(), func(s string) bool { return strings.HasPrefix(s, "RelationBeforeLink:members") }) {
|
|
t.Fatalf("the deferred link skipped RelationBeforeLink: %v", env.rec.snapshot())
|
|
}
|
|
|
|
g := dataID(t, saved.Body.Bytes())
|
|
k2 := newSessionKey(t)
|
|
up := env.a.upload(t, photosPath(g, ""), "q.png", conformPNG(t), sk(k2))
|
|
want(t, "upload on update", up, http.StatusCreated)
|
|
photo = dataID(t, up.Body.Bytes())
|
|
key = k2
|
|
env.rec.reset()
|
|
env.rec.probe = func(tx *gorm.DB) string {
|
|
var bindings, files int64
|
|
tx.Model(&lagoon.DeferredBinding{}).Where("session_key = ?", key).Count(&bindings)
|
|
tx.Model(&attach.File{}).Where("id = ? AND attachment_id <> ''", photo).Count(&files)
|
|
return fmt.Sprintf("bindings=%d,files=%d", bindings, files)
|
|
}
|
|
want(t, "update", env.a.do(t, http.MethodPut, dfPath(g, ""), map[string]any{"name": "g-" + env.stamp}, sk(k2)), http.StatusOK)
|
|
if got := fmt.Sprint(formCalls(env.rec.snapshot())); got != "[FormBeforeUpdate:bindings=1,files=0 FormAfterUpdate:bindings=0,files=1]" {
|
|
t.Fatalf("update hooks = %s", got)
|
|
}
|
|
}
|
|
|
|
// TestDeferredCommitRollback: a 422 at save rolls the whole transaction
|
|
// back and keeps every binding of the session for the next attempt: a
|
|
// datepicker bound, an ineligible deferred link, a required fileupload and
|
|
// maxFiles over the limit across sessions.
|
|
func TestDeferredCommitRollback(t *testing.T) {
|
|
env := newDeferredEnv(t)
|
|
|
|
t.Run("datepicker bound", func(t *testing.T) {
|
|
key := newSessionKey(t)
|
|
env.pendingWork(t, env.a, key)
|
|
before := env.state(t)
|
|
rec := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "bound-" + env.stamp, "released_on": "1999-01-01"}, sk(key))
|
|
want(t, "save out of bounds", rec, http.StatusUnprocessableEntity)
|
|
if len(errorDetails(t, rec)["released_on"]) != 1 {
|
|
t.Fatalf("details %v", errorDetails(t, rec))
|
|
}
|
|
env.unchanged(t, "a rolled-back save", before)
|
|
want(t, "save again", env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "bound-" + env.stamp}, sk(key)), http.StatusCreated)
|
|
var left int64
|
|
env.db.Model(&lagoon.DeferredBinding{}).Where("session_key = ?", key).Count(&left)
|
|
if left != 0 {
|
|
t.Fatalf("bindings after the retry = %d", left)
|
|
}
|
|
})
|
|
|
|
t.Run("ineligible link", func(t *testing.T) {
|
|
key := newSessionKey(t)
|
|
_, _, member := env.pendingWork(t, env.a, key)
|
|
// The linked member is gone by the time the record is saved.
|
|
if err := env.db.Delete(&dfMember{}, member).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := env.state(t)
|
|
rec := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "ineligible-" + env.stamp}, sk(key))
|
|
want(t, "save with an ineligible link", rec, http.StatusUnprocessableEntity)
|
|
if len(errorDetails(t, rec)["members"]) != 1 {
|
|
t.Fatalf("details %v", errorDetails(t, rec))
|
|
}
|
|
env.unchanged(t, "a rolled-back save", before)
|
|
})
|
|
|
|
t.Run("required fileupload", func(t *testing.T) {
|
|
key := newSessionKey(t)
|
|
rec := env.a.do(t, http.MethodPost, "/acme/deferred/locked", map[string]any{"name": "locked-" + env.stamp}, sk(key))
|
|
want(t, "save without the required file", rec, http.StatusUnprocessableEntity)
|
|
if d := errorDetails(t, rec)["manual"]; len(d) != 1 || d[0] != "The manual field is required." {
|
|
t.Fatalf("details %v", errorDetails(t, rec))
|
|
}
|
|
up := env.a.upload(t, "/acme/deferred/locked/0/files/manual", "m.txt", []byte("manual"), sk(key))
|
|
want(t, "upload the required file", up, http.StatusCreated)
|
|
saved := env.a.do(t, http.MethodPost, "/acme/deferred/locked", map[string]any{"name": "locked-" + env.stamp}, sk(key))
|
|
want(t, "save with the required file", saved, http.StatusCreated)
|
|
// Removing the only file of a required field fails the next save.
|
|
g := dataID(t, saved.Body.Bytes())
|
|
k2 := newSessionKey(t)
|
|
want(t, "remove it", env.a.do(t, http.MethodDelete, fmt.Sprintf("/acme/deferred/locked/%d/files/manual/%d", g, dataID(t, up.Body.Bytes())), nil, sk(k2)), http.StatusOK)
|
|
before := env.state(t)
|
|
want(t, "save without it", env.a.do(t, http.MethodPut, fmt.Sprintf("/acme/deferred/locked/%d", g), map[string]any{"name": "locked-" + env.stamp}, sk(k2)), http.StatusUnprocessableEntity)
|
|
env.unchanged(t, "a rolled-back removal", before)
|
|
})
|
|
|
|
t.Run("maxFiles at save", func(t *testing.T) {
|
|
g := env.gadget(t, "max-"+env.stamp, false)
|
|
k1, k2 := newSessionKey(t), newSessionKey(t)
|
|
for _, key := range []string{k1, k1, k2, k2} {
|
|
want(t, "upload", env.a.upload(t, photosPath(g, ""), "p.png", conformPNG(t), sk(key)), http.StatusCreated)
|
|
}
|
|
want(t, "first session", env.a.do(t, http.MethodPut, dfPath(g, ""), map[string]any{"name": "max-" + env.stamp}, sk(k1)), http.StatusOK)
|
|
before := env.state(t)
|
|
rec := env.a.do(t, http.MethodPut, dfPath(g, ""), map[string]any{"name": "max-" + env.stamp}, sk(k2))
|
|
want(t, "second session over maxFiles", rec, http.StatusUnprocessableEntity)
|
|
if d := errorDetails(t, rec)["photos"]; len(d) != 1 || d[0] != "The photos may not have more than 3 items." {
|
|
t.Fatalf("details %v", errorDetails(t, rec))
|
|
}
|
|
env.unchanged(t, "a rolled-back save", before)
|
|
})
|
|
}
|
|
|
|
// TestDeferredCommitAppliedOnly: a successful save deletes exactly the
|
|
// bindings it applied. Bindings of a field the form does not declare, of
|
|
// another master type, of another admin, and of a field whose context
|
|
// excludes the operation stay in place, and their slaves are untouched.
|
|
func TestDeferredCommitAppliedOnly(t *testing.T) {
|
|
fields := strings.Replace(dfFile(t, "models/gadget/fields.yaml"), " useCaption: true\n", " useCaption: true\n context: update\n", 1)
|
|
env := newDeferredEnvWith(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": fields})})
|
|
key := newSessionKey(t)
|
|
photo, _, _ := env.pendingWork(t, env.a, key)
|
|
stray := func(admin uint, master, field string) uint {
|
|
f := env.storeFile(t, "", 0, "", "stray.png", conformPNG(t), false)
|
|
err := lagoon.Transaction(t.Context(), env.db, func(ctx context.Context, tx *gorm.DB) error {
|
|
return lagoon.DeferredBind(ctx, tx, lagoon.DeferredKey{SessionKey: key, AdminID: admin, MasterType: master}, field, lagoon.DeferredFileType, fmt.Sprint(f.ID), nil)
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return f.ID
|
|
}
|
|
undeclared := stray(env.a.id, dfGadgetMorph, "brochure")
|
|
foreignMorph := stray(env.a.id, dfPartMorph, "photos")
|
|
foreignAdmin := stray(env.b.id, dfGadgetMorph, "photos")
|
|
updateOnly := stray(env.a.id, dfGadgetMorph, "manual")
|
|
|
|
saved := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "g-" + env.stamp}, sk(key))
|
|
want(t, "save", saved, http.StatusCreated)
|
|
if f, _ := env.fileRow(t, photo); f.AttachmentID == "" {
|
|
t.Fatal("the declared photo was not attached")
|
|
}
|
|
var rows []lagoon.DeferredBinding
|
|
if err := env.db.Where("session_key = ?", key).Order("id").Find(&rows).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var left []string
|
|
for _, r := range rows {
|
|
left = append(left, fmt.Sprintf("%d/%s/%s/%s", r.BackendUserID, r.MasterType, r.MasterField, r.SlaveID))
|
|
}
|
|
wantLeft := []string{
|
|
fmt.Sprintf("%d/%s/brochure/%d", env.a.id, dfGadgetMorph, undeclared),
|
|
fmt.Sprintf("%d/%s/photos/%d", env.a.id, dfPartMorph, foreignMorph),
|
|
fmt.Sprintf("%d/%s/photos/%d", env.b.id, dfGadgetMorph, foreignAdmin),
|
|
fmt.Sprintf("%d/%s/manual/%d", env.a.id, dfGadgetMorph, updateOnly),
|
|
}
|
|
if fmt.Sprint(left) != fmt.Sprint(wantLeft) {
|
|
t.Fatalf("bindings left\n got %v\nwant %v", left, wantLeft)
|
|
}
|
|
for _, id := range []uint{undeclared, foreignMorph, foreignAdmin, updateOnly} {
|
|
if f, ok := env.fileRow(t, id); !ok || f.AttachmentID != "" {
|
|
t.Fatalf("stray file %d was touched: %+v", id, f)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDeferredCommitConcurrent: two saves with one session key at the
|
|
// same time apply each binding once. The commit reads the bindings FOR
|
|
// UPDATE, so the second save waits, finds them gone and attaches nothing.
|
|
func TestDeferredCommitConcurrent(t *testing.T) {
|
|
env := newDeferredEnv(t)
|
|
key := newSessionKey(t)
|
|
photo, part, member := env.pendingWork(t, env.a, key)
|
|
var wg sync.WaitGroup
|
|
recs := make([]*httptest.ResponseRecorder, 2)
|
|
for i := range recs {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
recs[i] = env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": fmt.Sprintf("race%d-%s", i, env.stamp)}, sk(key))
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
ids := map[uint]bool{}
|
|
for i, rec := range recs {
|
|
want(t, fmt.Sprintf("concurrent save %d", i), rec, http.StatusCreated)
|
|
ids[dataID(t, rec.Body.Bytes())] = true
|
|
}
|
|
f, _ := env.fileRow(t, photo)
|
|
p, _ := env.partRow(t, part)
|
|
var pivots []dfGadgetMember
|
|
if err := env.db.Where("member_id = ?", member).Find(&pivots).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.AttachmentID == "" || p.GadgetID == nil || len(pivots) != 1 {
|
|
t.Fatalf("applied: file %q part %v pivots %d", f.AttachmentID, p.GadgetID, len(pivots))
|
|
}
|
|
owner := fmt.Sprint(*p.GadgetID)
|
|
if f.AttachmentID != owner || fmt.Sprint(pivots[0].GadgetID) != owner || !ids[*p.GadgetID] {
|
|
t.Fatalf("bindings split across saves: file %s part %s pivot %d", f.AttachmentID, owner, pivots[0].GadgetID)
|
|
}
|
|
var files int64
|
|
env.db.Model(&attach.File{}).Where("attachment_type = ? AND field = 'photos'", dfGadgetMorph).Count(&files)
|
|
if files != 1 || len(env.state(t).Bindings) != 0 {
|
|
t.Fatalf("files %d bindings %d", files, len(env.state(t).Bindings))
|
|
}
|
|
}
|