diff --git a/modules/cabana/field_type_postgres_test.go b/modules/cabana/field_type_postgres_test.go new file mode 100644 index 0000000..2aabb74 --- /dev/null +++ b/modules/cabana/field_type_postgres_test.go @@ -0,0 +1,234 @@ +package cabana + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "testing/fstest" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/pact" + "gorm.io/gorm" +) + +// The plugin field type save, show and upload paths on a real Postgres +// database (testcontainers, like newListService). Without Docker the test +// fails rather than skips; it runs on a Docker host before /msd-verify-work. + +// pgFTController scopes the notes form to rows whose title is not "hidden". +type pgFTController struct{ ftController } + +func (pgFTController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB { + return db.Where("title <> ?", "hidden") +} + +// pgFTPlugin serves pgFTController instead of ftController. +type pgFTPlugin struct{ ftPlugin } + +func (pgFTPlugin) AdminControllers() []pact.AdminController { + return []pact.AdminController{pgFTController{}} +} + +// pgFTCall runs one admin record request against the notes controller. +func pgFTCall(t *testing.T, svc *service, method, id string, body any) *httptest.ResponseRecorder { + t.Helper() + var raw []byte + if body != nil { + var err error + if raw, err = json.Marshal(body); err != nil { + t.Fatal(err) + } + } + req := httptest.NewRequest(method, "/", bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + for name, value := range map[string]string{"vendor": "acme", "plugin": "demo", "controller": "notes"} { + req.SetPathValue(name, value) + } + if id != "" { + req.SetPathValue("id", id) + } + req = req.WithContext(bouncer.WithUser(req.Context(), superUser())) + rec := httptest.NewRecorder() + switch method { + case http.MethodPost: + svc.create(rec, req) + case http.MethodGet: + svc.show(rec, req) + case http.MethodPut: + svc.update(rec, req) + default: + t.Fatalf("method %s", method) + } + return rec +} + +// pgFTBody is one admin record response. +type pgFTBody struct { + Data map[string]any `json:"data"` + Meta map[string]json.RawMessage `json:"meta"` +} + +func pgFTDecode(t *testing.T, rec *httptest.ResponseRecorder) pgFTBody { + t.Helper() + var out pgFTBody + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("body %s: %v", rec.Body.String(), err) + } + return out +} + +func pgFTNotices(t *testing.T, body pgFTBody) []FieldNotice { + t.Helper() + raw, ok := body.Meta["notices"] + if !ok { + return nil + } + var out []FieldNotice + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + return out +} + +func TestFieldTypeSavePostgres(t *testing.T) { + _, db := newListService(t) + if err := db.Migrator().DropTable(&ftNote{}); err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&ftNote{}); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Migrator().DropTable(&ftNote{}) }) + + fsys := ftFS(ftNotesField, ftBodyField) + fsys["controllers/notes/config_list.yaml"] = &fstest.MapFile{Data: []byte(ftListConfig + "toolbar:\n buttons: [create]\n")} + reg, err := ftCompile(pgFTPlugin{ftPlugin{id: "acme.demo", fsys: fsys, types: []pact.FormFieldType{ftNotesType(), ftNotesMLType()}}}) + if err != nil { + t.Fatalf("compile: %v", err) + } + writer := ftWriter() + app := backpack.New(nil) + if err := app.Publish(db); err != nil { + t.Fatal(err) + } + if err := app.Publish[TranslationWriter](writer); err != nil { + t.Fatal(err) + } + svc := &service{app: app, reg: reg, uploadBytes: 4096} + + row := func(id uint) ftNote { + t.Helper() + var out ftNote + if err := db.First(&out, id).Error; err != nil { + t.Fatal(err) + } + return out + } + + // Create: plain and translatable values decoded, notices per locale. + rec := pgFTCall(t, svc, http.MethodPost, "", map[string]any{"title": "T", "notes": " n ", "body": map[string]any{"pl": " P ", "en": " E "}}) + if rec.Code != http.StatusCreated { + t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String()) + } + created := pgFTDecode(t, rec) + idNum, _ := created.Data["id"].(float64) + id := uint(idNum) + if id == 0 { + t.Fatalf("create data=%v", created.Data) + } + idText := strconv.FormatUint(uint64(id), 10) + if got := row(id); got.Notes != "n" || got.Body != "P" || got.Title != "T" { + t.Fatalf("created row=%+v", got) + } + if writer.attrs["en"]["body"] != "E" { + t.Fatalf("en translation=%v", writer.attrs) + } + notices := pgFTNotices(t, created) + if len(notices) != 3 { + t.Fatalf("create notices=%+v", notices) + } + if created.Data["notes"] != "served:n" { + t.Fatalf("create response serves notes=%v, want it through Load", created.Data["notes"]) + } + + // Show serves every plugin field through Load, per locale. + rec = pgFTCall(t, svc, http.MethodGet, idText, nil) + if rec.Code != http.StatusOK { + t.Fatalf("show status=%d body=%s", rec.Code, rec.Body.String()) + } + shown := pgFTDecode(t, rec) + body, _ := shown.Data["body"].(map[string]any) + if shown.Data["notes"] != "served:n" || body["pl"] != "P" || body["en"] != "E" { + t.Fatalf("show data=%v", shown.Data) + } + + // An untouched update writes nothing and reports nothing. + before := row(id) + writes := len(writer.ids) + rec = pgFTCall(t, svc, http.MethodPut, idText, map[string]any{"title": "T", "notes": shown.Data["notes"], "body": shown.Data["body"]}) + if rec.Code != http.StatusOK { + t.Fatalf("untouched update status=%d body=%s", rec.Code, rec.Body.String()) + } + if _, ok := pgFTDecode(t, rec).Meta["notices"]; ok { + t.Fatalf("untouched update reports notices: %s", rec.Body.String()) + } + if after := row(id); after != before { + t.Fatalf("untouched update changed the row: %+v -> %+v", before, after) + } + if len(writer.ids) != writes { + t.Fatalf("untouched update wrote translations: %v", writer.ids[writes:]) + } + + // A changed update writes and reports only what changed. + rec = pgFTCall(t, svc, http.MethodPut, idText, map[string]any{"title": "T", "notes": "m ", "body": map[string]any{"pl": "P", "en": " E2"}}) + if rec.Code != http.StatusOK { + t.Fatalf("changed update status=%d body=%s", rec.Code, rec.Body.String()) + } + changed := pgFTNotices(t, pgFTDecode(t, rec)) + if fmt.Sprint(changed) != fmt.Sprint([]FieldNotice{{Field: "notes", Message: "acme.demo::lang.trimmed"}, {Field: "body", Locale: "en", Message: "trimmed en"}}) { + t.Fatalf("changed notices=%+v", changed) + } + if got := row(id); got.Notes != "m" || got.Body != "P" { + t.Fatalf("changed row=%+v", got) + } + if writer.attrs["en"]["body"] != "E2" { + t.Fatalf("en translation=%v", writer.attrs) + } + + // A FormFieldError is a 422 and the transaction writes nothing. + before = row(id) + rec = pgFTCall(t, svc, http.MethodPut, idText, map[string]any{"title": "Changed", "notes": "bad"}) + if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"notes":["acme.demo::lang.bad"]`) { + t.Fatalf("refused update status=%d body=%s", rec.Code, rec.Body.String()) + } + if after := row(id); after != before { + t.Fatalf("refused update changed the row: %+v -> %+v", before, after) + } + + // Upload with a real record: the type sees it; a record outside the + // FormExtendQuery scope is 404. + admin := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true} + up := upCall(t, svc, idText, "notes", []upPart{{"file", "a.txt", []byte("hello")}}, admin) + if up.Code != http.StatusCreated || !strings.Contains(up.Body.String(), "record=set") { + t.Fatalf("upload status=%d body=%s", up.Code, up.Body.String()) + } + hidden := ftNote{Title: "hidden"} + if err := db.Create(&hidden).Error; err != nil { + t.Fatal(err) + } + up = upCall(t, svc, strconv.FormatUint(uint64(hidden.ID), 10), "notes", []upPart{{"file", "a.txt", []byte("hello")}}, admin) + if up.Code != http.StatusNotFound { + t.Fatalf("out-of-scope upload status=%d body=%s", up.Code, up.Body.String()) + } + up = upCall(t, svc, "999999", "notes", []upPart{{"file", "a.txt", []byte("hello")}}, admin) + if up.Code != http.StatusNotFound { + t.Fatalf("missing record upload status=%d body=%s", up.Code, up.Body.String()) + } +} diff --git a/modules/cabana/field_type_test.go b/modules/cabana/field_type_test.go index f8b442c..a62d204 100644 --- a/modules/cabana/field_type_test.go +++ b/modules/cabana/field_type_test.go @@ -1,13 +1,27 @@ package cabana import ( + "bytes" "context" "encoding/json" + "errors" "fmt" + "io" + "io/fs" + "mime/multipart" + "net/http" + "net/http/httptest" "strings" "testing" + "testing/fstest" + "unicode/utf8" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/phrasebook" + "git.golem15.com/golem15/summercms/modules/towel" ) // Unit tests of the plugin field type framework (Phase 16.2 plan 06). They @@ -297,3 +311,1021 @@ func TestFieldTypeUntouched(t *testing.T) { } }) } + +// ftDecl is a minimal valid field type for boot checks. +func ftDecl(name, element string) pact.FormFieldType { + return pact.FormFieldType{ + Name: name, + Element: element, + Decode: func(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) { + return pact.FormFieldDecoded{Value: in.Value}, nil + }, + } +} + +// ftBare is a plugin with field types and no form controllers. +type ftBare struct { + id string + fsys fs.FS + types []pact.FormFieldType +} + +func (p ftBare) ID() string { return p.id } +func (ftBare) Requires() []string { return nil } +func (ftBare) Register(*backpack.App) error { return nil } +func (ftBare) Boot(*backpack.App) error { return nil } +func (p ftBare) AdminFS() fs.FS { return p.fsys } +func (p ftBare) FormFieldTypes() []pact.FormFieldType { return p.types } + +// ftPlain is a plugin without field types. +type ftPlain struct{} + +func (ftPlain) ID() string { return "acme.plain" } +func (ftPlain) Requires() []string { return nil } +func (ftPlain) Register(*backpack.App) error { return nil } +func (ftPlain) Boot(*backpack.App) error { return nil } + +// ftAssets is a tree with one script and one stylesheet. +func ftAssets() fstest.MapFS { + return fstest.MapFS{ + "assets/js/notes.js": &fstest.MapFile{Data: []byte("customElements.define('acme-demo-notes', class extends HTMLElement {})\n")}, + "assets/css/notes.css": &fstest.MapFile{Data: []byte("acme-demo-notes { display: block }\n")}, + } +} + +func ftBoot(types ...pact.FormFieldType) (*fieldTypeRegistry, error) { + return compileFieldTypes([]party.Plugin{ftBare{id: "acme.demo", fsys: ftAssets(), types: types}}) +} + +func wantErr(t *testing.T, err error, parts ...string) { + t.Helper() + if err == nil { + t.Fatalf("err=nil, want an error containing %q", parts) + } + for _, part := range parts { + if !strings.Contains(err.Error(), part) { + t.Fatalf("err=%v, want it to contain %q", err, part) + } + } +} + +func TestFieldTypeRegistry(t *testing.T) { + t.Run("the alias must match the pattern", func(t *testing.T) { + for _, name := range []string{"", "Notes", "1notes", "no-tes", "no_tes", "notes!"} { + _, err := ftBoot(ftDecl(name, "acme-demo-notes")) + wantErr(t, err, "acme.demo", "alias must match ^[a-z][a-z0-9]*$") + } + }) + + t.Run("an alias equal to a built-in type fails", func(t *testing.T) { + for _, name := range []string{"text", "markdown", "mltext", "mlmarkdown", "fileupload", "widget"} { + _, err := ftBoot(ftDecl(name, "acme-demo-notes")) + wantErr(t, err, "field type "+name, "alias is a built-in field type") + } + }) + + t.Run("an alias two plugins claim names both", func(t *testing.T) { + first := ftBare{id: "acme.demo", types: []pact.FormFieldType{ftDecl("notes", "acme-demo-notes")}} + second := ftBare{id: "acme.other", types: []pact.FormFieldType{ftDecl("notes", "acme-other-notes")}} + _, err := compileFieldTypes([]party.Plugin{first, second}) + wantErr(t, err, "plugin acme.other", "field type notes", "already registered by plugin acme.demo") + }) + + t.Run("an alias one plugin declares twice fails", func(t *testing.T) { + _, err := ftBoot(ftDecl("notes", "acme-demo-notes"), ftDecl("notes", "acme-demo-notes2")) + wantErr(t, err, "alias is registered twice") + }) + + t.Run("the element must be a valid tag with the plugin prefix", func(t *testing.T) { + for _, tc := range []struct{ element, want string }{ + {"acme-other-notes", `must start with the plugin prefix "acme-demo-"`}, + {"notes", "not a valid custom-element name"}, + {"Acme-demo-notes", "not a valid custom-element name"}, + {"", "not a valid custom-element name"}, + } { + _, err := ftBoot(ftDecl("notes", tc.element)) + wantErr(t, err, "element", tc.want) + } + _, err := compileFieldTypes([]party.Plugin{ftBare{id: "font.face", types: []pact.FormFieldType{ftDecl("notes", "font-face")}}}) + wantErr(t, err, "reserved element name") + }) + + t.Run("Decode is required", func(t *testing.T) { + typ := ftDecl("notes", "acme-demo-notes") + typ.Decode = nil + _, err := ftBoot(typ) + wantErr(t, err, "Decode is required") + }) + + t.Run("assets need pact.AdminAssets", func(t *testing.T) { + typ := ftDecl("notes", "acme-demo-notes") + typ.Scripts = []string{"assets/js/notes.js"} + _, err := compileFieldTypes([]party.Plugin{ftBare{id: "acme.demo", types: []pact.FormFieldType{typ}}}) + wantErr(t, err, "pact.AdminAssets") + }) + + t.Run("asset paths and extensions are checked", func(t *testing.T) { + for _, tc := range []struct { + scripts, styles []string + want string + }{ + {[]string{"js/notes.js"}, nil, "clean path under assets/"}, + {[]string{"assets/../notes.js"}, nil, "clean path under assets/"}, + {[]string{"assets//notes.js"}, nil, "clean path under assets/"}, + {[]string{"assets/js/notes.txt"}, nil, "asset must end in .js or .mjs"}, + {nil, []string{"assets/js/notes.js"}, "asset must end in .css"}, + {[]string{"assets/js/missing.js"}, nil, "not in the plugin's embedded files"}, + {[]string{"assets/js/notes.js", "assets/js/notes.js"}, nil, "asset declared twice"}, + } { + typ := ftDecl("notes", "acme-demo-notes") + typ.Scripts, typ.Styles = tc.scripts, tc.styles + _, err := ftBoot(typ) + wantErr(t, err, "acme.demo", tc.want) + } + }) + + t.Run("valid types register their assets with content hashes", func(t *testing.T) { + typ := ftDecl("notes", "acme-demo-notes") + typ.Scripts = []string{"assets/js/notes.js"} + typ.Styles = []string{"assets/css/notes.css"} + plain := ftDecl("alpha", "acme-demo-alpha") + reg, err := compileFieldTypes([]party.Plugin{nil, ftPlain{}, ftBare{id: "acme.demo", fsys: ftAssets(), types: []pact.FormFieldType{typ, plain}}}) + if err != nil { + t.Fatal(err) + } + if got := reg.names(); fmt.Sprint(got) != "[alpha notes]" { + t.Fatalf("names=%v", got) + } + assets := reg.assets() + if len(assets) != 2 || assets[0].key != "acme/demo/js/notes.js" || assets[1].key != "acme/demo/css/notes.css" { + t.Fatalf("assets=%v", assets) + } + for _, a := range assets { + if len(a.version) != 12 || strings.Trim(a.version, "0123456789abcdef") != "" || a.etag != `"`+a.version+strings.TrimPrefix(a.etag, `"`+a.version) { + t.Fatalf("asset %s version=%q etag=%q", a.key, a.version, a.etag) + } + } + if reg.byName["notes"].owner != "acme.demo" || reg.byName["alpha"].scripts != nil { + t.Fatalf("compiled=%+v", reg.byName) + } + }) + + t.Run("no registered type is a nil registry", func(t *testing.T) { + reg, err := compileFieldTypes([]party.Plugin{ftPlain{}, ftBare{id: "acme.demo"}}) + if err != nil || reg != nil { + t.Fatalf("reg=%v err=%v", reg, err) + } + var none *fieldTypeRegistry + if none.names() != nil || none.assets() != nil || none.refusing() != nil { + t.Fatal("nil registry helpers are not nil") + } + }) +} + +func TestFieldTypeOptions(t *testing.T) { + withOptions := func(options ...string) pact.FormFieldType { + typ := ftDecl("notes", "acme-demo-notes") + typ.Options = options + return typ + } + + t.Run("option keys must match the pattern", func(t *testing.T) { + for _, key := range []string{"", "1rows", "row-s", "row_s", "rows!"} { + _, err := ftBoot(withOptions(key)) + wantErr(t, err, "must match ^[A-Za-z][A-Za-z0-9]*$") + } + }) + + t.Run("option keys must not be field keys", func(t *testing.T) { + for _, key := range []string{"label", "span", "type", "default", "mode", "preset", "options", "nameFrom"} { + _, err := ftBoot(withOptions(key)) + wantErr(t, err, "option "+key+" is a general field key") + } + }) + + t.Run("an option declared twice fails", func(t *testing.T) { + _, err := ftBoot(withOptions("rows", "cols", "rows")) + wantErr(t, err, "option rows is declared twice") + }) + + t.Run("CompileFormWithTypes runs the same checks", func(t *testing.T) { + fsys := ftFS(ftNotesField) + for _, tc := range []struct { + types []pact.FormFieldType + want string + }{ + {[]pact.FormFieldType{withOptions("label")}, "is a general field key"}, + {[]pact.FormFieldType{withOptions("rows", "rows")}, "declared twice"}, + {[]pact.FormFieldType{ftDecl("Notes", "acme-demo-notes")}, "alias must match"}, + {[]pact.FormFieldType{ftDecl("notes", "acme-demo-notes"), ftDecl("notes", "acme-demo-x")}, "registered twice"}, + } { + _, err := CompileFormWithTypes("acme.demo", ftController{}, fsys, tc.types) + wantErr(t, err, tc.want) + } + }) + + t.Run("a key the type does not declare fails boot naming field and key", func(t *testing.T) { + for _, key := range []string{"cols", "element", "translatable", "typeOptions"} { + _, err := ftCompile(ftDemo(ftNotesField + " " + key + ": 1\n")) + wantErr(t, err, "field notes", "unknown field "+key) + } + }) +} + +func TestFieldTypeCompile(t *testing.T) { + kinds := ftDecl("notes", "acme-demo-notes") + kinds.Options = []string{"text", "block", "flag", "count", "ratio", "nothing", "items", "nested"} + var seen map[string]any + kinds.CheckOptions = func(options map[string]any) error { + seen = options + options["text"] = "changed by CheckOptions" + return nil + } + const kindsField = " type: notes\n" + + " text: plain\n" + + " block: |\n line one\n line two\n" + + " flag: true\n" + + " count: 4\n" + + " ratio: 1.5\n" + + " nothing: ~\n" + + " items: [a, 2, false]\n" + + " nested:\n deep:\n list: [x]\n n: 3\n" + + t.Run("option values of every YAML kind are decoded", func(t *testing.T) { + schema, err := CompileFormWithTypes("acme.demo", ftController{}, ftFS(kindsField), []pact.FormFieldType{kinds}) + if err != nil { + t.Fatal(err) + } + var notes FormField + for _, field := range schema.Fields { + if field.Name == "notes" { + notes = field + } + } + want := map[string]any{ + "text": "plain", + "block": "line one\nline two\n", + "flag": true, + "count": 4, + "ratio": 1.5, + "nothing": nil, + "items": []any{"a", 2, false}, + "nested": map[string]any{"deep": map[string]any{"list": []any{"x"}}, "n": 3}, + } + if fmt.Sprintf("%#v", notes.TypeOptions) != fmt.Sprintf("%#v", want) { + t.Fatalf("typeOptions=%#v\nwant %#v", notes.TypeOptions, want) + } + if notes.Element != "acme-demo-notes" || notes.Translatable { + t.Fatalf("field=%+v", notes) + } + // CheckOptions got a copy: its change did not reach the schema. + if seen["text"] != "changed by CheckOptions" || notes.TypeOptions["text"] != "plain" { + t.Fatalf("CheckOptions shares the schema's options: %v", notes.TypeOptions) + } + }) + + t.Run("the general field keys are accepted", func(t *testing.T) { + field := " type: notes\n label: Notes\n comment: C\n span: left\n required: true\n" + + " tab: T\n context: [create, update]\n attributes:\n placeholder: y\n size: large\n default: d\n" + schema, err := CompileFormWithTypes("acme.demo", ftController{}, ftFS(field), []pact.FormFieldType{ftDecl("notes", "acme-demo-notes")}) + if err != nil { + t.Fatal(err) + } + if len(schema.Fields) != 2 || schema.Fields[1].Name != "notes" || schema.Fields[1].TypeOptions != nil { + t.Fatalf("fields=%+v", schema.Fields) + } + }) + + t.Run("built-in type keys are refused on a plugin field", func(t *testing.T) { + for _, key := range []string{"mode: tab", "nameFrom: name", "options: {a: A}", "preset: {field: title, type: slug}", "widget: acme-demo-x", "fileTypes: png"} { + _, err := CompileFormWithTypes("acme.demo", ftController{}, ftFS(" type: notes\n "+key+"\n"), []pact.FormFieldType{ftDecl("notes", "acme-demo-notes")}) + wantErr(t, err, "unknown field "+strings.SplitN(key, ":", 2)[0]) + } + }) + + t.Run("a CheckOptions error fails naming field and type", func(t *testing.T) { + typ := ftDecl("notes", "acme-demo-notes") + typ.Options = []string{"rows"} + typ.CheckOptions = func(map[string]any) error { return fmt.Errorf("rows must be at most 3") } + _, err := CompileFormWithTypes("acme.demo", ftController{}, ftFS(ftNotesField), []pact.FormFieldType{typ}) + wantErr(t, err, "field notes", "type notes", "rows must be at most 3") + }) + + t.Run("CompileForm accepts only built-in types", func(t *testing.T) { + _, err := CompileForm("acme.demo", ftController{}, ftFS(" type: notes\n label: Notes\n")) + wantErr(t, err, "unsupported type notes") + }) + + t.Run("settings and relation forms refuse plugin types", func(t *testing.T) { + reg, err := ftCompile(ftDemo(ftNotesField)) + if err != nil { + t.Fatal(err) + } + fsys := ftFS(ftNotesField) + item := pact.SettingsItem{Code: "notes", Label: "Notes", Model: "Note", Form: "models/note/fields.yaml", NewModel: func() any { return &ftNote{} }} + _, err = compileSettingWithTypes("acme.demo", item, fsys, reg.fieldTypes) + wantErr(t, err, "setting notes", "plugin field type notes is supported in controller forms only") + raw := []byte("fields:\n notes:\n type: notes\n") + _, err = decodeFields(raw, reg.fieldTypes.refusing()) + wantErr(t, err, "supported in controller forms only") + _, err = decodePivotFields([]byte("fields:\n pivot[notes]:\n type: notes\n"), reg.fieldTypes.refusing()) + wantErr(t, err, "supported in controller forms only") + // A built-in field still compiles on a refusing registry. + if _, err := decodeFields([]byte("fields:\n title:\n type: text\n"), reg.fieldTypes.refusing()); err != nil { + t.Fatal(err) + } + }) +} + +// ftTranslator is a real phrasebook translator for the acme.demo phrases. +func ftTranslator(t *testing.T, locale string) *phrasebook.Translator { + t.Helper() + cat := phrasebook.NewCatalog() + err := cat.Load("acme.demo", fstest.MapFS{ + "lang/en/lang.yaml": &fstest.MapFile{Data: []byte("bad: The notes are not acceptable.\n" + + "removed:\n one: \":count tag was removed: :names\"\n other: \":count tags were removed: :names\"\n" + + "changed: Changed :what.\n")}, + "lang/pl/lang.yaml": &fstest.MapFile{Data: []byte("bad: Notatki są niepoprawne.\n" + + "removed:\n one: \"Usunięto :count znacznik: :names\"\n few: \"Usunięto :count znaczniki: :names\"\n many: \"Usunięto :count znaczników: :names\"\n other: \"Usunięto :count znacznika: :names\"\n" + + "changed: Zmieniono :what.\n")}, + }) + if err != nil { + t.Fatalf("catalog: %v", err) + } + return phrasebook.NewTranslator(cat, phrasebook.Options{Locale: locale, Fallback: "en"}) +} + +// ftOnly compiles the notes controller with exactly these types. +func ftOnly(t *testing.T, notesField string, types ...pact.FormFieldType) *CompiledController { + t.Helper() + reg, err := ftCompile(ftPlugin{id: "acme.demo", fsys: ftFS(notesField, ftBodyField), types: types}) + if err != nil { + t.Fatalf("compile: %v", err) + } + cc, _ := reg.Get("acme.demo.notes") + return cc +} + +func TestFieldTypeDecode(t *testing.T) { + cc := ftOnly(t, ftNotesField, ftNotesType(), ftNotesMLType()) + + t.Run("a FormFieldError on the default locale is a 422 with no write and no notices", func(t *testing.T) { + writer := ftWriter() + res, projected, translations, err := ftSave(t, cc, writer, &ftNote{ID: 1}, map[string]any{"body": map[string]any{"pl": "bad", "en": " E "}, "notes": " n "}, false) + var invalid *ValidationError + if !errors.As(err, &invalid) { + t.Fatalf("err=%v", err) + } + if msgs, _ := invalid.Details["body"].([]string); len(msgs) != 1 || msgs[0] != "bad in pl" { + t.Fatalf("details=%v", invalid.Details) + } + if len(res.notices) != 0 || len(res.writes) != 0 { + t.Fatalf("notices=%v writes=%v", res.notices, res.writes) + } + if projected["body"] != "bad" { + t.Fatalf("the refused value was replaced: %v", projected) + } + _ = translations + rec := httptest.NewRecorder() + writeCRUDError(rec, err) + if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"body":["bad in pl"]`) || strings.Contains(rec.Body.String(), "notices") { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + }) + + t.Run("a FormFieldError on a non-default locale is a 422 on the field", func(t *testing.T) { + writer := ftWriter() + res, _, _, err := ftSave(t, cc, writer, &ftNote{ID: 1}, map[string]any{"body": map[string]any{"pl": " P ", "en": "bad"}}, false) + var invalid *ValidationError + if !errors.As(err, &invalid) || fmt.Sprint(invalid.Details["body"]) != "[bad in en]" { + t.Fatalf("err=%v", err) + } + if len(res.notices) != 0 || len(writer.attrs) != 0 { + t.Fatalf("notices=%v attrs=%v", res.notices, writer.attrs) + } + }) + + t.Run("FormFieldError messages are localized; an empty list gets a default", func(t *testing.T) { + plain := ftNotesType() + empty := ftDecl("blob", "acme-demo-blob") + empty.Decode = func(context.Context, pact.FormFieldValue) (pact.FormFieldDecoded, error) { + return pact.FormFieldDecoded{}, fmt.Errorf("wrapped: %w", &pact.FormFieldError{}) + } + occ := ftOnly(t, ftNotesField, plain, ftNotesMLType()) + for locale, want := range map[string]string{"en": "The notes are not acceptable.", "pl": "Notatki są niepoprawne."} { + _, err := decodePluginFields(context.Background(), pluginDecodeInput{ + Tr: ftTranslator(t, locale), CC: occ, Target: &ftNote{}, Values: map[string]any{"notes": "bad"}, Projected: map[string]any{}, + }) + var invalid *ValidationError + if !errors.As(err, &invalid) || fmt.Sprint(invalid.Details["notes"]) != "["+want+"]" { + t.Fatalf("%s: err=%v", locale, err) + } + } + ecc := ftOnly(t, " type: blob\n", empty, ftNotesMLType()) + _, err := ftDecode(ecc, &ftNote{}, map[string]any{"notes": "x"}, map[string]any{}) + var invalid *ValidationError + if !errors.As(err, &invalid) || fmt.Sprint(invalid.Details["notes"]) != "[The notes field has an invalid value.]" { + t.Fatalf("err=%v", err) + } + }) + + t.Run("other errors and a non-string translatable value are opaque 500s", func(t *testing.T) { + secret := ftNotesMLType() + secret.Decode = func(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) { + if in.Locale == "en" { + return pact.FormFieldDecoded{}, errors.New("secret plugin detail") + } + return pact.FormFieldDecoded{Value: in.Value}, nil + } + number := ftNotesMLType() + number.Decode = func(context.Context, pact.FormFieldValue) (pact.FormFieldDecoded, error) { + return pact.FormFieldDecoded{Value: 42, Notices: []pact.FormFieldNotice{{Message: "secret notice"}}}, nil + } + for name, typ := range map[string]pact.FormFieldType{"error": secret, "number": number} { + occ := ftOnly(t, ftNotesField, ftNotesType(), typ) + res, _, _, err := ftSave(t, occ, ftWriter(), &ftNote{ID: 1}, map[string]any{"body": map[string]any{"pl": "P", "en": "E"}}, false) + var life *lifecycleError + if !errors.As(err, &life) || strings.Contains(err.Error(), "secret") { + t.Fatalf("%s: err=%v", name, err) + } + if len(res.notices) != 0 { + t.Fatalf("%s: notices=%v", name, res.notices) + } + rec := httptest.NewRecorder() + writeCRUDError(rec, err) + if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "secret") { + t.Fatalf("%s: status=%d body=%s", name, rec.Code, rec.Body.String()) + } + } + }) + + t.Run("Decode receives the save's record, options and locale", func(t *testing.T) { + var got []pact.FormFieldValue + probe := ftNotesMLType() + probe.Options = []string{"rows"} + probe.Decode = func(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) { + got = append(got, in) + return pact.FormFieldDecoded{Value: in.Value}, nil + } + occ := ftOnly(t, ftNotesField, ftNotesType(), probe) + target := &ftNote{ID: 9} + if _, _, _, err := ftSave(t, occ, ftWriter(), target, map[string]any{"body": map[string]any{"pl": "P", "en": "E"}}, false); err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].Locale != "pl" || !got[0].Default || got[1].Locale != "en" || got[1].Default { + t.Fatalf("calls=%+v", got) + } + if got[0].Record != target || got[0].Field != "body" || got[0].Translated == nil || got[0].Options == nil { + t.Fatalf("value=%+v", got[0]) + } + plain := ftOnly(t, ftNotesField, ftNotesType(), ftNotesMLType()) + var plainIn pact.FormFieldValue + typ := plain.Form.Fields[1].fieldType + typ.def.Decode = func(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) { + plainIn = in + return pact.FormFieldDecoded{Value: in.Value}, nil + } + if _, err := ftDecode(plain, &ftNote{}, map[string]any{"notes": "n"}, map[string]any{}); err != nil { + t.Fatal(err) + } + if plainIn.Locale != "" || !plainIn.Default || plainIn.Translated != nil || plainIn.Options["rows"] != 4 { + t.Fatalf("plain value=%+v", plainIn) + } + }) +} + +func TestFieldTypeNotices(t *testing.T) { + notice := func(field, locale, message string, count int, params map[string]string) pluginNotice { + return pluginNotice{field: field, locale: locale, notice: pact.FormFieldNotice{Message: message, Count: count, Params: params}} + } + + t.Run("a notice carries its field and the locale of a translatable value", func(t *testing.T) { + cc := ftOnly(t, ftNotesField, ftNotesType(), ftNotesMLType()) + res, _, _, err := ftSave(t, cc, ftWriter(), &ftNote{ID: 1}, map[string]any{"body": map[string]any{"pl": " P ", "en": "E"}, "notes": " n "}, false) + if err != nil { + t.Fatal(err) + } + got := localizeNotices(context.Background(), nil, res.notices) + want := []FieldNotice{{Field: "notes", Message: "acme.demo::lang.trimmed"}, {Field: "body", Locale: "pl", Message: "trimmed pl"}} + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("notices=%+v", got) + } + }) + + t.Run("Count selects the plural form and Params fill placeholders", func(t *testing.T) { + in := []pluginNotice{ + notice("body", "pl", "acme.demo::lang.removed", 1, map[string]string{"names": "script"}), + notice("body", "pl", "acme.demo::lang.removed", 3, map[string]string{"names": "a, b, c"}), + notice("body", "en", "acme.demo::lang.removed", 5, map[string]string{"names": "x"}), + notice("body", "en", "acme.demo::lang.changed", 0, map[string]string{"what": "markup"}), + } + en := localizeNotices(context.Background(), ftTranslator(t, "en"), in) + wantEN := []string{"1 tag was removed: script", "3 tags were removed: a, b, c", "5 tags were removed: x", "Changed markup."} + pl := localizeNotices(context.Background(), ftTranslator(t, "pl"), in) + wantPL := []string{"Usunięto 1 znacznik: script", "Usunięto 3 znaczniki: a, b, c", "Usunięto 5 znaczników: x", "Zmieniono markup."} + for i := range in { + if en[i].Message != wantEN[i] || pl[i].Message != wantPL[i] { + t.Fatalf("%d: en=%q pl=%q", i, en[i].Message, pl[i].Message) + } + } + // The request locale wins over the translator's default locale. + ctx := towel.WithLocale(context.Background(), "en") + if got := localizeNotices(ctx, ftTranslator(t, "pl"), in[:1]); got[0].Message != "1 tag was removed: script" { + t.Fatalf("request locale ignored: %q", got[0].Message) + } + }) + + t.Run("text that is not a phrase key passes through", func(t *testing.T) { + got := localizeNotices(context.Background(), ftTranslator(t, "en"), []pluginNotice{notice("notes", "", "Removed one