test(12.2-05): cover every Go behaviour of phase 12.2
- 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
This commit is contained in:
353
modules/cabana/datepicker_test.go
Normal file
353
modules/cabana/datepicker_test.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
)
|
||||
|
||||
// dfGadgetFields is a minimal gadget fields.yaml (the two relation
|
||||
// managers the contracts need) plus extra.
|
||||
func dfGadgetFields(extra string) string {
|
||||
return `fields:
|
||||
name:
|
||||
label: Name
|
||||
type: text
|
||||
members:
|
||||
type: relation-manager
|
||||
relation: members
|
||||
parts:
|
||||
type: relation-manager
|
||||
relation: parts
|
||||
` + extra
|
||||
}
|
||||
|
||||
// bootGadgetFields boots the fixture with the gadget fields.yaml replaced.
|
||||
func bootGadgetFields(t *testing.T, fields string) error {
|
||||
t.Helper()
|
||||
return activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": fields})}, nil)
|
||||
}
|
||||
|
||||
// TestDatepickerCompile: every datepicker compile error stops boot with a
|
||||
// message that names the field's file (D-20), and the Go type of the
|
||||
// column must match the mode (D-19).
|
||||
func TestDatepickerCompile(t *testing.T) {
|
||||
field := func(name, body string) string {
|
||||
return " " + name + ":\n type: datepicker\n" + body
|
||||
}
|
||||
for name, tc := range map[string]struct {
|
||||
field string
|
||||
want string
|
||||
}{
|
||||
"unknown key": {field("released_on", " mode: date\n showWeekNumber: true\n"), "unknown field showWeekNumber"},
|
||||
"unknown mode": {field("released_on", " mode: week\n"), `mode "week" must be date, datetime or time`},
|
||||
"options key": {field("released_on", " mode: date\n options: {a: b}\n"), "options is not valid on type: datepicker"},
|
||||
"format not a string": {field("starts_at", " format: [Y]\n"), "format must be a PHP date format string"},
|
||||
"unmapped token": {field("starts_at", " format: d.m.Y T\n"), "token T has no equivalent"},
|
||||
"unmapped token U": {field("starts_at", " format: U\n"), "token U has no equivalent"},
|
||||
"minDate on time": {field("opens_at", " mode: time\n minDate: 2026-01-01\n"), "minDate is not valid with mode: time"},
|
||||
"maxDate on time": {field("opens_at", " mode: time\n maxDate: 2026-01-01\n"), "maxDate is not valid with mode: time"},
|
||||
"bad minDate": {field("released_on", " mode: date\n minDate: 01.01.2026\n"), "minDate:"},
|
||||
"min after max": {field("starts_at", " minDate: 2026-02-01\n maxDate: 2026-01-01\n"), "is after maxDate"},
|
||||
"yearRange one year": {field("starts_at", " yearRange: [2000]\n"), "yearRange: a list must hold"},
|
||||
"yearRange three years": {field("starts_at", " yearRange: [2000, 2010, 2020]\n"), "yearRange: a list must hold"},
|
||||
"yearRange reversed": {field("starts_at", " yearRange: [2040, 2000]\n"), "yearRange: from 2040 is after to 2000"},
|
||||
"yearRange zero": {field("starts_at", " yearRange: 0\n"), "yearRange:"},
|
||||
"yearRange text": {field("starts_at", " yearRange: soon\n"), "yearRange:"},
|
||||
"firstDay seven": {field("starts_at", " firstDay: 7\n"), `firstDay "7" must be an integer from 0 (Sunday) to 6`},
|
||||
"firstDay negative": {field("starts_at", " firstDay: -1\n"), "firstDay"},
|
||||
"twelveHour not bool": {field("starts_at", " twelveHour: maybe\n"), "twelveHour:"},
|
||||
"ignoreTimezone not bool": {field("starts_at", " ignoreTimezone: maybe\n"), "ignoreTimezone:"},
|
||||
"ignoreTimezone on date": {field("released_on", " mode: date\n ignoreTimezone: true\n"), "ignoreTimezone is only valid with mode: datetime"},
|
||||
"key on another type": {" name2:\n type: text\n firstDay: 1\n", "firstDay is only valid on type: datepicker"},
|
||||
"mode on another type": {" name2:\n type: text\n mode: date\n", "mode is only valid on type: fileupload or datepicker"},
|
||||
"date on a datetime": {field("starts_at", " mode: date\n"), "field starts_at: datepicker mode date needs a lagoon.Date or *lagoon.Date column, found *time.Time"},
|
||||
"datetime on a date": {field("released_on", " mode: datetime\n"), "datepicker mode datetime needs a time.Time or *time.Time column, found lagoon.Date"},
|
||||
"time on a date": {field("released_on", " mode: time\n"), "datepicker mode time needs a lagoon.TimeOfDay or *lagoon.TimeOfDay column, found lagoon.Date"},
|
||||
"date on a time": {field("opens_at", " mode: date\n"), "found lagoon.TimeOfDay"},
|
||||
"not a date column": {field("hidden", " mode: date\n"), "found bool"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := bootGadgetFields(t, dfGadgetFields(tc.field))
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "models/gadget/fields.yaml") {
|
||||
t.Fatalf("err = %v, want %q naming the file", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
// A datepicker on a name that is no model column stops boot too (the
|
||||
// generic writable-field check, which names the controller).
|
||||
if err := bootGadgetFields(t, dfGadgetFields(field("nothere", " mode: date\n"))); err == nil || !strings.Contains(err.Error(), "field nothere is not a model column") {
|
||||
t.Fatalf("not a column: %v", err)
|
||||
}
|
||||
// Each mode on its own Go type, pointer variants included, boots.
|
||||
ok := dfGadgetFields(field("released_on", " mode: date\n") + field("starts_at", " mode: datetime\n") + field("opens_at", " mode: time\n twelveHour: true\n"))
|
||||
if err := bootGadgetFields(t, ok); err != nil {
|
||||
t.Fatalf("matching types failed boot: %v", err)
|
||||
}
|
||||
part := "fields:\n label:\n type: text\n due_on:\n type: datepicker\n mode: date\n"
|
||||
if err := activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/part/fields.yaml": part})}, nil); err != nil {
|
||||
t.Fatalf("*lagoon.Date column failed boot: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// compiledField is one field of a compiled form's canonical JSON.
|
||||
func compiledField(t *testing.T, fields, name string) map[string]any {
|
||||
t.Helper()
|
||||
form, err := cabana.CompileForm("acme.deferred", dfController{id: "acme.deferred.gadgets", dir: "controllers/gadgets"},
|
||||
dfOverlay(t, map[string]string{"models/gadget/fields.yaml": fields}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := form.CanonicalJSON()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc struct {
|
||||
Fields []map[string]any `json:"fields"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range doc.Fields {
|
||||
if f["name"] == name {
|
||||
return f
|
||||
}
|
||||
}
|
||||
t.Fatalf("field %s not compiled", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestDatepickerDisplayFormat maps PHP date() formats to the SPA's display
|
||||
// format with WinterCMS's momentFormat table; an escaped letter stays
|
||||
// literal and no format means no displayFormat.
|
||||
func TestDatepickerDisplayFormat(t *testing.T) {
|
||||
for format, want := range map[string]string{
|
||||
"d.m.Y H:i": "DD.MM.YYYY HH:mm",
|
||||
"j F Y": "D MMMM YYYY",
|
||||
"D, d M y": "ddd, DD MMM YY",
|
||||
"l G:i:s A": "dddd H:mm:ss A",
|
||||
"g:i a": "h:mm a",
|
||||
"n/j/Y h:i": "M/D/YYYY hh:mm",
|
||||
`Y-m-d \a\t H:i`: "YYYY-MM-DD [a][t] HH:mm",
|
||||
"Y-m-d (H:i:s)": "YYYY-MM-DD (HH:mm:ss)",
|
||||
"d/m/Y": "DD/MM/YYYY",
|
||||
"Y. m. d. G시 i분": "YYYY. MM. DD. H시 mm분",
|
||||
} {
|
||||
f := compiledField(t, dfGadgetFields(" starts_at:\n type: datepicker\n format: '"+strings.ReplaceAll(format, "'", "''")+"'\n"), "starts_at")
|
||||
if f["format"] != format || f["displayFormat"] != want {
|
||||
t.Errorf("format %q: displayFormat = %v, want %q", format, f["displayFormat"], want)
|
||||
}
|
||||
}
|
||||
plain := compiledField(t, dfGadgetFields(" starts_at:\n type: datepicker\n"), "starts_at")
|
||||
if _, ok := plain["displayFormat"]; ok || plain["mode"] != "datetime" {
|
||||
t.Fatalf("no format: %v", plain)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDatepickerSchemaKeys: yearRange forms, firstDay, twelveHour,
|
||||
// ignoreTimezone and the bounds reach the schema.
|
||||
func TestDatepickerSchemaKeys(t *testing.T) {
|
||||
f := compiledField(t, dfGadgetFields(" starts_at:\n type: datepicker\n yearRange: 10\n firstDay: 0\n twelveHour: true\n ignoreTimezone: true\n minDate: 2000-01-01\n maxDate: 2030-12-31\n"), "starts_at")
|
||||
if years, _ := f["yearRange"].([]any); len(years) != 1 || years[0] != float64(10) {
|
||||
t.Fatalf("yearRange 10 = %v", f["yearRange"])
|
||||
}
|
||||
if f["firstDay"] != float64(0) || f["twelveHour"] != true || f["ignoreTimezone"] != true || f["minDate"] != "2000-01-01" || f["maxDate"] != "2030-12-31" {
|
||||
t.Fatalf("keys = %v", f)
|
||||
}
|
||||
f = compiledField(t, dfGadgetFields(" released_on:\n type: datepicker\n mode: date\n yearRange: [1990, 2040]\n"), "released_on")
|
||||
if years, _ := f["yearRange"].([]any); len(years) != 2 || years[0] != float64(1990) || years[1] != float64(2040) || f["mode"] != "date" {
|
||||
t.Fatalf("yearRange list = %v", f)
|
||||
}
|
||||
}
|
||||
|
||||
// gadgetRow reads the stored date columns of a gadget as text.
|
||||
type gadgetDates struct {
|
||||
Day *string
|
||||
UTC *string
|
||||
Clock *string
|
||||
}
|
||||
|
||||
func (e *dfEnv) gadgetDates(t *testing.T, id uint) gadgetDates {
|
||||
t.Helper()
|
||||
var row gadgetDates
|
||||
err := e.db.Raw(`SELECT released_on::text AS day, to_char(starts_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS utc, opens_at::text AS clock FROM cabana_deferred_gadgets WHERE id = ?`, id).Scan(&row).Error
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func strOrNil(s *string) string {
|
||||
if s == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// TestDatepickerSave stores each mode through the admin API: a date stays
|
||||
// the calendar day, a datetime with an offset is stored in UTC, a time
|
||||
// keeps its clock, and null or an empty string clears each one.
|
||||
func TestDatepickerSave(t *testing.T) {
|
||||
env := newDeferredEnv(t)
|
||||
rec := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{
|
||||
"name": "dates-" + env.stamp, "released_on": "2026-10-02", "starts_at": "2026-10-02T01:30:00+02:00", "opens_at": "14:30",
|
||||
}, nil)
|
||||
want(t, "create with dates", rec, http.StatusCreated)
|
||||
id := dataID(t, rec.Body.Bytes())
|
||||
if got := env.gadgetDates(t, id); strOrNil(got.Day) != "2026-10-02" || strOrNil(got.UTC) != "2026-10-01 23:30:00" || strOrNil(got.Clock) != "14:30:00" {
|
||||
t.Fatalf("stored = %s %s %s", strOrNil(got.Day), strOrNil(got.UTC), strOrNil(got.Clock))
|
||||
}
|
||||
var body struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data["released_on"] != "2026-10-02" || body.Data["opens_at"] != "14:30:00" {
|
||||
t.Fatalf("record = %s", rec.Body.String())
|
||||
}
|
||||
if at, err := time.Parse(time.RFC3339, fmt.Sprint(body.Data["starts_at"])); err != nil || !at.Equal(time.Date(2026, 10, 1, 23, 30, 0, 0, time.UTC)) {
|
||||
t.Fatalf("starts_at = %v (%v)", body.Data["starts_at"], err)
|
||||
}
|
||||
|
||||
upd := env.a.do(t, http.MethodPut, dfPath(id, ""), map[string]any{"name": "dates-" + env.stamp, "released_on": "2026-12-24", "starts_at": "2026-12-24T18:00:00Z", "opens_at": "00:00:00"}, nil)
|
||||
want(t, "update dates", upd, http.StatusOK)
|
||||
if got := env.gadgetDates(t, id); strOrNil(got.Day) != "2026-12-24" || strOrNil(got.UTC) != "2026-12-24 18:00:00" || strOrNil(got.Clock) != "00:00:00" {
|
||||
t.Fatalf("updated = %s %s %s", strOrNil(got.Day), strOrNil(got.UTC), strOrNil(got.Clock))
|
||||
}
|
||||
|
||||
clr := env.a.do(t, http.MethodPut, dfPath(id, ""), map[string]any{"name": "dates-" + env.stamp, "released_on": nil, "starts_at": nil, "opens_at": ""}, nil)
|
||||
want(t, "clear dates", clr, http.StatusOK)
|
||||
if got := env.gadgetDates(t, id); got.Day != nil || got.UTC != nil || got.Clock != nil {
|
||||
t.Fatalf("cleared = %s %s %s", strOrNil(got.Day), strOrNil(got.UTC), strOrNil(got.Clock))
|
||||
}
|
||||
|
||||
for name, bad := range map[string]map[string]any{
|
||||
"garbage date": {"released_on": "02.10.2026"},
|
||||
"garbage datetime": {"starts_at": "tomorrow"},
|
||||
"garbage time": {"opens_at": "25:00"},
|
||||
} {
|
||||
bad["name"] = "bad-" + env.stamp
|
||||
rec := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", bad, nil)
|
||||
want(t, name, rec, http.StatusUnprocessableEntity)
|
||||
for key := range bad {
|
||||
if key != "name" && len(errorDetails(t, rec)[key]) == 0 {
|
||||
t.Fatalf("%s: details %v lack %s", name, errorDetails(t, rec), key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDatepickerBounds: minDate and maxDate are enforced on the server and
|
||||
// inclusive (D-20). A date compares as is, a datetime by its UTC date, and
|
||||
// with ignoreTimezone by the wall-clock date it was sent with; the check
|
||||
// runs on create and update and answers Laravel's after_or_equal and
|
||||
// before_or_equal text on the field.
|
||||
func TestDatepickerBounds(t *testing.T) {
|
||||
env := newDeferredEnv(t)
|
||||
save := func(id uint, values map[string]any) (int, map[string][]string) {
|
||||
values["name"] = "bounds-" + env.stamp
|
||||
var rec = env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", values, nil)
|
||||
if id > 0 {
|
||||
rec = env.a.do(t, http.MethodPut, dfPath(id, ""), values, nil)
|
||||
}
|
||||
if rec.Code == http.StatusUnprocessableEntity {
|
||||
return rec.Code, errorDetails(t, rec)
|
||||
}
|
||||
return rec.Code, nil
|
||||
}
|
||||
existing := env.gadget(t, "existing-"+env.stamp, false)
|
||||
for _, id := range []uint{0, existing} {
|
||||
for _, tc := range []struct {
|
||||
field, value string
|
||||
ok bool
|
||||
msg string
|
||||
}{
|
||||
{"released_on", "2000-01-01", true, ""},
|
||||
{"released_on", "2030-12-31", true, ""},
|
||||
{"released_on", "1999-12-31", false, "released on must be a date after or equal to 2000-01-01"},
|
||||
{"released_on", "2031-01-01", false, "released on must be a date before or equal to 2030-12-31"},
|
||||
{"starts_at", "2000-01-01T00:00:00Z", true, ""},
|
||||
{"starts_at", "2030-12-31T23:59:59Z", true, ""},
|
||||
// 00:30 at +01:00 is 23:30 UTC on the day before.
|
||||
{"starts_at", "2000-01-01T00:30:00+01:00", false, "starts at must be a date after or equal to 2000-01-01"},
|
||||
{"starts_at", "2031-01-01T00:30:00+01:00", true, ""},
|
||||
{"starts_at", "2031-01-01T00:00:00Z", false, "starts at must be a date before or equal to 2030-12-31"},
|
||||
} {
|
||||
code, details := save(id, map[string]any{tc.field: tc.value})
|
||||
if tc.ok && code/100 != 2 {
|
||||
t.Fatalf("id %d %s=%s: status %d %v, want accepted", id, tc.field, tc.value, code, details)
|
||||
}
|
||||
if !tc.ok && (code != http.StatusUnprocessableEntity || len(details[tc.field]) != 1 || details[tc.field][0] != "The "+tc.msg+".") {
|
||||
t.Fatalf("id %d %s=%s: status %d %v, want %q", id, tc.field, tc.value, code, details, tc.msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A cleared value is not out of bounds.
|
||||
if code, details := save(existing, map[string]any{"released_on": nil, "starts_at": nil}); code != http.StatusOK {
|
||||
t.Fatalf("cleared: %d %v", code, details)
|
||||
}
|
||||
|
||||
fields := strings.Replace(dfFile(t, "models/gadget/fields.yaml"), " mode: datetime\n", " mode: datetime\n ignoreTimezone: true\n", 1)
|
||||
wall := newDeferredEnvWith(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": fields})})
|
||||
for value, ok := range map[string]bool{
|
||||
"2031-01-01T00:30:00+01:00": false, // wall-clock date 2031-01-01
|
||||
"2000-01-01T00:30:00+01:00": true, // wall-clock date 2000-01-01
|
||||
"2030-12-31T23:30:00-05:00": true,
|
||||
"1999-12-31T23:30:00-05:00": false,
|
||||
} {
|
||||
rec := wall.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "wall-" + value, "starts_at": value}, nil)
|
||||
if ok != (rec.Code == http.StatusCreated) {
|
||||
t.Fatalf("ignoreTimezone %s: status %d body %s", value, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDatepickerListColumns: type: date and type: time list columns show
|
||||
// the stored values, and Scanner/Valuer struct columns are listed as
|
||||
// values, not taken for relations.
|
||||
func TestDatepickerListColumns(t *testing.T) {
|
||||
env := newDeferredEnv(t)
|
||||
rec := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "list-" + env.stamp, "released_on": "2026-10-02", "opens_at": "08:15"}, nil)
|
||||
want(t, "create", rec, http.StatusCreated)
|
||||
env.gadget(t, "empty-"+env.stamp, false)
|
||||
|
||||
schema := env.a.do(t, http.MethodGet, "/acme/deferred/gadgets/schema/list", nil, nil)
|
||||
want(t, "list schema", schema, http.StatusOK)
|
||||
var s struct {
|
||||
Data struct {
|
||||
Columns []struct {
|
||||
Key string `json:"key"`
|
||||
Type string `json:"type"`
|
||||
} `json:"columns"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(schema.Body.Bytes(), &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
types := map[string]string{}
|
||||
for _, c := range s.Data.Columns {
|
||||
types[c.Key] = c.Type
|
||||
}
|
||||
if types["released_on"] != "date" || types["opens_at"] != "time" {
|
||||
t.Fatalf("column types = %v (%s)", types, schema.Body.String())
|
||||
}
|
||||
|
||||
list := env.a.do(t, http.MethodGet, "/acme/deferred/gadgets?search="+env.stamp, nil, nil)
|
||||
want(t, "list", list, http.StatusOK)
|
||||
var body struct {
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(list.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, row := range body.Data {
|
||||
got[fmt.Sprint(row["name"])] = fmt.Sprintf("%v|%v", row["released_on"], row["opens_at"])
|
||||
}
|
||||
if got["list-"+env.stamp] != "2026-10-02|08:15:00" || got["empty-"+env.stamp] != "<nil>|<nil>" {
|
||||
t.Fatalf("rows = %v", got)
|
||||
}
|
||||
}
|
||||
261
modules/cabana/deferred_commit_test.go
Normal file
261
modules/cabana/deferred_commit_test.go
Normal file
@@ -0,0 +1,261 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
334
modules/cabana/fileupload_test.go
Normal file
334
modules/cabana/fileupload_test.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
)
|
||||
|
||||
// TestFileuploadCompile: every fileupload compile error stops boot naming
|
||||
// the field's file (D-08, D-09).
|
||||
func TestFileuploadCompile(t *testing.T) {
|
||||
photo := func(body string) string {
|
||||
return " photos:\n type: fileupload\n" + body
|
||||
}
|
||||
for name, tc := range map[string]struct {
|
||||
field string
|
||||
want string
|
||||
}{
|
||||
"unknown key": {photo(" mode: image\n resize: true\n"), "unknown field resize"},
|
||||
"datepicker key": {photo(" firstDay: 1\n"), "firstDay is only valid on type: datepicker"},
|
||||
"unknown mode": {photo(" mode: video\n"), `mode "video" must be image or file on type: fileupload`},
|
||||
"image-mode fileType": {photo(" mode: image\n fileTypes: [png, pdf]\n"), "fileTypes: pdf is not an image type"},
|
||||
"bad fileType": {photo(" fileTypes: [p-f]\n"), `fileTypes: "p-f" is not a file extension`},
|
||||
"bad mimeType": {photo(" mimeTypes: ['no such']\n"), `mimeTypes: "no such" is not a MIME type or extension`},
|
||||
"maxFilesize zero": {photo(" maxFilesize: 0\n"), "must be a positive number of megabytes"},
|
||||
"maxFilesize text": {photo(" maxFilesize: big\n"), "must be a positive number of megabytes"},
|
||||
"maxFiles zero": {photo(" maxFiles: 0\n"), "maxFiles \"0\" must be a positive integer"},
|
||||
"imageWidth zero": {photo(" imageWidth: 0\n"), "imageWidth"},
|
||||
"imageHeight huge": {photo(" imageHeight: 99999\n"), "imageHeight"},
|
||||
"thumbOptions key": {photo(" thumbOptions:\n size: 1\n"), "thumbOptions: unknown field size (only mode is supported)"},
|
||||
"thumbOptions mode": {photo(" thumbOptions:\n mode: stretch\n"), `thumbOptions: mode "stretch" must be auto, exact, crop or fit`},
|
||||
"thumbOptions list": {photo(" thumbOptions: [crop]\n"), "thumbOptions: must be a mapping"},
|
||||
"useCaption not bool": {photo(" useCaption: maybe\n"), "useCaption:"},
|
||||
"no relation": {" brochure:\n type: fileupload\n", "field brochure: is not an attachment relation the model declares in AttachRelations"},
|
||||
"maxFiles on attachOne": {" manual:\n type: fileupload\n maxFiles: 2\n", "field manual: maxFiles is only valid on an attachMany relation"},
|
||||
"key on another type": {" name2:\n type: text\n maxFiles: 2\n", "maxFiles is only valid on type: fileupload"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := bootGadgetFields(t, dfGadgetFields(tc.field))
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "models/gadget/fields.yaml") {
|
||||
t.Fatalf("err = %v, want %q naming the file", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A fileupload on a model without attach.Owner (the members manage
|
||||
// form) stops boot.
|
||||
member := "fields:\n email:\n type: text\n avatar:\n type: fileupload\n"
|
||||
err := activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/member/fields.yaml": member})}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "type fileupload needs a model implementing attach.Owner") {
|
||||
t.Fatalf("model without attach.Owner: %v", err)
|
||||
}
|
||||
// maxFilesize above http.body_limits.upload_bytes stops boot.
|
||||
err = bootGadgetFields(t, dfGadgetFields(photo(" maxFilesize: 2\n")))
|
||||
if err != nil {
|
||||
t.Fatalf("2 MB without an upload cap: %v", err)
|
||||
}
|
||||
err = activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": dfGadgetFields(photo(" maxFilesize: 2\n"))})},
|
||||
map[string]any{"http.body_limits.upload_bytes": 1048576})
|
||||
if err == nil || !strings.Contains(err.Error(), "field photos: maxFilesize exceeds http.body_limits.upload_bytes") {
|
||||
t.Fatalf("maxFilesize over upload_bytes: %v", err)
|
||||
}
|
||||
if err := bootGadgetFields(t, dfGadgetFields(photo(" mode: image\n fileTypes: jpg|png\n mimeTypes: image/png, png\n maxFiles: 2\n imageWidth: 120\n thumbOptions:\n mode: fit\n useCaption: true\n prompt: Drop\n"))); err != nil {
|
||||
t.Fatalf("every key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// photosPath and manualPath build a gadget's photos file route.
|
||||
func photosPath(gadget uint, rest string) string { return dfPath(gadget, "/files/photos"+rest) }
|
||||
|
||||
// fileItems decodes a file list.
|
||||
func fileItems(t *testing.T, c dfClient, gadget uint, field, key string) []cabana.FileItem {
|
||||
t.Helper()
|
||||
var h map[string]string
|
||||
if key != "" {
|
||||
h = sk(key)
|
||||
}
|
||||
return fileList(t, c.do(t, http.MethodGet, dfPath(gadget, "/files/"+field), nil, h))
|
||||
}
|
||||
|
||||
func itemIDs(items []cabana.FileItem) []uint {
|
||||
out := make([]uint, 0, len(items))
|
||||
for _, it := range items {
|
||||
out = append(out, it.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *dfEnv) fileRow(t *testing.T, id uint) (attach.File, bool) {
|
||||
t.Helper()
|
||||
var f attach.File
|
||||
err := e.db.Where("id = ?", id).Limit(1).Find(&f).Error
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f, f.ID != 0
|
||||
}
|
||||
|
||||
func (e *dfEnv) blob(t *testing.T, f attach.File) bool {
|
||||
t.Helper()
|
||||
ok, err := e.bucket.Exists(context.Background(), attach.BlobKey(f.DiskName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// TestFileuploadUpload: an upload on id 0 is 201 and pending; the request
|
||||
// cap answers 413 and the field limits answer 422 on the field in the
|
||||
// request locale; maxFiles counts the visible files at upload; the list
|
||||
// orders by sort_order and flags pending uploads (D-03, D-08, D-09).
|
||||
func TestFileuploadUpload(t *testing.T) {
|
||||
env := newDeferredEnv(t)
|
||||
key := newSessionKey(t)
|
||||
png := conformPNG(t)
|
||||
|
||||
up := env.a.upload(t, photosPath(0, ""), "a.png", png, sk(key))
|
||||
want(t, "upload", up, http.StatusCreated)
|
||||
var item cabana.Envelope[cabana.FileItem]
|
||||
if err := json.Unmarshal(up.Body.Bytes(), &item); err != nil || !item.Data.Pending || item.Data.FileName != "a.png" || item.Data.URL == "" || item.Data.ContentType != "image/png" {
|
||||
t.Fatalf("upload item = %s (%v)", up.Body.String(), err)
|
||||
}
|
||||
|
||||
before := env.state(t)
|
||||
// photos: maxFilesize 0.5 MB, cap 512 KiB + 64 KiB of multipart framing.
|
||||
big := append(append([]byte{}, png...), bytes.Repeat([]byte{0}, 700<<10)...)
|
||||
want(t, "body past the cap", env.a.upload(t, photosPath(0, ""), "big.png", big, sk(key)), http.StatusRequestEntityTooLarge)
|
||||
over := append(append([]byte{}, png...), bytes.Repeat([]byte{0}, 530<<10)...)
|
||||
rec := env.a.upload(t, photosPath(0, ""), "over.png", over, sk(key))
|
||||
want(t, "file over maxFilesize", rec, http.StatusUnprocessableEntity)
|
||||
if d := errorDetails(t, rec)["photos"]; len(d) != 1 || d[0] != "The photos may not be greater than 512 kilobytes." {
|
||||
t.Fatalf("size message %v", d)
|
||||
}
|
||||
rec = env.a.upload(t, photosPath(0, ""), "x.svg", []byte("<svg/>"), sk(key))
|
||||
want(t, "wrong type", rec, http.StatusUnprocessableEntity)
|
||||
if d := errorDetails(t, rec)["photos"]; len(d) != 1 || d[0] != "The photos must be a file of type: jpg, jpeg, png, gif, webp." {
|
||||
t.Fatalf("type message %v", d)
|
||||
}
|
||||
rec = env.a.upload(t, photosPath(0, ""), "fake.png", []byte("GIF89a<script>alert(1)</script>"), map[string]string{cabana.SessionKeyHeader: key, "Accept-Language": "pl"})
|
||||
want(t, "polyglot", rec, http.StatusUnprocessableEntity)
|
||||
if d := errorDetails(t, rec)["photos"]; len(d) != 1 || d[0] != "photos musi być obrazkiem." {
|
||||
t.Fatalf("localized image message %v", d)
|
||||
}
|
||||
want(t, "no key", env.a.upload(t, photosPath(0, ""), "a.png", png, nil), http.StatusUnprocessableEntity)
|
||||
want(t, "unknown field", env.a.upload(t, dfPath(0, "/files/nothere"), "a.png", png, sk(key)), http.StatusNotFound)
|
||||
env.unchanged(t, "refused uploads", before)
|
||||
|
||||
for i := 2; i <= 3; i++ {
|
||||
want(t, fmt.Sprintf("upload %d", i), env.a.upload(t, photosPath(0, ""), fmt.Sprintf("%d.png", i), png, sk(key)), http.StatusCreated)
|
||||
}
|
||||
rec = env.a.upload(t, photosPath(0, ""), "4.png", png, sk(key))
|
||||
want(t, "fourth upload 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("maxFiles message %v", d)
|
||||
}
|
||||
items := fileItems(t, env.a, 0, "photos", key)
|
||||
if len(items) != 3 || !slices.IsSortedFunc(items, func(a, b cabana.FileItem) int { return a.SortOrder - b.SortOrder }) {
|
||||
t.Fatalf("pending list = %+v", items)
|
||||
}
|
||||
for _, it := range items {
|
||||
if !it.Pending || it.URL == "" || it.ThumbURL == "" {
|
||||
t.Fatalf("pending public item %+v", it)
|
||||
}
|
||||
}
|
||||
|
||||
// After the save the same files are attached and no longer pending.
|
||||
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)
|
||||
g := dataID(t, saved.Body.Bytes())
|
||||
attached := fileItems(t, env.a, g, "photos", "")
|
||||
if fmt.Sprint(itemIDs(attached)) != fmt.Sprint(itemIDs(items)) {
|
||||
t.Fatalf("attached %v, pending was %v", itemIDs(attached), itemIDs(items))
|
||||
}
|
||||
for _, it := range attached {
|
||||
if it.Pending {
|
||||
t.Fatalf("attached item still pending %+v", it)
|
||||
}
|
||||
}
|
||||
// A new session sees the attached files plus its own pending upload.
|
||||
k2 := newSessionKey(t)
|
||||
want(t, "upload on a full field", env.a.upload(t, photosPath(g, ""), "5.png", png, sk(k2)), http.StatusUnprocessableEntity)
|
||||
first := attached[0].ID
|
||||
want(t, "remove one", env.a.do(t, http.MethodDelete, photosPath(g, fmt.Sprintf("/%d", first)), nil, sk(k2)), http.StatusOK)
|
||||
up = env.a.upload(t, photosPath(g, ""), "5.png", png, sk(k2))
|
||||
want(t, "upload after a deferred removal", up, http.StatusCreated)
|
||||
mixed := fileItems(t, env.a, g, "photos", k2)
|
||||
if len(mixed) != 3 || mixed[2].ID != dataID(t, up.Body.Bytes()) || !mixed[2].Pending || mixed[0].Pending || slices.Contains(itemIDs(mixed), first) {
|
||||
t.Fatalf("session list = %+v", mixed)
|
||||
}
|
||||
if got := fileItems(t, env.a, g, "photos", ""); len(got) != 3 || !slices.Contains(itemIDs(got), first) {
|
||||
t.Fatalf("list without the session = %v", itemIDs(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileuploadRemove: removing a pending upload deletes its row and blob
|
||||
// at once; removing an attached file is deferred to the save, which then
|
||||
// deletes row and blob after commit.
|
||||
func TestFileuploadRemove(t *testing.T) {
|
||||
env := newDeferredEnv(t)
|
||||
g := env.gadget(t, "g-"+env.stamp, false)
|
||||
attached := env.storeFile(t, dfGadgetMorph, g, "photos", "a.png", conformPNG(t), true)
|
||||
key := newSessionKey(t)
|
||||
up := env.a.upload(t, photosPath(g, ""), "p.png", conformPNG(t), sk(key))
|
||||
want(t, "upload", up, http.StatusCreated)
|
||||
pending, _ := env.fileRow(t, dataID(t, up.Body.Bytes()))
|
||||
|
||||
want(t, "remove pending", env.a.do(t, http.MethodDelete, photosPath(g, fmt.Sprintf("/%d", pending.ID)), nil, sk(key)), http.StatusOK)
|
||||
if _, ok := env.fileRow(t, pending.ID); ok || env.blob(t, pending) {
|
||||
t.Fatal("removed pending upload kept its row or blob")
|
||||
}
|
||||
want(t, "remove attached", env.a.do(t, http.MethodDelete, photosPath(g, fmt.Sprintf("/%d", attached.ID)), nil, sk(key)), http.StatusOK)
|
||||
want(t, "remove attached again", env.a.do(t, http.MethodDelete, photosPath(g, fmt.Sprintf("/%d", attached.ID)), nil, sk(key)), http.StatusOK)
|
||||
if _, ok := env.fileRow(t, attached.ID); !ok || !env.blob(t, attached) {
|
||||
t.Fatal("a deferred removal deleted the file before the save")
|
||||
}
|
||||
if got := fileItems(t, env.a, g, "photos", key); len(got) != 0 {
|
||||
t.Fatalf("session still lists %v", itemIDs(got))
|
||||
}
|
||||
want(t, "save", env.a.do(t, http.MethodPut, dfPath(g, ""), map[string]any{"name": "g-" + env.stamp}, sk(key)), http.StatusOK)
|
||||
if _, ok := env.fileRow(t, attached.ID); ok || env.blob(t, attached) {
|
||||
t.Fatal("the save kept the removed file or its blob")
|
||||
}
|
||||
if n := len(env.state(t).Bindings); n != 0 {
|
||||
t.Fatalf("bindings left %d", n)
|
||||
}
|
||||
want(t, "remove without a key", env.a.do(t, http.MethodDelete, photosPath(g, "/1"), nil, nil), http.StatusUnprocessableEntity)
|
||||
}
|
||||
|
||||
// TestFileuploadCaption: a field without useCaption refuses caption edits
|
||||
// (403); with it the title and description are saved at once, not
|
||||
// deferred, and are limited in length.
|
||||
func TestFileuploadCaption(t *testing.T) {
|
||||
env := newDeferredEnv(t)
|
||||
g := env.gadget(t, "g-"+env.stamp, false)
|
||||
photo := env.storeFile(t, dfGadgetMorph, g, "photos", "a.png", conformPNG(t), true)
|
||||
manual := env.storeFile(t, dfGadgetMorph, g, "manual", "m.txt", []byte("manual"), false)
|
||||
key := newSessionKey(t)
|
||||
want(t, "caption without useCaption", env.a.do(t, http.MethodPut, photosPath(g, fmt.Sprintf("/%d", photo.ID)), map[string]any{"title": "x"}, sk(key)), http.StatusForbidden)
|
||||
rec := env.a.do(t, http.MethodPut, manualPath(g, manual.ID, ""), map[string]any{"title": "Manual", "description": "The full manual"}, sk(key))
|
||||
want(t, "caption", rec, http.StatusOK)
|
||||
f, _ := env.fileRow(t, manual.ID)
|
||||
if strOrNil(f.Title) != "Manual" || strOrNil(f.Description) != "The full manual" {
|
||||
t.Fatalf("caption not saved at once: %+v", f)
|
||||
}
|
||||
want(t, "caption with an unknown key", env.a.do(t, http.MethodPut, manualPath(g, manual.ID, ""), map[string]any{"title": "x", "sort_order": 1}, sk(key)), http.StatusUnprocessableEntity)
|
||||
}
|
||||
|
||||
// TestFileuploadReorder: the id set must equal the visible files exactly
|
||||
// (422 otherwise), the new order is written to sort_order at once, and an
|
||||
// attachOne field has no reorder.
|
||||
func TestFileuploadReorder(t *testing.T) {
|
||||
env := newDeferredEnv(t)
|
||||
g := env.gadget(t, "g-"+env.stamp, false)
|
||||
a := env.storeFile(t, dfGadgetMorph, g, "photos", "a.png", conformPNG(t), true)
|
||||
b := env.storeFile(t, dfGadgetMorph, g, "photos", "b.png", conformPNG(t), true)
|
||||
c := env.storeFile(t, dfGadgetMorph, g, "photos", "c.png", conformPNG(t), true)
|
||||
key := newSessionKey(t)
|
||||
for name, ids := range map[string][]uint{
|
||||
"missing one": {a.ID, b.ID},
|
||||
"extra id": {a.ID, b.ID, c.ID, c.ID + 100},
|
||||
"duplicate id": {a.ID, a.ID, b.ID},
|
||||
"empty": {},
|
||||
} {
|
||||
before := env.state(t)
|
||||
want(t, "reorder "+name, env.a.do(t, http.MethodPost, photosPath(g, "/reorder"), map[string]any{"ids": ids}, sk(key)), http.StatusUnprocessableEntity)
|
||||
env.unchanged(t, "reorder "+name, before)
|
||||
}
|
||||
rec := env.a.do(t, http.MethodPost, photosPath(g, "/reorder"), map[string]any{"ids": []uint{c.ID, a.ID, b.ID}}, sk(key))
|
||||
want(t, "reorder", rec, http.StatusOK)
|
||||
if got := itemIDs(fileItems(t, env.a, g, "photos", "")); fmt.Sprint(got) != fmt.Sprint([]uint{c.ID, a.ID, b.ID}) {
|
||||
t.Fatalf("order = %v", got)
|
||||
}
|
||||
// The permutation reuses the existing sort_order values.
|
||||
var orders []int
|
||||
if err := env.db.Model(&attach.File{}).Where("id IN ?", []uint{a.ID, b.ID, c.ID}).Order("sort_order").Pluck("sort_order", &orders).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(orders) != fmt.Sprint([]int{a.SortOrder, b.SortOrder, c.SortOrder}) {
|
||||
t.Fatalf("sort orders %v, want the permutation of %d %d %d", orders, a.SortOrder, b.SortOrder, c.SortOrder)
|
||||
}
|
||||
m := env.storeFile(t, dfGadgetMorph, g, "manual", "m.txt", []byte("m"), false)
|
||||
rec = env.a.do(t, http.MethodPost, dfPath(g, "/files/manual/reorder"), map[string]any{"ids": []uint{m.ID}}, sk(key))
|
||||
if rec.Code/100 == 2 {
|
||||
t.Fatalf("attachOne reorder status=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileuploadAttachOneReplace: two uploads into an attachOne field in
|
||||
// one session leave the last one attached at the save; the replaced file
|
||||
// and the file attached before lose their rows and blobs after commit.
|
||||
func TestFileuploadAttachOneReplace(t *testing.T) {
|
||||
env := newDeferredEnv(t)
|
||||
g := env.gadget(t, "g-"+env.stamp, false)
|
||||
old := env.storeFile(t, dfGadgetMorph, g, "manual", "old.txt", []byte("old"), false)
|
||||
key := newSessionKey(t)
|
||||
upA := env.a.upload(t, dfPath(g, "/files/manual"), "a.txt", []byte("a"), sk(key))
|
||||
upB := env.a.upload(t, dfPath(g, "/files/manual"), "b.txt", []byte("b"), sk(key))
|
||||
want(t, "upload a", upA, http.StatusCreated)
|
||||
want(t, "upload b", upB, http.StatusCreated)
|
||||
a, _ := env.fileRow(t, dataID(t, upA.Body.Bytes()))
|
||||
want(t, "save", env.a.do(t, http.MethodPut, dfPath(g, ""), map[string]any{"name": "g-" + env.stamp}, sk(key)), http.StatusOK)
|
||||
got := fileItems(t, env.a, g, "manual", "")
|
||||
if len(got) != 1 || got[0].ID != dataID(t, upB.Body.Bytes()) || got[0].URL != "" {
|
||||
t.Fatalf("attachOne after save = %+v", got)
|
||||
}
|
||||
for _, f := range []attach.File{old, a} {
|
||||
if _, ok := env.fileRow(t, f.ID); ok || env.blob(t, f) {
|
||||
t.Fatalf("replaced file %s kept its row or blob", f.FileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileuploadMIME: a mimeTypes list refuses an allowed extension whose
|
||||
// sniffed type is not listed (422 on the field).
|
||||
func TestFileuploadMIME(t *testing.T) {
|
||||
fields := strings.Replace(dfFile(t, "models/gadget/fields.yaml"), " fileTypes: [pdf, png, svg, txt, html]\n", " fileTypes: [pdf, png, svg, txt, html]\n mimeTypes: [application/pdf, text/plain]\n", 1)
|
||||
env := newDeferredEnvWith(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": fields})})
|
||||
g := env.gadget(t, "g-"+env.stamp, false)
|
||||
key := newSessionKey(t)
|
||||
rec := env.a.upload(t, dfPath(g, "/files/manual"), "a.png", conformPNG(t), sk(key))
|
||||
want(t, "png under a pdf/text list", rec, http.StatusUnprocessableEntity)
|
||||
if d := errorDetails(t, rec)["manual"]; len(d) != 1 || d[0] != "The manual must be a file of type: application/pdf, text/plain." {
|
||||
t.Fatalf("mime message %v", d)
|
||||
}
|
||||
want(t, "text", env.a.upload(t, dfPath(g, "/files/manual"), "a.txt", []byte("hello"), sk(key)), http.StatusCreated)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
@@ -59,11 +60,13 @@ type dfGadget struct {
|
||||
Hidden bool `gorm:"column:hidden;not null;default:false"`
|
||||
ReleasedOn lagoon.Date `gorm:"column:released_on;type:date"`
|
||||
StartsAt *time.Time `gorm:"column:starts_at"`
|
||||
OpensAt lagoon.TimeOfDay `gorm:"column:opens_at;type:time"`
|
||||
OpensAt lagoon.TimeOfDay `gorm:"column:opens_at;type:time without time zone"`
|
||||
Members []dfMember `gorm:"-"`
|
||||
Parts []dfPart `gorm:"-"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
// StrictParts backs a test-only hasMany on the NOT NULL dfStrictPart.
|
||||
StrictParts []dfStrictPart `gorm:"-"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (dfGadget) TableName() string { return "cabana_deferred_gadgets" }
|
||||
@@ -183,13 +186,24 @@ func (r *dfRecorder) hook(ctx context.Context, name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type dfPlugin struct{ rec *dfRecorder }
|
||||
// dfPlugin is the fixture plugin. The zero options give the testdata tree,
|
||||
// the gadgets and locked controllers and the full Models() list; a test
|
||||
// may replace the tree (dfOverlay), the controllers or the models.
|
||||
type dfPlugin struct {
|
||||
rec *dfRecorder
|
||||
fsys fs.FS
|
||||
controllers func(rec *dfRecorder) []pact.AdminController
|
||||
noModels bool
|
||||
}
|
||||
|
||||
func (dfPlugin) ID() string { return "acme.deferred" }
|
||||
func (dfPlugin) Requires() []string { return nil }
|
||||
func (dfPlugin) Register(*backpack.App) error { return nil }
|
||||
func (dfPlugin) Boot(*backpack.App) error { return nil }
|
||||
func (p dfPlugin) AdminControllers() []pact.AdminController {
|
||||
if p.controllers != nil {
|
||||
return p.controllers(p.rec)
|
||||
}
|
||||
return []pact.AdminController{
|
||||
dfController{rec: p.rec, id: "acme.deferred.gadgets", dir: "controllers/gadgets"},
|
||||
dfController{rec: p.rec, id: "acme.deferred.locked", dir: "controllers/locked"},
|
||||
@@ -197,7 +211,12 @@ func (p dfPlugin) AdminControllers() []pact.AdminController {
|
||||
}
|
||||
|
||||
// Models lists every fixture model deferred:purge may delete.
|
||||
func (dfPlugin) Models() []any { return []any{&dfGadget{}, &dfPart{}, &dfMember{}} }
|
||||
func (p dfPlugin) Models() []any {
|
||||
if p.noModels {
|
||||
return nil
|
||||
}
|
||||
return []any{&dfGadget{}, &dfPart{}, &dfMember{}}
|
||||
}
|
||||
|
||||
func (dfPlugin) Permissions() []pact.Permission {
|
||||
return []pact.Permission{{Code: "acme.deferred.access", Roles: []string{"developer"}}}
|
||||
@@ -207,13 +226,84 @@ func (dfPlugin) Navigation() []pact.NavigationItem {
|
||||
Permissions: []string{"acme.deferred.access"}}}
|
||||
}
|
||||
|
||||
// AdminFS is the YAML tree under testdata/deferred.
|
||||
func (dfPlugin) AdminFS() fs.FS { return os.DirFS(filepath.Join("testdata", "deferred")) }
|
||||
// AdminFS is the YAML tree under testdata/deferred, or the test's overlay.
|
||||
func (p dfPlugin) AdminFS() fs.FS {
|
||||
if p.fsys != nil {
|
||||
return p.fsys
|
||||
}
|
||||
return os.DirFS(filepath.Join("testdata", "deferred"))
|
||||
}
|
||||
|
||||
// dfOverlay is the testdata tree with files replaced; an empty value
|
||||
// removes the file.
|
||||
func dfOverlay(t *testing.T, files map[string]string) fs.FS {
|
||||
t.Helper()
|
||||
base := os.DirFS(filepath.Join("testdata", "deferred"))
|
||||
out := fstest.MapFS{}
|
||||
err := fs.WalkDir(base, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
data, err := fs.ReadFile(base, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out[path] = &fstest.MapFile{Data: data}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, data := range files {
|
||||
if data == "" {
|
||||
delete(out, name)
|
||||
continue
|
||||
}
|
||||
out[name] = &fstest.MapFile{Data: []byte(data)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dfFile reads one testdata file, for a test that edits it.
|
||||
func dfFile(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "deferred", filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// activateDeferred boots cabana with the fixture plugin p (no database)
|
||||
// and returns the boot error.
|
||||
func activateDeferred(t *testing.T, p dfPlugin, config map[string]any) error {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-deferred-boot\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)
|
||||
}
|
||||
for k, v := range config {
|
||||
if err := cfg.Set(k, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if p.rec == nil {
|
||||
p.rec = &dfRecorder{fail: map[string]bool{}}
|
||||
}
|
||||
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{p})
|
||||
return err
|
||||
}
|
||||
|
||||
type dfController struct {
|
||||
rec *dfRecorder
|
||||
id string
|
||||
dir string
|
||||
// contracts, when set, replaces the relation contracts.
|
||||
contracts func() []cabana.RelationContract
|
||||
}
|
||||
|
||||
func (c dfController) ID() string { return c.id }
|
||||
@@ -221,7 +311,16 @@ func (dfController) ModelName() string { return "Gadget" }
|
||||
func (c dfController) ConfigDir() string { return c.dir }
|
||||
func (dfController) RequiredPermissions() []string { return []string{"acme.deferred.access"} }
|
||||
func (dfController) NewRecord() any { return &dfGadget{} }
|
||||
func (dfController) AdminRelationContracts() []cabana.RelationContract {
|
||||
func (c dfController) AdminRelationContracts() []cabana.RelationContract {
|
||||
if c.contracts != nil {
|
||||
return c.contracts()
|
||||
}
|
||||
return dfContracts()
|
||||
}
|
||||
|
||||
// dfContracts are the fixture's relation contracts: members (belongsToMany
|
||||
// by the empty Kind) and parts (hasMany on the nullable gadget_id).
|
||||
func dfContracts() []cabana.RelationContract {
|
||||
return []cabana.RelationContract{{
|
||||
Name: "members", NewRelated: func() any { return &dfMember{} }, NewPivot: func() any { return &dfGadgetMember{} },
|
||||
ParentForeignKey: "gadget_id", RelatedForeignKey: "member_id", Columns: map[string]string{"email": "email"},
|
||||
@@ -232,6 +331,15 @@ func (dfController) AdminRelationContracts() []cabana.RelationContract {
|
||||
}}
|
||||
}
|
||||
|
||||
// AdminActions registers one widget action, so a relation form's widget
|
||||
// field reaches the D-23 refusal instead of failing on its action.
|
||||
func (dfController) AdminActions() []pact.AdminAction {
|
||||
return []pact.AdminAction{{Name: "lookup", Label: "Look up", Permissions: []string{"acme.deferred.access"},
|
||||
Run: func(context.Context, pact.AdminActionInput) (pact.AdminActionResult, error) {
|
||||
return pact.AdminActionResult{}, nil
|
||||
}}}
|
||||
}
|
||||
|
||||
// FormExtendQuery hides gadgets marked hidden.
|
||||
func (dfController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
|
||||
return db.Where("hidden = ?", false)
|
||||
@@ -298,6 +406,13 @@ type dfClient struct {
|
||||
var dfModels = []any{&dfGadget{}, &dfPart{}, &dfMember{}, &dfGadgetMember{}}
|
||||
|
||||
func newDeferredEnv(t *testing.T) *dfEnv {
|
||||
t.Helper()
|
||||
return newDeferredEnvWith(t, dfPlugin{})
|
||||
}
|
||||
|
||||
// newDeferredEnvWith assembles the router over the plugin p (its recorder
|
||||
// is replaced by the env's).
|
||||
func newDeferredEnvWith(t *testing.T, p dfPlugin) *dfEnv {
|
||||
t.Helper()
|
||||
gdb := adminGorm(t)
|
||||
if err := gdb.Migrator().DropTable(dfModels...); err != nil {
|
||||
@@ -339,7 +454,8 @@ func newDeferredEnv(t *testing.T) *dfEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := &dfRecorder{fail: map[string]bool{}}
|
||||
plugins := []party.Plugin{dfPlugin{rec: rec}}
|
||||
p.rec = rec
|
||||
plugins := []party.Plugin{p}
|
||||
if err := phrasebook.Activate(app, plugins); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -572,11 +688,19 @@ func (e *dfEnv) state(t *testing.T) dfState {
|
||||
for _, q := range []struct {
|
||||
dest any
|
||||
order string
|
||||
}{{&s.Gadgets, "id"}, {&s.Members, "id"}, {&s.Pivots, "id"}, {&s.Files, "id"}, {&s.Bindings, "id"}} {
|
||||
}{{&s.Gadgets, "id"}, {&s.Members, "id"}, {&s.Pivots, "id"}} {
|
||||
if err := e.db.Order(q.order).Find(q.dest).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Other tests share the database: only the fixture's files (and the
|
||||
// unattached ones, cleared when the env starts) and bindings count.
|
||||
if err := e.db.Where("attachment_type IN ? OR attachment_id IS NULL OR attachment_id = ''", []string{dfGadgetMorph, dfPartMorph}).Order("id").Find(&s.Files).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.db.Where("master_type IN ?", []string{dfGadgetMorph, dfPartMorph}).Order("id").Find(&s.Bindings).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.db.Unscoped().Order("id").Find(&s.Parts).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
588
modules/cabana/relation_child_test.go
Normal file
588
modules/cabana/relation_child_test.go
Normal file
@@ -0,0 +1,588 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,5 @@ list: ~/plugins/acme/deferred/models/gadget/columns.yaml
|
||||
modelClass: Gadget
|
||||
title: Locked gadgets
|
||||
recordUrl: acme/deferred/locked/update/:id
|
||||
toolbar:
|
||||
buttons: [create]
|
||||
|
||||
@@ -30,6 +30,7 @@ fields:
|
||||
type: fileupload
|
||||
mode: image
|
||||
maxFiles: 3
|
||||
maxFilesize: 0.5
|
||||
manual:
|
||||
label: Manual
|
||||
type: fileupload
|
||||
|
||||
Reference in New Issue
Block a user