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 "" } 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] != "|" { t.Fatalf("rows = %v", got) } }