package cabana import ( "context" "encoding/json" "os" "strings" "testing" "testing/fstest" "time" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/towel" "gorm.io/gorm" ) 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: [create, delete] 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","delete"],"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"}],` + defaultListMessagesJSON + `}` // defaultListMessagesJSON is a compiled list's messages block when the YAML // declares none: every key is a framework default phrase key (D-13). const defaultListMessagesJSON = `"messages":{"recordCount":{"other":"backend::lang.messages.list.record_count"},"create":{"other":"backend::lang.messages.list.create"},"searchPrompt":{"other":"backend::lang.list.search_prompt"},"empty":{"other":"backend::lang.list.no_records"},"emptySearch":{"other":"backend::lang.messages.list.empty_search"},"emptySearchHint":{"other":"backend::lang.messages.list.empty_search_hint"},"selected":{"other":"backend::lang.messages.list.selected"},"deleteSelected":{"other":"backend::lang.list.delete_selected"},"deleteConfirm":{"other":"backend::lang.messages.list.delete_confirm"},"deleted":{"other":"backend::lang.messages.list.deleted"}}` 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","delete"]`, `"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/acme/models/genre/columns.yaml modelClass: Golem15\Acme\Models\Genre title: golem15.acme::lang.genre.label_plural recordUrl: golem15/acme/genres/update/:id noRecordsMessage: backend::lang.list.no_records recordsPerPage: 20 showCheckboxes: true toolbar: buttons: [create, delete] search: prompt: backend::lang.list.search_prompt ` const cols = `columns: name: label: golem15.acme::lang.genre.name searchable: true slug: label: golem15.acme::lang.genre.slug searchable: true ` ctl := schemaController{model: `Golem15\Acme\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.acme", 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":[],` + defaultListMessagesJSON + `}` 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":[],` + defaultListMessagesJSON + `}` 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: [create, delete]", "buttons: [create, 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.acme.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) } const filterListConfig = `title: demo.lang.widgets modelClass: Widget list: ~/plugins/acme/demo/models/widget/columns.yaml recordsPerPage: 20 filter: config_filter.yaml ` const filterColumns = `columns: name: label: Name searchable: true ` const allFiltersJSON = `{"title":"demo.lang.widgets","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":[{"name":"activated","label":"demo.lang.activated","type":"switch","column":"active","options":[{"value":true,"label":"demo.lang.yes"},{"value":false,"label":"demo.lang.no"}]},{"name":"created","label":"demo.lang.created_range","type":"daterange","column":"created_at"},{"name":"grouped","label":"demo.lang.grouped","type":"scope","scope":"filterByGroup","modelClass":"Group","nameFrom":"name"}],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}` func TestListSchemaFilter(t *testing.T) { filters := readListFixture(t, "testdata/list/all_filters.yaml") ctl := filterController{schemaController: schemaController{model: "Widget"}, rec: &filterRow{}} got := compileFilterJSON(t, ctl, filterListConfig, filterColumns, filters) if got != allFiltersJSON { t.Fatalf("compiled filters =\n%s\nwant\n%s", got, allFiltersJSON) } again := compileFilterJSON(t, ctl, filterListConfig, filterColumns, filters) if again != got { t.Fatalf("recompilation changed JSON:\n%s\n%s", got, again) } if strings.Contains(got, `"value":"true"`) || strings.Contains(got, `"value":"false"`) || strings.Contains(got, "conditions") { t.Fatalf("filter values lost their type or carried a condition: %s", got) } yes := strings.Index(got, `{"value":true,"label":"demo.lang.yes"}`) no := strings.Index(got, `{"value":false,"label":"demo.lang.no"}`) if yes < 0 || no < 0 || yes > no { t.Fatalf("switch option order drifted: %s", got) } t.Run("empty", func(t *testing.T) { raw := compileFilterJSON(t, ctl, filterListConfig, filterColumns, "scopes: {}\n") if !strings.Contains(raw, `"filters":[]`) || strings.Contains(raw, `"filters":null`) { t.Fatalf("empty filters = %s", raw) } }) t.Run("single", func(t *testing.T) { const one = `scopes: activated: label: Activated type: switch column: active ` raw := compileFilterJSON(t, ctl, filterListConfig, filterColumns, one) want := `"filters":[{"name":"activated","label":"Activated","type":"switch","column":"active","trueValue":true,"falseValue":false}]` if !strings.Contains(raw, want) { t.Fatalf("single filter =\n%s\nwant substring\n%s", raw, want) } }) t.Run("labels localize", func(t *testing.T) { const localized = `scopes: activated: label: acme.demo::lang.activated type: switch column: active options: true: acme.demo::lang.yes false: acme.demo::lang.no grouped: label: acme.demo::lang.grouped modelClass: Group nameFrom: name scope: filterByGroup ` schema, err := CompileList("acme.demo", ctl, filterFS(filterListConfig, filterColumns, localized)) if err != nil { t.Fatalf("compile: %v", err) } before, err := json.Marshal(schema) if err != nil { t.Fatal(err) } tr := phrasebook.NewTranslator(filterCatalog(t), phrasebook.Options{Locale: "pl", Fallback: "en"}) view, err := schema.Localize(towel.WithLocale(context.Background(), "pl"), tr) if err != nil { t.Fatalf("localize: %v", err) } raw, err := json.Marshal(view) if err != nil { t.Fatal(err) } got := string(raw) for _, want := range []string{`"locale":"pl"`, `"label":"Aktywny"`, `"label":"Tak"`, `"label":"Grupa"`, `"column":"active"`, `"scope":"filterByGroup"`, `"value":true`} { if !strings.Contains(got, want) { t.Fatalf("localized filters missing %s:\n%s", want, got) } } if strings.Contains(got, `"value":"true"`) || strings.Contains(got, "acme.demo::lang.activated") { t.Fatalf("localization changed a value or left a label key: %s", got) } after, err := json.Marshal(schema) if err != nil { t.Fatal(err) } if string(before) != string(after) || strings.Contains(string(after), "Aktywny") { t.Fatalf("localization mutated the cached schema:\n%s", after) } }) } func TestListSchemaScope(t *testing.T) { const scopes = `scopes: grouped: label: Group modelClass: Group nameFrom: name scope: filterByGroup ` ctl := filterController{schemaController: schemaController{model: "Widget"}, rec: &filterRow{}} raw := compileFilterJSON(t, ctl, filterListConfig, filterColumns, scopes) if !strings.Contains(raw, `"type":"scope"`) || !strings.Contains(raw, `"scope":"filterByGroup"`) || strings.Contains(raw, "DestroyAll") { t.Fatalf("registered scope = %s", raw) } t.Run("missing provider", func(t *testing.T) { _, err := CompileList("acme.demo", schemaController{model: "Widget"}, filterFS(filterListConfig, filterColumns, scopes)) if err == nil || !strings.Contains(err.Error(), "filterByGroup") || !strings.Contains(err.Error(), "FilterScope") || !strings.Contains(err.Error(), "config_filter.yaml") { t.Fatalf("missing provider err = %v", err) } }) t.Run("case sensitive scope", func(t *testing.T) { body := strings.Replace(scopes, "scope: filterByGroup", "scope: FilterByGroup", 1) _, err := CompileList("acme.demo", ctl, filterFS(filterListConfig, filterColumns, body)) if err == nil || !strings.Contains(err.Error(), "FilterByGroup") { t.Fatalf("case variant err = %v", err) } }) t.Run("unknown column", func(t *testing.T) { const body = `scopes: activated: label: Activated type: switch column: missing ` _, err := CompileList("acme.demo", ctl, filterFS(filterListConfig, filterColumns, body)) if err == nil || !strings.Contains(err.Error(), "missing") || !strings.Contains(err.Error(), "acme.demo.widgets") { t.Fatalf("unknown column err = %v", err) } }) } func TestListSchemaRejectsRawCondition(t *testing.T) { cases := []struct { name string body string want []string }{ { name: "conditions list", body: `scopes: activated: label: Activated type: switch column: active conditions: - "active = true" `, want: []string{"acme.demo", "acme.demo.widgets", "config_filter.yaml", "conditions"}, }, { name: "conditions string", body: `scopes: created: label: Created type: daterange conditions: "created_at >= ':after'" `, want: []string{"conditions", "config_filter.yaml"}, }, { name: "arbitrary method", body: `scopes: grouped: label: Group modelClass: Group nameFrom: name scope: DestroyAll `, want: []string{"DestroyAll", "config_filter.yaml"}, }, { name: "duplicate filter", body: `scopes: activated: label: A type: switch column: active activated: label: B type: switch column: active `, want: []string{"duplicate", "config_filter.yaml"}, }, { name: "unknown type", body: `scopes: activated: label: Activated type: checkbox column: active `, want: []string{"checkbox", "config_filter.yaml"}, }, } ctl := filterController{schemaController: schemaController{model: "Widget"}, rec: &filterRow{}} for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { _, err := CompileList("acme.demo", ctl, filterFS(filterListConfig, filterColumns, tc.body)) 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 !strings.Contains(msg, want) { t.Fatalf("error %q missing %q", msg, want) } } }) } } type filterRow struct { ID uint `gorm:"column:id;primaryKey"` Name string `gorm:"column:name"` Active bool `gorm:"column:active"` CreatedAt time.Time `gorm:"column:created_at"` } func (filterRow) FilterScopes() []string { return []string{"filterByGroup"} } // FilterOptions serves the model-backed scope's choices (D-27). func (filterRow) FilterOptions(scope string) []pact.Option { if scope != "filterByGroup" { return nil } return []pact.Option{{Value: "4", Label: "demo::lang.group_four"}, {Value: "9", Label: "Group nine"}} } func (filterRow) FilterScope(name string, db *gorm.DB, value any) *gorm.DB { if db == nil || name != "filterByGroup" { return db } return db.Where("group_id = ?", value) } type filterController struct { schemaController rec any } func (c filterController) NewRecord() any { return c.rec } func filterFS(config, columns, filters 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)}, "controllers/widgets/config_filter.yaml": &fstest.MapFile{Data: []byte(filters)}, } } func compileFilterJSON(t *testing.T, ctl pact.AdminController, config, columns, filters string) string { t.Helper() schema, err := CompileList("acme.demo", ctl, filterFS(config, columns, filters)) 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 filterCatalog(t *testing.T) *phrasebook.Catalog { t.Helper() cat := phrasebook.NewCatalog() err := cat.Load("acme.demo", fstest.MapFS{ "lang/en/lang.yaml": &fstest.MapFile{Data: []byte("activated: Activated\nyes: Yes\nno: No\ngrouped: Grouped\n")}, "lang/pl/lang.yaml": &fstest.MapFile{Data: []byte("activated: Aktywny\nyes: Tak\nno: Nie\ngrouped: Grupa\n")}, }) if err != nil { t.Fatalf("catalog: %v", err) } return cat }