package cabana import ( "bytes" "context" "encoding/json" "io/fs" "strings" "sync" "testing" "testing/fstest" "git.golem15.com/golem15/summercms/backpack" "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/party" "git.golem15.com/golem15/summercms/phrasebook" "git.golem15.com/golem15/summercms/towel" ) const formConfig = `name: demo.form form: ~/plugins/acme/demo/models/widget/fields.yaml modelClass: Widget defaultRedirect: acme/demo/widgets create: redirect: acme/demo/widgets/update/:id redirectClose: acme/demo/widgets update: redirect: acme/demo/widgets redirectClose: acme/demo/widgets ` const allFieldsYAML = `fields: title: label: demo.lang.title comment: demo.lang.title_comment span: left type: text required: true tab: demo.lang.main context: create default: Untitled body: label: demo.lang.body span: full type: textarea size: small count: label: demo.lang.count span: right type: number default: 3 attributes: min: 0 readonly: false step: 1 active: label: demo.lang.active span: left type: checkbox default: false enabled: label: demo.lang.enabled type: switch default: 0 format: label: demo.lang.format type: dropdown required: true emptyOption: demo.lang.none options: vinyl: Vinyl 2: Two true: "Yes" artist: label: demo.lang.artist span: right type: relation nameFrom: name emptyOption: demo.lang.pick editors: label: demo.lang.editors type: relation-manager relation: editors tab: demo.lang.editors_tab span: full context: - update - create ` const allFieldsJSON = `{"name":"demo.form","modelClass":"Widget","fields":[` + `{"name":"title","type":"text","label":"demo.lang.title","comment":"demo.lang.title_comment","span":"left","tab":"demo.lang.main","context":"create","required":true,"default":"Untitled"},` + `{"name":"body","type":"textarea","label":"demo.lang.body","span":"full","size":"small"},` + `{"name":"count","type":"number","label":"demo.lang.count","span":"right","default":3,"attributes":{"min":0,"readonly":false,"step":1}},` + `{"name":"active","type":"checkbox","label":"demo.lang.active","span":"left","default":false},` + `{"name":"enabled","type":"switch","label":"demo.lang.enabled","default":0},` + `{"name":"format","type":"dropdown","label":"demo.lang.format","emptyOption":"demo.lang.none","required":true,"options":[{"value":"vinyl","label":"Vinyl"},{"value":2,"label":"Two"},{"value":true,"label":"Yes"}]},` + `{"name":"artist","type":"relation","label":"demo.lang.artist","span":"right","nameFrom":"name","emptyOption":"demo.lang.pick"},` + `{"name":"editors","type":"relation-manager","label":"demo.lang.editors","span":"full","tab":"demo.lang.editors_tab","context":["update","create"],"relation":"editors"}]}` func TestFormSchemaCompile(t *testing.T) { got := compileFormJSON(t, schemaController{model: "Widget"}, formConfig, allFieldsYAML) if got != allFieldsJSON { t.Fatalf("compiled form =\n%s\nwant\n%s", got, allFieldsJSON) } again := compileFormJSON(t, schemaController{model: "Widget"}, formConfig, allFieldsYAML) if again != got { t.Fatalf("recompilation changed JSON:\n%s\n%s", got, again) } for _, want := range []string{`"default":false`, `"default":0`, `"value":2`, `"value":true`, `"value":"vinyl"`} { if !strings.Contains(got, want) { t.Fatalf("compiled form missing scalar %s:\n%s", want, got) } } if strings.Contains(got, `"value":"2"`) || strings.Contains(got, `"value":"true"`) || strings.Contains(got, `"fields":null`) { t.Fatalf("scalar type or null fields drifted:\n%s", got) } t.Run("list-only controller still activates", func(t *testing.T) { _, err := compileRegistry([]controllerRef{{ plugin: formPlugin{fsys: fstest.MapFS{ "controllers/widgets/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\nshowSearch: true\n")}, "models/widget/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n searchable: true\n")}, }}, ctl: schemaController{model: "Widget"}, }}) if err != nil { t.Fatalf("list-only registry: %v", err) } }) t.Run("field name case is exact", func(t *testing.T) { raw := compileFormJSON(t, schemaController{model: "Widget"}, formConfig, "fields:\n Name:\n label: Name\n type: text\n") if !strings.Contains(raw, `"name":"Name"`) || strings.Contains(raw, `"name":"name"`) { t.Fatalf("field name was folded: %s", raw) } }) } func TestFormSchemaEmpty(t *testing.T) { cfg := strings.Replace(formConfig, "name: demo.form", "name: empty", 1) got := compileFormJSON(t, schemaController{model: "Widget"}, cfg, "fields: {}\n") if got != `{"name":"empty","modelClass":"Widget","fields":[]}` { t.Fatalf("empty form = %s", got) } if strings.Contains(got, `"fields":null`) { t.Fatal("empty fields serialized as null") } } func TestFormSchemaSingle(t *testing.T) { cfg := strings.Replace(formConfig, "name: demo.form", "name: one", 1) got := compileFormJSON(t, schemaController{model: "Widget"}, cfg, "fields:\n title:\n label: Title\n type: text\n") want := `{"name":"one","modelClass":"Widget","fields":[{"name":"title","type":"text","label":"Title"}]}` if got != want { t.Fatalf("single form =\n%s\nwant\n%s", got, want) } } func TestFormSchemaOrdering(t *testing.T) { fields := `fields: zeta: label: Z type: text alpha: label: A type: text mid: label: M type: number ` first := compileFormJSON(t, schemaController{model: "Widget"}, formConfig, fields) second := compileFormJSON(t, schemaController{model: "Widget"}, formConfig, fields) if first != second { t.Fatalf("order changed between compiles:\n%s\n%s", first, second) } zeta := strings.Index(first, `"name":"zeta"`) alpha := strings.Index(first, `"name":"alpha"`) mid := strings.Index(first, `"name":"mid"`) if zeta < 0 || alpha < 0 || mid < 0 || !(zeta < alpha && alpha < mid) { t.Fatalf("source order lost: %s", first) } } func TestFormSchemaRejects(t *testing.T) { cases := []struct { name string model string config string fields string want []string }{ { name: "unknown key", config: formConfig, fields: "fields:\n title:\n type: text\n cssClass: bad\n", want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "cssClass"}, }, { name: "unknown type", config: formConfig, fields: "fields:\n title:\n type: widget\n label: Title\n", want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "widget"}, }, { name: "duplicate name", config: formConfig, fields: "fields:\n title:\n type: text\n label: A\n title:\n type: text\n label: B\n", want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "duplicate"}, }, { name: "path escape", config: strings.Replace(formConfig, "form: ~/plugins/acme/demo/models/widget/fields.yaml", "form: ../../etc/passwd", 1), fields: "fields: {}\n", want: []string{"acme.demo", "acme.demo.widgets", "config_form.yaml", "escape"}, }, { name: "mismatched modelClass", model: "Widget", config: strings.Replace(formConfig, "modelClass: Widget", "modelClass: widget", 1), fields: "fields: {}\n", want: []string{"acme.demo", "acme.demo.widgets", "config_form.yaml", "modelClass"}, }, { name: "partial", config: formConfig, fields: "fields:\n editors:\n type: partial\n tab: Editors\n span: full\n", want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "partial"}, }, { name: "partial path", config: formConfig, fields: "fields:\n editors:\n type: partial\n path: $/golem15/fonoteka/controllers/collections/_editors.htm\n", want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "path"}, }, { name: "missing fields", config: strings.Replace(formConfig, "models/widget/fields.yaml", "models/widget/missing.yaml", 1), fields: "fields: {}\n", want: []string{"acme.demo", "acme.demo.widgets", "missing.yaml"}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { model := tc.model if model == "" { model = "Widget" } _, err := CompileForm("acme.demo", schemaController{model: model}, formFS(tc.config, tc.fields)) 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) } } }) } t.Run("bad form fails activation", func(t *testing.T) { fsys := formFS(formConfig, "fields:\n editors:\n type: partial\n tab: Editors\n") fsys = withList(fsys) _, err := compileRegistry([]controllerRef{{ plugin: &formPlugin{fsys: fsys}, ctl: schemaController{model: "Widget"}, }}) if err == nil || !strings.Contains(err.Error(), "partial") { t.Fatalf("activation err = %v", err) } }) } type schemaController struct{ model string } func (c schemaController) ID() string { return "acme.demo.widgets" } func (c schemaController) ModelName() string { if c.model == "" { return "Widget" } return c.model } func (c schemaController) ConfigDir() string { return "controllers/widgets" } type formPlugin struct{ fsys fs.FS } func (p formPlugin) ID() string { return "acme.demo" } func (p formPlugin) Requires() []string { return nil } func (p formPlugin) Register(*backpack.App) error { return nil } func (p formPlugin) Boot(*backpack.App) error { return nil } func (p formPlugin) AdminFS() fs.FS { return p.fsys } func formFS(config, fields string) fs.FS { return fstest.MapFS{ "controllers/widgets/config_form.yaml": &fstest.MapFile{Data: []byte(config)}, "models/widget/fields.yaml": &fstest.MapFile{Data: []byte(fields)}, } } func withList(fsys fs.FS) fs.FS { base, ok := fsys.(fstest.MapFS) if !ok { return fsys } base["controllers/widgets/config_list.yaml"] = &fstest.MapFile{Data: []byte("list: ~/plugins/acme/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\nshowSearch: true\n")} base["models/widget/columns.yaml"] = &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n searchable: true\n")} return base } func compileFormJSON(t *testing.T, ctl schemaController, config, fields string) string { t.Helper() schema, err := CompileForm("acme.demo", ctl, formFS(config, fields)) if err != nil { t.Fatalf("compile: %v", err) } raw, err := schema.CanonicalJSON() if err != nil { t.Fatalf("json: %v", err) } var buf bytes.Buffer if err := json.Compact(&buf, raw); err != nil { t.Fatalf("compact: %v\n%s", err, raw) } return buf.String() } func TestFormSchemaLocalization(t *testing.T) { schema := mustCompileForm(t, schemaController{model: "Widget"}, strings.Replace(formConfig, "name: demo.form", "name: acme.demo::lang.form", 1), localizedFieldsYAML) before := mustCanonical(t, schema) en := phrasebook.NewTranslator(formCatalog(t), phrasebook.Options{Locale: "en", Fallback: "en"}) pl := phrasebook.NewTranslator(formCatalog(t), phrasebook.Options{Locale: "pl", Fallback: "en"}) enJSON := localizeJSON(t, schema, towel.WithLocale(context.Background(), "en"), en, nil) plJSON := localizeJSON(t, schema, towel.WithLocale(context.Background(), "pl"), pl, nil) if enJSON == plJSON { t.Fatalf("locales collapsed:\n%s", enJSON) } for _, want := range []string{`"locale":"en"`, `"label":"Title"`, `"comment":"Comment"`, `"tab":"Tab"`, `"emptyOption":"None"`, `"name":"Form"`} { if !strings.Contains(enJSON, want) { t.Fatalf("en JSON missing %s:\n%s", want, enJSON) } } for _, want := range []string{`"locale":"pl"`, `"label":"Tytuł"`, `"comment":"Komentarz"`, `"tab":"Zakładka"`, `"emptyOption":"Brak"`, `"name":"Formularz"`} { if !strings.Contains(plJSON, want) { t.Fatalf("pl JSON missing %s:\n%s", want, plJSON) } } if !strings.Contains(enJSON, `"label":"missing.label"`) || !strings.Contains(plJSON, `"label":"missing.label"`) { t.Fatalf("raw key fallback lost:\nen %s\npl %s", enJSON, plJSON) } after := mustCanonical(t, schema) if before != after { t.Fatalf("localization mutated the cached schema:\n%s\n%s", before, after) } t.Run("parent and app locale", func(t *testing.T) { app := phrasebook.NewTranslator(formCatalog(t), phrasebook.Options{Locale: "pl", Fallback: "en"}) parent := localizeJSON(t, schema, towel.WithLocale(context.Background(), "pl-PL,en;q=0.5"), app, nil) if !strings.Contains(parent, `"locale":"pl-PL"`) || !strings.Contains(parent, `"label":"Tytuł"`) { t.Fatalf("Accept-Language parent fallback = %s", parent) } fallback := localizeJSON(t, schema, context.Background(), app, nil) if !strings.Contains(fallback, `"locale":"pl"`) || !strings.Contains(fallback, `"name":"Formularz"`) { t.Fatalf("app.locale fallback = %s", fallback) } }) } func TestFormSchemaDropdownOptions(t *testing.T) { schema := mustCompileForm(t, schemaController{model: "Widget"}, formConfig, optionMapYAML) tr := phrasebook.NewTranslator(formCatalog(t), phrasebook.Options{Locale: "pl", Fallback: "en"}) got := localizeJSON(t, schema, towel.WithLocale(context.Background(), "pl"), tr, nil) vinyl := strings.Index(got, `{"value":"vinyl","label":"Winyl"}`) two := strings.Index(got, `{"value":2,"label":"Dwa"}`) yes := strings.Index(got, `{"value":true,"label":"Yes"}`) if vinyl < 0 || two < 0 || yes < 0 || !(vinyl < two && two < yes) { t.Fatalf("option order or scalar type drifted: %s", got) } if strings.Contains(got, `"value":"2"`) || strings.Contains(got, `"value":"true"`) { t.Fatalf("option values became strings: %s", got) } methodSchema := mustCompileForm(t, providerController{schemaController: schemaController{model: "Widget"}}, formConfig, methodFieldsYAML) methodJSON := localizeJSON(t, methodSchema, towel.WithLocale(context.Background(), "pl"), tr, providerController{}) if !strings.Contains(methodJSON, `{"value":"vinyl","label":"Winyl"}`) || !strings.Contains(methodJSON, `{"value":"cd","label":"Płyta"}`) { t.Fatalf("method options = %s", methodJSON) } if strings.Contains(methodJSON, "getFormatOptions") { t.Fatalf("method name leaked into the response: %s", methodJSON) } source := mustCanonical(t, methodSchema) if strings.Contains(source, "getFormatOptions") { t.Fatalf("cached schema stored the method name in JSON: %s", source) } modelSchema := mustCompileForm(t, sourceController{schemaController: schemaController{model: "Widget"}, rec: modelOptions{}}, formConfig, "fields:\n format:\n type: dropdown\n options: getFormatOptions\n") modelJSON := localizeJSON(t, modelSchema, towel.WithLocale(context.Background(), "pl"), tr, modelOptions{}) if !strings.Contains(modelJSON, `{"value":"lp","label":"Winyl"}`) { t.Fatalf("model provider options = %s", modelJSON) } for _, method := range []string{"getFormatOptions", "GetFormatOptions"} { _, err := CompileForm("acme.demo", schemaController{model: "Widget"}, formFS(formConfig, "fields:\n format:\n type: dropdown\n options: "+method+"\n")) if err == nil || !strings.Contains(err.Error(), method) || !strings.Contains(err.Error(), "acme.demo.widgets") || !strings.Contains(err.Error(), "models/widget/fields.yaml") { t.Fatalf("method %s err = %v", method, err) } } } func TestFormSchemaLocaleIsolation(t *testing.T) { schema := mustCompileForm(t, schemaController{model: "Widget"}, strings.Replace(formConfig, "name: demo.form", "name: acme.demo::lang.form", 1), localizedFieldsYAML) before := mustCanonical(t, schema) tr := phrasebook.NewTranslator(formCatalog(t), phrasebook.Options{Locale: "en", Fallback: "en"}) enWant := localizeJSON(t, schema, towel.WithLocale(context.Background(), "en"), tr, nil) plWant := localizeJSON(t, schema, towel.WithLocale(context.Background(), "pl"), tr, nil) var wg sync.WaitGroup errCh := make(chan string, 32) for i := 0; i < 16; i++ { wg.Add(2) go func() { defer wg.Done() got := localizeJSON(t, schema, towel.WithLocale(context.Background(), "en"), tr, nil) if got != enWant { errCh <- "en drifted: " + got } }() go func() { defer wg.Done() got := localizeJSON(t, schema, towel.WithLocale(context.Background(), "pl"), tr, nil) if got != plWant { errCh <- "pl drifted: " + got } }() } wg.Wait() close(errCh) for msg := range errCh { t.Fatal(msg) } if after := mustCanonical(t, schema); after != before { t.Fatal("concurrent localization mutated the cache") } } const localizedFieldsYAML = `fields: title: label: acme.demo::lang.title comment: acme.demo::lang.comment tab: acme.demo::lang.tab emptyOption: acme.demo::lang.empty type: dropdown options: vinyl: acme.demo::lang.vinyl note: label: missing.label type: text ` const optionMapYAML = `fields: format: label: acme.demo::lang.title type: dropdown emptyOption: acme.demo::lang.empty options: vinyl: acme.demo::lang.vinyl 2: acme.demo::lang.two true: "Yes" ` const methodFieldsYAML = `fields: Format: label: acme.demo::lang.title type: dropdown options: getFormatOptions ` func formCatalog(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("form: Form\ntitle: Title\ncomment: Comment\ntab: Tab\nempty: None\nvinyl: Vinyl\ncd: CD\ntwo: Two\n")}, "lang/pl/lang.yaml": &fstest.MapFile{Data: []byte("form: Formularz\ntitle: Tytuł\ncomment: Komentarz\ntab: Zakładka\nempty: Brak\nvinyl: Winyl\ncd: Płyta\ntwo: Dwa\n")}, }) if err != nil { t.Fatalf("catalog: %v", err) } return cat } func mustCompileForm(t *testing.T, ctl pact.AdminController, config, fields string) *FormSchema { t.Helper() schema, err := CompileForm("acme.demo", ctl, formFS(config, fields)) if err != nil { t.Fatalf("compile: %v", err) } return schema } func mustCanonical(t *testing.T, schema *FormSchema) string { t.Helper() raw, err := schema.CanonicalJSON() if err != nil { t.Fatal(err) } return string(raw) } func localizeJSON(t *testing.T, schema *FormSchema, ctx context.Context, tr *phrasebook.Translator, provider pact.DropdownOptionsProvider) string { t.Helper() view, err := schema.Localize(ctx, tr, provider) if err != nil { t.Fatalf("localize: %v", err) } raw, err := view.CanonicalJSON() if err != nil { t.Fatal(err) } var buf bytes.Buffer if err := json.Compact(&buf, raw); err != nil { t.Fatalf("compact: %v\n%s", err, raw) } return buf.String() } type providerController struct{ schemaController } func (providerController) DropdownOptions(field string) []pact.Option { if field != "Format" { return nil } return []pact.Option{ {Value: "vinyl", Label: "acme.demo::lang.vinyl"}, {Value: "cd", Label: "acme.demo::lang.cd"}, } } type sourceController struct { schemaController rec any } func (s sourceController) NewRecord() any { return s.rec } type modelOptions struct{} func (modelOptions) DropdownOptions(field string) []pact.Option { if field != "format" { return nil } return []pact.Option{{Value: "lp", Label: "acme.demo::lang.vinyl"}} } var _ party.Plugin = formPlugin{}