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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user