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:
Jakub Zych
2026-09-24 18:50:17 +02:00
parent aab4398ce4
commit cb832eb70c
3 changed files with 328 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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
View 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