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
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
17
cabana/testdata/list/all_filters.yaml
vendored
Normal file
17
cabana/testdata/list/all_filters.yaml
vendored
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user