- 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
173 lines
6.4 KiB
Go
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())
|
|
}
|
|
}
|