package cabana_test import ( "encoding/json" "io/fs" "net/http" "os" "path/filepath" "strings" "testing" "testing/fstest" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/party" ) // datepickerFields is the gadget fields.yaml the conform controller's // contracts need, plus extra. func datepickerFields(extra string) string { return `fields: name: label: Name type: text group: label: Group type: relation nameFrom: title members: type: relation-manager relation: members context: [update] lookup: label: Lookup type: widget widget: acme-conform-lookup action: lookup fill: [name] summary: label: Summary type: partial path: summary ` + extra } // datepickerFS is the conform plugin tree with its gadget fields.yaml // replaced. func datepickerFS(fields string) fs.FS { base := conformFS().(fstest.MapFS) out := fstest.MapFS{} for name, file := range base { out[name] = file } out["models/gadget/fields.yaml"] = &fstest.MapFile{Data: []byte(fields)} return out } type datepickerPlugin struct { conformPlugin fsys fs.FS } func (p datepickerPlugin) AdminFS() fs.FS { return p.fsys } // activateFields runs cabana.Activate with the conform plugin and a gadget // fields.yaml. func activateFields(t *testing.T, fields string) error { t.Helper() dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-datepicker\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) } _, err = cabana.Activate(backpack.New(cfg), []party.Plugin{datepickerPlugin{fsys: datepickerFS(fields)}}) return err } func TestDatepickerSmokeCompile(t *testing.T) { for name, tc := range map[string]struct { field string want string }{ "unknown key": {" day:\n type: datepicker\n mode: date\n showWeekNumber: true\n", "unknown field showWeekNumber"}, "ignoreTimezone date": {" day:\n type: datepicker\n mode: date\n ignoreTimezone: true\n", "ignoreTimezone is only valid with mode: datetime"}, "unmapped token": {" starts_at:\n type: datepicker\n format: d.m.Y T\n", "token T has no equivalent"}, "minDate on time": {" day:\n type: datepicker\n mode: time\n minDate: 2026-01-01\n", "minDate is not valid with mode: time"}, "min after max": {" starts_at:\n type: datepicker\n minDate: 2026-02-01\n maxDate: 2026-01-01\n", "is after maxDate"}, "key on another type": {" title:\n type: text\n firstDay: 1\n", "firstDay is only valid on type: datepicker"}, "mode on another type": {" title:\n type: text\n mode: date\n", "mode is only valid on type: fileupload or datepicker"}, } { t.Run(name, func(t *testing.T) { err := activateFields(t, datepickerFields(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) } }) } form, err := cabana.CompileForm("acme.conform", conformController{}, datepickerFS(datepickerFields(" starts_at:\n type: datepicker\n format: d.m.Y H:i\n yearRange: 10\n firstDay: 1\n twelveHour: true\n"))) 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) } got := doc.Fields[len(doc.Fields)-1] if got["mode"] != "datetime" || got["format"] != "d.m.Y H:i" || got["displayFormat"] != "DD.MM.YYYY HH:mm" || got["firstDay"] != float64(1) || got["twelveHour"] != true { t.Fatalf("datepicker field = %v", got) } if years, _ := got["yearRange"].([]any); len(years) != 1 || years[0] != float64(10) { t.Fatalf("yearRange = %v", got["yearRange"]) } } func TestDatepickerSmokeTypeMismatch(t *testing.T) { err := activateFields(t, datepickerFields(" starts_at:\n type: datepicker\n mode: date\n")) if err == nil || !strings.Contains(err.Error(), "field starts_at: datepicker mode date needs a lagoon.Date or *lagoon.Date column, found *time.Time") { t.Fatalf("err = %v", err) } if err := activateFields(t, datepickerFields(" released_on:\n type: datepicker\n mode: date\n starts_at:\n type: datepicker\n")); err != nil { t.Fatalf("matching types failed boot: %v", err) } } // TestDatepickerSmokeSave saves a date and a datetime through the admin // API: DATE stays the calendar day, the datetime is stored in UTC, and a // date before minDate is a 422 on the field. func TestDatepickerSmokeSave(t *testing.T) { env := newConformEnv(t) env.loginAs(t, env.login) rec := env.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "dates-" + env.stamp, "released_on": "2026-10-02", "starts_at": "2026-10-02T12:30:00+02:00"}, true) if rec.Code != http.StatusCreated { t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String()) } id := dataID(t, rec.Body.Bytes()) 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" { t.Fatalf("record = %s", rec.Body.String()) } var row struct { Day string UTCTime string } err := env.db.Raw(`SELECT released_on::text AS day, to_char(starts_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI') AS utc_time FROM cabana_conform_gadgets WHERE id = ?`, id).Scan(&row).Error if err != nil { t.Fatal(err) } if row.Day != "2026-10-02" || row.UTCTime != "2026-10-02 10:30" { t.Fatalf("stored = %+v", row) } early := env.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "early-" + env.stamp, "released_on": "1999-12-31"}, true) if early.Code != http.StatusUnprocessableEntity || !strings.Contains(early.Body.String(), "released on must be a date after or equal to 2000-01-01") { t.Fatalf("before minDate status=%d body=%s", early.Code, early.Body.String()) } }