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
|
package cabana
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"path"
|
"path"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/pact"
|
"git.golem15.com/golem15/summercms/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/phrasebook"
|
||||||
"github.com/goccy/go-yaml"
|
"github.com/goccy/go-yaml"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -351,3 +353,17 @@ func isListRelation(t reflect.Type) bool {
|
|||||||
}
|
}
|
||||||
return true
|
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
|
package cabana
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/pact"
|
"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
|
const allColumnsConfig = `title: demo.lang.widgets
|
||||||
@@ -288,3 +293,293 @@ func readListFixture(t *testing.T, name string) string {
|
|||||||
}
|
}
|
||||||
return string(raw)
|
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