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:
Jakub Zych
2026-10-02 20:34:23 +02:00
parent 9c87a59532
commit c8d41a68c6
16 changed files with 3509 additions and 10 deletions

View 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)
}
}