Files
summercms/modules/cabana/datepicker_smoke_test.go
Jakub Zych 48a5b8045a feat(12.2-03): add hasMany relation contracts, relation forms and child create
- RelationContract gains Kind (empty is belongsToMany) and ForeignKey, with kind-aware boot checks
- manage.form, view.form and pivot.form compile against the related or pivot model; $/ paths resolve inside the plugin
- view toolbarButtons accept create|update|delete|link|unlink, each the capability of its routes
- POST .../relations/{name}/records creates a child through the manage form; the server sets the hasMany key
- relation schema carries kind, deferrable and the localized forms; 17 new relation message keys in en and pl
2026-10-02 18:37:13 +02:00

173 lines
6.4 KiB
Go

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]
parts:
type: relation-manager
relation: parts
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())
}
}