diff --git a/cabana/list_schema_test.go b/cabana/list_schema_test.go new file mode 100644 index 0000000..a8d48f8 --- /dev/null +++ b/cabana/list_schema_test.go @@ -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) +} diff --git a/cabana/testdata/list/all_columns.yaml b/cabana/testdata/list/all_columns.yaml new file mode 100644 index 0000000..9144236 --- /dev/null +++ b/cabana/testdata/list/all_columns.yaml @@ -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