test(09-04): add failing tests for ordered list schemas
- Columns, actions, default sort, and page sizes must compile to typed JSON - Empty and single declarations stay arrays and keep source order - Unsupported keys, actions, defaults, and path escape fail activation
This commit is contained in:
290
cabana/list_schema_test.go
Normal file
290
cabana/list_schema_test.go
Normal file
@@ -0,0 +1,290 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
)
|
||||
|
||||
const allColumnsConfig = `title: demo.lang.widgets
|
||||
modelClass: Widget
|
||||
list: ~/plugins/acme/demo/models/widget/columns.yaml
|
||||
recordUrl: acme/demo/widgets/update/:id
|
||||
noRecordsMessage: backend::lang.list.no_records
|
||||
recordsPerPage: 20
|
||||
perPageOptions:
|
||||
- 20
|
||||
- 50
|
||||
- 100
|
||||
showSetup: true
|
||||
showCheckboxes: true
|
||||
showSorting: true
|
||||
defaultSort:
|
||||
column: name
|
||||
direction: asc
|
||||
toolbar:
|
||||
buttons: list_toolbar
|
||||
search:
|
||||
prompt: backend::lang.list.search_prompt
|
||||
`
|
||||
|
||||
const allColumnsJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordUrl":"acme/demo/widgets/update/:id","noRecordsMessage":"backend::lang.list.no_records","recordsPerPage":20,"perPageOptions":[20,50,100],"showSearch":true,"showSetup":true,"showCheckboxes":true,"showSorting":true,"searchTerm":"search","searchPrompt":"backend::lang.list.search_prompt","defaultSort":{"column":"name","direction":"asc"},"toolbarButtons":["create"],"columns":[{"key":"name","label":"demo.lang.name","searchable":true,"sortable":true,"type":"text"},{"key":"created_at","label":"demo.lang.created","searchable":false,"sortable":true,"type":"datetime"},{"key":"active","label":"demo.lang.active","searchable":false,"sortable":false,"type":"switch"},{"key":"genre","label":"demo.lang.genre","searchable":true,"sortable":false,"relation":"genre","select":"name"}],"filters":[],"rowActions":[{"name":"update","label":"backend::lang.form.update","url":"acme/demo/widgets/update/:id"}],"bulkActions":[{"name":"delete","label":"backend::lang.list.delete_selected"}]}`
|
||||
|
||||
func TestListSchemaCompile(t *testing.T) {
|
||||
columns := readListFixture(t, "testdata/list/all_columns.yaml")
|
||||
got := compileListJSON(t, schemaController{model: "Widget"}, allColumnsConfig, columns)
|
||||
if got != allColumnsJSON {
|
||||
t.Fatalf("compiled list =\n%s\nwant\n%s", got, allColumnsJSON)
|
||||
}
|
||||
again := compileListJSON(t, schemaController{model: "Widget"}, allColumnsConfig, columns)
|
||||
if again != got {
|
||||
t.Fatalf("recompilation changed JSON:\n%s\n%s", got, again)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`"searchable":false`, `"sortable":false`, `"type":"datetime"`, `"type":"switch"`,
|
||||
`"relation":"genre"`, `"select":"name"`, `"searchTerm":"search"`, `"showSetup":true`,
|
||||
`"perPageOptions":[20,50,100]`, `"toolbarButtons":["create"]`, `"filters":[]`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("compiled list missing %s:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("winter genre list still compiles", func(t *testing.T) {
|
||||
const config = `list: ~/plugins/golem15/fonoteka/models/genre/columns.yaml
|
||||
modelClass: Golem15\Fonoteka\Models\Genre
|
||||
title: golem15.fonoteka::lang.genre.label_plural
|
||||
recordUrl: golem15/fonoteka/genres/update/:id
|
||||
noRecordsMessage: backend::lang.list.no_records
|
||||
recordsPerPage: 20
|
||||
showCheckboxes: true
|
||||
toolbar:
|
||||
buttons: list_toolbar
|
||||
search:
|
||||
prompt: backend::lang.list.search_prompt
|
||||
`
|
||||
const cols = `columns:
|
||||
name:
|
||||
label: golem15.fonoteka::lang.genre.name
|
||||
searchable: true
|
||||
slug:
|
||||
label: golem15.fonoteka::lang.genre.slug
|
||||
searchable: true
|
||||
`
|
||||
ctl := schemaController{model: `Golem15\Fonoteka\Models\Genre`}
|
||||
fsys := fstest.MapFS{
|
||||
"controllers/genres/config_list.yaml": &fstest.MapFile{Data: []byte(config)},
|
||||
"models/genre/columns.yaml": &fstest.MapFile{Data: []byte(cols)},
|
||||
}
|
||||
genre := genreConfigController{schemaController: ctl}
|
||||
if _, err := CompileList("golem15.fonoteka", genre, fsys); err != nil {
|
||||
t.Fatalf("genre list: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestListSchemaEmpty(t *testing.T) {
|
||||
const config = `list: ~/plugins/acme/demo/models/widget/columns.yaml
|
||||
modelClass: Widget
|
||||
recordsPerPage: 20
|
||||
`
|
||||
got := compileListJSON(t, schemaController{model: "Widget"}, config, "columns: {}\n")
|
||||
want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[],"filters":[],"rowActions":[],"bulkActions":[]}`
|
||||
if got != want {
|
||||
t.Fatalf("empty list =\n%s\nwant\n%s", got, want)
|
||||
}
|
||||
for _, nullKey := range []string{`"columns":null`, `"filters":null`, `"rowActions":null`, `"bulkActions":null`, `"perPageOptions":null`, `"toolbarButtons":null`} {
|
||||
if strings.Contains(got, nullKey) {
|
||||
t.Fatalf("empty collection serialized as null: %s", nullKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSchemaSingle(t *testing.T) {
|
||||
const config = `list: ~/plugins/acme/demo/models/widget/columns.yaml
|
||||
modelClass: Widget
|
||||
recordsPerPage: 20
|
||||
`
|
||||
got := compileListJSON(t, schemaController{model: "Widget"}, config, "columns:\n name:\n label: Name\n searchable: true\n")
|
||||
want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[],"rowActions":[],"bulkActions":[]}`
|
||||
if got != want {
|
||||
t.Fatalf("single list =\n%s\nwant\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSchemaOrdering(t *testing.T) {
|
||||
const config = `list: ~/plugins/acme/demo/models/widget/columns.yaml
|
||||
modelClass: Widget
|
||||
recordsPerPage: 20
|
||||
perPageOptions:
|
||||
- 100
|
||||
- 20
|
||||
- 50
|
||||
`
|
||||
const columns = `columns:
|
||||
zeta:
|
||||
label: Z
|
||||
type: text
|
||||
alpha:
|
||||
label: A
|
||||
type: datetime
|
||||
mid:
|
||||
label: M
|
||||
type: switch
|
||||
`
|
||||
first := compileListJSON(t, schemaController{model: "Widget"}, config, columns)
|
||||
second := compileListJSON(t, schemaController{model: "Widget"}, config, columns)
|
||||
if first != second {
|
||||
t.Fatalf("order changed between compiles:\n%s\n%s", first, second)
|
||||
}
|
||||
zeta := strings.Index(first, `"key":"zeta"`)
|
||||
alpha := strings.Index(first, `"key":"alpha"`)
|
||||
mid := strings.Index(first, `"key":"mid"`)
|
||||
if zeta < 0 || alpha < 0 || mid < 0 || !(zeta < alpha && alpha < mid) {
|
||||
t.Fatalf("source order lost: %s", first)
|
||||
}
|
||||
if !strings.Contains(first, `"perPageOptions":[100,20,50]`) {
|
||||
t.Fatalf("perPageOptions were reordered: %s", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSchemaRejects(t *testing.T) {
|
||||
columns := readListFixture(t, "testdata/list/all_columns.yaml")
|
||||
cases := []struct {
|
||||
name string
|
||||
model string
|
||||
config string
|
||||
columns string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "duplicate column",
|
||||
config: allColumnsConfig,
|
||||
columns: "columns:\n name:\n label: A\n name:\n label: B\n",
|
||||
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/columns.yaml", "duplicate"},
|
||||
},
|
||||
{
|
||||
name: "unknown key",
|
||||
config: allColumnsConfig,
|
||||
columns: "columns:\n name:\n label: Name\n cssClass: bad\n",
|
||||
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/columns.yaml", "cssClass"},
|
||||
},
|
||||
{
|
||||
name: "unsupported type",
|
||||
config: allColumnsConfig,
|
||||
columns: "columns:\n name:\n label: Name\n type: partial\n",
|
||||
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/columns.yaml", "partial"},
|
||||
},
|
||||
{
|
||||
name: "sql select",
|
||||
config: allColumnsConfig,
|
||||
columns: "columns:\n genre:\n label: Genre\n relation: genre\n select: \"@.name\"\n",
|
||||
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/columns.yaml", "select"},
|
||||
},
|
||||
{
|
||||
name: "invalid default column",
|
||||
config: strings.Replace(allColumnsConfig, "column: name", "column: missing", 1),
|
||||
columns: columns,
|
||||
want: []string{"acme.demo", "acme.demo.widgets", "config_list.yaml", "missing"},
|
||||
},
|
||||
{
|
||||
name: "invalid default direction",
|
||||
config: strings.Replace(allColumnsConfig, "direction: asc", "direction: ASC", 1),
|
||||
columns: columns,
|
||||
want: []string{"acme.demo", "acme.demo.widgets", "config_list.yaml", "ASC"},
|
||||
},
|
||||
{
|
||||
name: "path escape",
|
||||
config: strings.Replace(allColumnsConfig, "list: ~/plugins/acme/demo/models/widget/columns.yaml", "list: ../../etc/passwd", 1),
|
||||
columns: columns,
|
||||
want: []string{"acme.demo", "acme.demo.widgets", "config_list.yaml", "escape"},
|
||||
},
|
||||
{
|
||||
name: "mismatched modelClass",
|
||||
model: "Widget",
|
||||
config: strings.Replace(allColumnsConfig, "modelClass: Widget", "modelClass: widget", 1),
|
||||
columns: columns,
|
||||
want: []string{"acme.demo", "acme.demo.widgets", "config_list.yaml", "modelClass"},
|
||||
},
|
||||
{
|
||||
name: "unsupported action",
|
||||
config: strings.Replace(allColumnsConfig, "buttons: list_toolbar", "buttons: drop_database", 1),
|
||||
columns: columns,
|
||||
want: []string{"acme.demo", "acme.demo.widgets", "config_list.yaml", "drop_database"},
|
||||
},
|
||||
{
|
||||
name: "invalid page size",
|
||||
config: strings.Replace(allColumnsConfig, " - 100\n", " - 0\n", 1),
|
||||
columns: columns,
|
||||
want: []string{"acme.demo", "acme.demo.widgets", "config_list.yaml", "perPageOptions"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
model := tc.model
|
||||
if model == "" {
|
||||
model = "Widget"
|
||||
}
|
||||
_, err := CompileList("acme.demo", schemaController{model: model}, listFS(tc.config, tc.columns))
|
||||
if err == nil {
|
||||
t.Fatal("expected boot error")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range tc.want {
|
||||
if want == "duplicate" {
|
||||
if strings.Contains(msg, "duplicate") || strings.Contains(msg, "already defined") {
|
||||
continue
|
||||
}
|
||||
t.Fatalf("error %q missing duplicate", msg)
|
||||
}
|
||||
if want == "escape" {
|
||||
if strings.Contains(msg, "escape") || strings.Contains(msg, "..") {
|
||||
continue
|
||||
}
|
||||
t.Fatalf("error %q missing escape", msg)
|
||||
}
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("error %q missing %q", msg, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type genreConfigController struct{ schemaController }
|
||||
|
||||
func (c genreConfigController) ConfigDir() string { return "controllers/genres" }
|
||||
func (c genreConfigController) ID() string { return "golem15.fonoteka.genres" }
|
||||
|
||||
func listFS(config, columns string) fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"controllers/widgets/config_list.yaml": &fstest.MapFile{Data: []byte(config)},
|
||||
"models/widget/columns.yaml": &fstest.MapFile{Data: []byte(columns)},
|
||||
}
|
||||
}
|
||||
|
||||
func compileListJSON(t *testing.T, ctl pact.AdminController, config, columns string) string {
|
||||
t.Helper()
|
||||
schema, err := CompileList("acme.demo", ctl, listFS(config, columns))
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(schema)
|
||||
if err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func readListFixture(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("fixture %s: %v", name, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
20
cabana/testdata/list/all_columns.yaml
vendored
Normal file
20
cabana/testdata/list/all_columns.yaml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
columns:
|
||||
name:
|
||||
label: demo.lang.name
|
||||
type: text
|
||||
searchable: true
|
||||
sortable: true
|
||||
created_at:
|
||||
label: demo.lang.created
|
||||
type: datetime
|
||||
sortable: true
|
||||
active:
|
||||
label: demo.lang.active
|
||||
type: switch
|
||||
sortable: false
|
||||
genre:
|
||||
label: demo.lang.genre
|
||||
relation: genre
|
||||
select: name
|
||||
searchable: true
|
||||
sortable: false
|
||||
Reference in New Issue
Block a user