package cabana import ( "context" "encoding/json" "errors" "fmt" "io/fs" "strings" "testing" "testing/fstest" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" ) // Docker-free unit tests of the Phase 17 form features (plan 06): they // compile the acme.unit links controller from an fstest tree and run the // decode, recheck and sanitize steps of a save without a database. The // Postgres tests through the HTTP routes are in companions_postgres_test.go, // reorder_test.go and singleton_test.go. // ucLink is the links model of the unit fixture. type ucLink struct { ID uint `gorm:"column:id;primaryKey"` Title string `gorm:"column:title"` Type *string `gorm:"column:type"` Reference *string `gorm:"column:reference"` Description *string `gorm:"column:description"` Rows lagoon.Jsonable[[]map[string]any] `gorm:"column:rows"` } func (*ucLink) TableName() string { return "uc_links" } func (*ucLink) Fillable() []string { return []string{"title", "type", "reference", "description", "rows"} } func (*ucLink) Rules() map[string]string { return map[string]string{} } // ucDependent is the fake dependent options binding: acme-page offers a // two-level tree, acme-list and every other value take no reference. func ucDependent(_ context.Context, _ string, values map[string]any) (pact.DependentOptionSet, error) { for _, v := range values { switch v { case "acme-page": return pact.DependentOptionSet{Applies: true, Options: []pact.Option{{Value: "1", Label: "Home"}, {Value: "2", Label: "About", Depth: 1}}}, nil case "acme-broken": return pact.DependentOptionSet{}, errors.New("references failed") } } return pact.DependentOptionSet{Applies: false, Options: []pact.Option{}}, nil } // ucLinks is the links controller of the unit fixture. type ucLinks struct { calls *[]string } func (ucLinks) ID() string { return "acme.unit.links" } func (ucLinks) ModelName() string { return "Link" } func (ucLinks) ConfigDir() string { return "controllers/links" } func (ucLinks) RequiredPermissions() []string { return []string{"acme.unit.access"} } func (ucLinks) NewRecord() any { return &ucLink{} } func (ucLinks) DropdownOptions(string) []pact.Option { return []pact.Option{{Value: "url", Label: "URL"}, {Value: "acme-page", Label: "Page"}, {Value: "acme-list", Label: "All pages"}} } func (c ucLinks) DependentOptions(ctx context.Context, field string, values map[string]any) (pact.DependentOptionSet, error) { if c.calls != nil { *c.calls = append(*c.calls, fmt.Sprintf("%s=%v", field, values)) } return ucDependent(ctx, field, values) } // ucPlainLinks is the same controller without a DependentOptionsProvider. type ucPlainLinks struct{} func (ucPlainLinks) ID() string { return "acme.unit.links" } func (ucPlainLinks) ModelName() string { return "Link" } func (ucPlainLinks) ConfigDir() string { return "controllers/links" } func (ucPlainLinks) RequiredPermissions() []string { return []string{"acme.unit.access"} } func (ucPlainLinks) NewRecord() any { return &ucLink{} } func (ucPlainLinks) DropdownOptions(string) []pact.Option { return []pact.Option{{Value: "url", Label: "URL"}} } // ucPlugin serves the unit fixture tree with one controller. type ucPlugin struct { fsys fs.FS ctl pact.AdminController } func (ucPlugin) ID() string { return "acme.unit" } func (ucPlugin) Requires() []string { return nil } func (ucPlugin) Register(*backpack.App) error { return nil } func (ucPlugin) Boot(*backpack.App) error { return nil } func (p ucPlugin) AdminFS() fs.FS { return p.fsys } func (p ucPlugin) AdminControllers() []pact.AdminController { return []pact.AdminController{p.ctl} } // ucFields is the start of the unit fixture's fields.yaml: a title, a type // dropdown and a reference dropdown that depends on it. const ucFields = "fields:\n" + " title:\n type: text\n label: Title\n" + " type:\n type: dropdown\n label: Type\n options: typeOptions\n" + " reference:\n type: dropdown\n label: Reference\n dependsOn: type\n" // ucRepeater is a valid repeater named rows with every nested type it // takes; extra is appended to the repeater's own keys. func ucRepeater(extra string) string { return " rows:\n type: repeater\n label: Rows\n" + extra + " form:\n fields:\n" + " title:\n type: text\n required: true\n" + " body:\n type: richeditor\n" + " count:\n type: number\n" + " on:\n type: checkbox\n" + " flag:\n type: switch\n" + " note:\n type: textarea\n" + " type:\n type: dropdown\n options: typeOptions\n" + " reference:\n type: dropdown\n dependsOn: type\n" } // ucFS is the unit fixture tree with the given fields.yaml. func ucFS(fields string) fstest.MapFS { return fstest.MapFS{ "controllers/links/config_form.yaml": &fstest.MapFile{Data: []byte("name: Link\nform: ~/plugins/acme/unit/models/link/fields.yaml\nmodelClass: Link\n")}, "controllers/links/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/unit/models/link/columns.yaml\nmodelClass: Link\nrecordsPerPage: 20\n")}, "models/link/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n title:\n label: Title\n")}, "models/link/fields.yaml": &fstest.MapFile{Data: []byte(fields)}, } } // ucCompile compiles the links controller of fields with ctl (ucLinks when // nil). func ucCompile(fields string, ctl pact.AdminController) (*CompiledController, error) { if ctl == nil { ctl = ucLinks{} } reg, err := ftCompile(ucPlugin{fsys: ucFS(fields), ctl: ctl}) if err != nil { return nil, err } cc, ok := reg.Get("acme.unit.links") if !ok { return nil, errors.New("no links controller") } return cc, nil } func ucMustCompile(t *testing.T, fields string, ctl pact.AdminController) *CompiledController { t.Helper() cc, err := ucCompile(fields, ctl) if err != nil { t.Fatalf("compile: %v", err) } return cc } // ucField returns the compiled form field name of cc. func ucField(t *testing.T, cc *CompiledController, name string) FormField { t.Helper() for _, field := range cc.Form.Fields { if field.Name == name { return field } } t.Fatalf("no field %s", name) return FormField{} } // TestRepeaterCompile pins D-07's compile rules: every key a repeater // refuses, every nested type it refuses, the nested tab/context/preset and // dependsOn checks, the row bounds and titleFrom, and the repeater-only keys // on other types. func TestRepeaterCompile(t *testing.T) { t.Run("a valid repeater compiles with its nested form", func(t *testing.T) { cc := ucMustCompile(t, ucFields+ucRepeater(" prompt: Add row\n titleFrom: title\n minItems: 1\n maxItems: 3\n tab: Content\n span: left\n required: true\n comment: Rows\n"), nil) rows := ucField(t, cc, "rows") if rows.Span != "full" || rows.Prompt != "Add row" || rows.TitleFrom != "title" || rows.MinItems == nil || *rows.MinItems != 1 || rows.MaxItems == nil || *rows.MaxItems != 3 || !rows.Required || rows.Tab != "Content" { t.Fatalf("rows = %+v", rows) } if rows.Form == nil || len(rows.Form.Fields) != 8 { t.Fatalf("nested form = %+v", rows.Form) } var names []string for _, inner := range rows.Form.Fields { names = append(names, inner.Name+":"+inner.Type) } if strings.Join(names, ",") != "title:text,body:richeditor,count:number,on:checkbox,flag:switch,note:textarea,type:dropdown,reference:dropdown" { t.Fatalf("nested fields = %v", names) } if !rows.Form.Fields[0].Required || rows.Form.Fields[7].DependsOn != "type" { t.Fatalf("nested keys lost: %+v", rows.Form.Fields) } if names := repeaterNames(cc); !names["rows"] || len(names) != 1 { t.Fatalf("repeaterNames = %v", names) } }) nested := func(field string) string { return " rows:\n type: repeater\n form:\n fields:\n" + field } for _, tc := range []struct { name, fields, want string }{ {"groups", ucFields + ucRepeater(" groups: ~/groups.yaml\n"), "repeater groups are not supported"}, {"style", ucFields + ucRepeater(" style: accordion\n"), "repeater style is not supported"}, {"options key", ucFields + ucRepeater(" options: typeOptions\n"), "options is not valid on type: repeater"}, {"size key", ucFields + ucRepeater(" size: large\n"), "size is not valid on type: repeater"}, {"default key", ucFields + ucRepeater(" default: []\n"), "default is not valid on type: repeater"}, {"attributes key", ucFields + ucRepeater(" attributes: {}\n"), "attributes is not valid on type: repeater"}, {"no form", ucFields + " rows:\n type: repeater\n", "a repeater needs form: with fields"}, {"form without fields", ucFields + " rows:\n type: repeater\n form:\n label: x\n", "a repeater form takes fields only"}, {"form groups", ucFields + " rows:\n type: repeater\n form:\n groups: {}\n", "repeater groups are not supported"}, {"empty fields", ucFields + " rows:\n type: repeater\n form:\n fields: {}\n", "form.fields is empty"}, {"nested tab", ucFields + nested(" title:\n type: text\n tab: Main\n"), "tab, context and preset are not supported in a repeater row"}, {"nested context", ucFields + nested(" title:\n type: text\n context: update\n"), "tab, context and preset are not supported in a repeater row"}, {"nested dependsOn to a missing sibling", ucFields + nested(" reference:\n type: dropdown\n dependsOn: type\n"), "is not a field of the same repeater row"}, {"nested dependsOn to itself", ucFields + nested(" reference:\n type: dropdown\n dependsOn: reference\n"), "dependsOn names the field itself"}, {"negative maxItems", ucFields + ucRepeater(" maxItems: -1\n"), "maxItems: want a non-negative integer"}, {"text maxItems", ucFields + ucRepeater(" maxItems: many\n"), "maxItems: want a non-negative integer"}, {"negative minItems", ucFields + ucRepeater(" minItems: -2\n"), "minItems: want a non-negative integer"}, {"minItems above maxItems", ucFields + ucRepeater(" minItems: 4\n maxItems: 2\n"), "minItems 4 is greater than maxItems 2"}, {"titleFrom outside the form", ucFields + ucRepeater(" titleFrom: missing\n"), "titleFrom missing is not a field of the repeater form"}, {"maxItems on text", ucFields + " extra:\n type: text\n maxItems: 2\n", "maxItems is only valid on type: repeater"}, {"minItems on textarea", ucFields + " extra:\n type: textarea\n minItems: 2\n", "minItems is only valid on type: repeater"}, {"titleFrom on dropdown", ucFields + " extra:\n type: dropdown\n options: typeOptions\n titleFrom: x\n", "titleFrom is only valid on type: repeater"}, {"form on text", ucFields + " extra:\n type: text\n form:\n fields: {}\n", "form is only valid on type: repeater"}, {"groups on text", ucFields + " extra:\n type: text\n groups: x\n", "repeater groups are not supported"}, } { t.Run(tc.name, func(t *testing.T) { _, err := ucCompile(tc.fields, nil) wantErr(t, err, tc.want) }) } // Every core type outside text, textarea, number, checkbox, switch, // dropdown and richeditor is refused in a row. // Each type gets the keys it needs on its own, so the refusal reached is // the repeater's. typeKeys := map[string]string{ "relation-manager": " relation: parts\n", "widget": " widget: acme-x\n action: lookup\n", "partial": " path: summary\n", "permissioneditor": " mode: checkbox\n", "repeater": " form:\n fields:\n a:\n type: text\n", "money": " currency: PLN\n", } for typ := range formFieldTypes { if repeaterNestedTypes[typ] { continue } t.Run("nested "+typ, func(t *testing.T) { _, err := ucCompile(ucFields+nested(" inner:\n type: "+typ+"\n"+typeKeys[typ]), nil) wantErr(t, err, "field inner: type "+typ+" is not supported in a repeater") }) } } // ucDecode runs liftRepeaterValues on a JSON body. func ucDecode(t *testing.T, cc *CompiledController, raw, op string) (map[string]any, map[string]any) { t.Helper() body := utJSON(t, raw).(map[string]any) err := liftRepeaterValues(context.Background(), cc, body, op, nil) if err == nil { return body, nil } var invalid *ValidationError if !errors.As(err, &invalid) { t.Fatalf("err = %v, want a ValidationError", err) } return body, invalid.Details } // TestRepeaterDecode pins the D-07 decode of a submitted repeater value: // rows keep only the nested keys, scalars are coerced per nested type, // required nested values fail under field.index.nested, the row bounds and // the list shape are checked, and stored values hydrate back to rows. func TestRepeaterDecode(t *testing.T) { cc := ucMustCompile(t, ucFields+ucRepeater(" minItems: 1\n maxItems: 3\n"), nil) t.Run("rows keep only nested keys, coerced per type", func(t *testing.T) { body, details := ucDecode(t, cc, `{"rows":[{"title":"One","body":"

x

","count":"12","on":"yes","flag":1,"note":7,"type":"acme-page","reference":2,"extra":"dropped"},{"title":5,"count":3.5,"on":"off","flag":false,"note":true}]}`, "update") if details != nil { t.Fatalf("details = %v", details) } rows := body["rows"].([]any) first, second := rows[0].(map[string]any), rows[1].(map[string]any) if _, extra := first["extra"]; extra || len(first) != 8 || len(second) != 8 { t.Fatalf("row keys = %v / %v", first, second) } want := map[string]any{"title": "One", "body": "

x

", "count": json.Number("12"), "on": true, "flag": true, "note": "7", "type": "acme-page", "reference": "2"} if fmt.Sprint(first) != fmt.Sprint(want) { t.Fatalf("first row = %v\nwant %v", first, want) } want = map[string]any{"title": "5", "body": nil, "count": json.Number("3.5"), "on": false, "flag": false, "note": "true", "type": nil, "reference": nil} if fmt.Sprint(second) != fmt.Sprint(want) { t.Fatalf("second row = %v\nwant %v", second, want) } }) t.Run("coercion refusals and required values are keyed per row", func(t *testing.T) { _, details := ucDecode(t, cc, `{"rows":[{"title":"ok","count":"abc"},{"title":" ","on":"maybe"},{"title":{"x":1},"flag":[1]}]}`, "update") for key, part := range map[string]string{ "rows.0.count": "must be a number", "rows.1.title": "is required", "rows.1.on": "must be true or false", "rows.2.title": "must be text", "rows.2.flag": "must be true or false", } { msgs, _ := details[key].([]string) if len(msgs) != 1 || !strings.Contains(msgs[0], key) || !strings.Contains(msgs[0], part) { t.Fatalf("%s = %v, want %q", key, details[key], part) } } if len(details) != 5 { t.Fatalf("details = %v", details) } }) t.Run("a missing required nested value fails", func(t *testing.T) { _, details := ucDecode(t, cc, `{"rows":[{"title":"ok"},{"body":"

x

"}]}`, "update") if msgs, _ := details["rows.1.title"].([]string); len(msgs) != 1 || len(details) != 1 { t.Fatalf("details = %v", details) } }) t.Run("the list shape is checked", func(t *testing.T) { for _, raw := range []string{`{"rows":"x"}`, `{"rows":{"a":1}}`, `{"rows":3}`, `{"rows":[{"title":"ok"},"x"]}`, `{"rows":[[1]]}`} { _, details := ucDecode(t, cc, raw, "update") msgs, _ := details["rows"].([]string) if len(msgs) != 1 || !strings.Contains(msgs[0], "must be a list of rows") { t.Fatalf("%s: details = %v", raw, details) } } }) t.Run("row bounds", func(t *testing.T) { _, details := ucDecode(t, cc, `{"rows":[{"title":"1"},{"title":"2"},{"title":"3"},{"title":"4"}]}`, "update") if msgs, _ := details["rows"].([]string); len(msgs) != 1 || msgs[0] != msgRepeaterMaxItems { t.Fatalf("max details = %v", details) } _, details = ucDecode(t, cc, `{"rows":[]}`, "update") if msgs, _ := details["rows"].([]string); len(msgs) != 1 || msgs[0] != msgRepeaterMinItems { t.Fatalf("min details = %v", details) } body, details := ucDecode(t, cc, `{"rows":[{"title":"1"},{"title":"2"},{"title":"3"}]}`, "update") if details != nil || len(body["rows"].([]any)) != 3 { t.Fatalf("three rows: %v %v", body, details) } }) t.Run("null is an empty list and an absent repeater is left alone", func(t *testing.T) { free := ucMustCompile(t, ucFields+ucRepeater(""), nil) body, details := ucDecode(t, free, `{"rows":null}`, "update") if details != nil || fmt.Sprint(body["rows"]) != "[]" { t.Fatalf("null rows: %v %v", body, details) } body, details = ucDecode(t, free, `{"title":"x"}`, "create") if _, present := body["rows"]; present || details != nil { t.Fatalf("absent rows: %v %v", body, details) } }) t.Run("a required repeater needs a row", func(t *testing.T) { required := ucMustCompile(t, ucFields+ucRepeater(" required: true\n"), nil) for _, tc := range []struct{ raw, op string }{{`{"rows":[]}`, "update"}, {`{"rows":null}`, "create"}, {`{"title":"x"}`, "create"}} { _, details := ucDecode(t, required, tc.raw, tc.op) if msgs, _ := details["rows"].([]string); len(msgs) != 1 || !strings.Contains(msgs[0], "is required") { t.Fatalf("%s on %s: details = %v", tc.raw, tc.op, details) } } if _, details := ucDecode(t, required, `{"title":"x"}`, "update"); details != nil { t.Fatalf("absent on update: %v", details) } }) t.Run("nested richeditors are sanitized with row-path notices", func(t *testing.T) { projected := map[string]any{"rows": []any{ map[string]any{"title": "a", "body": "

clean

"}, map[string]any{"title": "b", "body": `

dirty

`}, map[string]any{"title": "c", "body": nil}, }} notices := sanitizeRepeaterRichEditors(cc, projected) rows := projected["rows"].([]any) if rows[0].(map[string]any)["body"] != "

clean

" || rows[1].(map[string]any)["body"] != "

dirty

" || rows[2].(map[string]any)["body"] != nil { t.Fatalf("rows = %v", rows) } var fields []string for _, n := range notices { fields = append(fields, n.field+":"+n.notice.Message) } if strings.Join(fields, ",") != "rows.1.body:"+msgRichRemovedElements+",rows.1.body:"+msgRichRemovedAttributes { t.Fatalf("notices = %v", fields) } }) t.Run("stored rows hydrate to a list", func(t *testing.T) { stored := lagoon.Jsonable[[]map[string]any]{Data: []map[string]any{{"title": "a"}}, Valid: true} text := `[{"title":"b"}]` var nilText *string for _, tc := range []struct { value any want string }{ {stored, "[map[title:a]]"}, {text, "[map[title:b]]"}, {&text, "[map[title:b]]"}, {nilText, "[]"}, {nil, "[]"}, {"not json", "[]"}, {"null", "[]"}, {[]map[string]any{{"title": "c"}}, "[map[title:c]]"}, } { data := map[string]any{"rows": tc.value, "title": "kept"} hydrateRepeaterFields(cc, data) if fmt.Sprint(data["rows"]) != tc.want || data["title"] != "kept" { t.Fatalf("hydrate(%#v) = %v, want %s", tc.value, data["rows"], tc.want) } } data := map[string]any{"title": "no rows key"} hydrateRepeaterFields(cc, data) if _, present := data["rows"]; present { t.Fatal("hydrate added a missing rows key") } }) }