From cb832eb70cfcda106cbf77e2560cc6b742085975 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 24 Sep 2026 18:50:17 +0200 Subject: [PATCH] test(09-04): add failing tests for typed list filters - Switch, date-range, and model-scope filters must keep typed values - Option labels localize without changing identifiers or cached keys - Raw conditions, unknown scopes, and arbitrary methods fail activation --- cabana/list_schema.go | 16 ++ cabana/list_schema_test.go | 295 ++++++++++++++++++++++++++ cabana/testdata/list/all_filters.yaml | 17 ++ 3 files changed, 328 insertions(+) create mode 100644 cabana/testdata/list/all_filters.yaml diff --git a/cabana/list_schema.go b/cabana/list_schema.go index 17f0b75..d69bd58 100644 --- a/cabana/list_schema.go +++ b/cabana/list_schema.go @@ -1,6 +1,7 @@ package cabana import ( + "context" "fmt" "io/fs" "path" @@ -9,6 +10,7 @@ import ( "time" "git.golem15.com/golem15/summercms/pact" + "git.golem15.com/golem15/summercms/phrasebook" "github.com/goccy/go-yaml" "gorm.io/gorm" ) @@ -351,3 +353,17 @@ func isListRelation(t reflect.Type) bool { } return true } + +// Localize copies the cached list schema for one request. +// The RED seam returns source keys; request-time translation replaces this body. +func (s *ListSchema) Localize(ctx context.Context, tr *phrasebook.Translator) (*ListSchema, error) { + if s == nil { + return nil, fmt.Errorf("cabana: nil list schema") + } + if ctx == nil { + ctx = context.Background() + } + out := *s + _ = tr + return &out, nil +} diff --git a/cabana/list_schema_test.go b/cabana/list_schema_test.go index a8d48f8..b916ec0 100644 --- a/cabana/list_schema_test.go +++ b/cabana/list_schema_test.go @@ -1,13 +1,18 @@ package cabana import ( + "context" "encoding/json" "os" "strings" "testing" "testing/fstest" + "time" "git.golem15.com/golem15/summercms/pact" + "git.golem15.com/golem15/summercms/phrasebook" + "git.golem15.com/golem15/summercms/towel" + "gorm.io/gorm" ) const allColumnsConfig = `title: demo.lang.widgets @@ -288,3 +293,293 @@ func readListFixture(t *testing.T, name string) string { } 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":[]}` + +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"} } + +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 +} diff --git a/cabana/testdata/list/all_filters.yaml b/cabana/testdata/list/all_filters.yaml new file mode 100644 index 0000000..3d72b96 --- /dev/null +++ b/cabana/testdata/list/all_filters.yaml @@ -0,0 +1,17 @@ +scopes: + activated: + label: demo.lang.activated + type: switch + column: active + options: + true: demo.lang.yes + false: demo.lang.no + created: + label: demo.lang.created_range + type: daterange + column: created_at + grouped: + label: demo.lang.grouped + modelClass: Group + nameFrom: name + scope: filterByGroup