Files
summercms/modules/cabana/repeater_test.go
Jakub Zych 91593da1f7 test(17-06): framework and admin SPA unit tests for the blog companions
- signpost: activation refusals, host overrides, D-02 pattern layers
  incl. PatternFrom, ReferenceOptions depths
- seaglass: removal report, bluemonday bypass corpus, Check
- pact Option.Depth omitempty and the menu item type contracts; party
  activates menu item types after Register and before the first Boot
- cabana: repeater compile and decode, dependsOn compile and recheck,
  richeditor keys and notices, reorder and singleton boot checks
  (Docker-free); repeater save, dependent recheck, richeditor save,
  reorder and singleton routes on Postgres; conformance cases for the
  documented 404, 409 and 422 answers of the new routes
- admin: richeditor value contract, link popover, notices and chunk
  failure; repeater bounds, remove and reorder, collapsed rows;
  dependent dropdown states and last request wins; reorder
  announcements, partition switch and tree rules; SingletonForm spec
- gate: required names in the go, admin and postgres stages
2026-10-12 19:29:37 +02:00

427 lines
20 KiB
Go

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",
}
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":"<p>x</p>","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": "<p>x</p>", "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":"<p>x</p>"}]}`, "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": "<p>clean</p>"},
map[string]any{"title": "b", "body": `<p onclick="x()">dirty</p><script>1</script>`},
map[string]any{"title": "c", "body": nil},
}}
notices := sanitizeRepeaterRichEditors(cc, projected)
rows := projected["rows"].([]any)
if rows[0].(map[string]any)["body"] != "<p>clean</p>" || rows[1].(map[string]any)["body"] != "<p>dirty</p>" || 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")
}
})
}