// Copyright 2014 Martini Authors // Copyright 2014 The Macaron Authors // // Licensed under the Apache License, Version 2.0 (the "License"): you may // not use this file except in compliance with the License. You may obtain // a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the // License for the specific language governing permissions and limitations // under the License. package binding import ( "fmt" "net/http" "net/http/httptest" "reflect" "strings" "testing" chi "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var formTestCases = []formTestCase{ { description: "Happy path", shouldSucceed: true, payload: `title=Glorious+Post+Title&content=Lorem+ipsum+dolor+sit+amet`, contentType: formContentType, expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, }, { description: "Happy path with interface", shouldSucceed: true, withInterface: true, payload: `title=Glorious+Post+Title&content=Lorem+ipsum+dolor+sit+amet`, contentType: formContentType, expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, }, { description: "Empty payload", shouldSucceed: false, payload: ``, contentType: formContentType, expected: Post{}, }, { description: "Empty content type", shouldSucceed: false, payload: `title=Glorious+Post+Title&content=Lorem+ipsum+dolor+sit+amet`, contentType: ``, expected: Post{}, }, { description: "Malformed form body", shouldSucceed: false, payload: `title=%2`, contentType: formContentType, expected: Post{}, }, { description: "With nested and embedded structs", shouldSucceed: true, payload: `title=Glorious+Post+Title&id=1&name=Matt+Holt`, contentType: formContentType, expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}}, }, { description: "Required embedded struct field not specified", shouldSucceed: false, payload: `id=1&name=Matt+Holt`, contentType: formContentType, expected: BlogPost{Id: 1, Author: Person{Name: "Matt Holt"}}, }, { description: "Required nested struct field not specified", shouldSucceed: false, payload: `title=Glorious+Post+Title&id=1`, contentType: formContentType, expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1}, }, { description: "Multiple values into slice", shouldSucceed: true, payload: `title=Glorious+Post+Title&id=1&name=Matt+Holt&rating=4&rating=3&rating=5`, contentType: formContentType, expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}, Ratings: []int{4, 3, 5}}, }, { description: "Unexported field", shouldSucceed: true, payload: `title=Glorious+Post+Title&id=1&name=Matt+Holt&unexported=foo`, contentType: formContentType, expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}}, }, { description: "Query string POST", shouldSucceed: true, payload: `title=Glorious+Post+Title&content=Lorem+ipsum+dolor+sit+amet`, contentType: formContentType, expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, }, { description: "Query string with Content-Type (POST request)", shouldSucceed: true, queryString: "?title=Glorious+Post+Title&content=Lorem+ipsum+dolor+sit+amet", payload: ``, contentType: formContentType, expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, }, { description: "Query string without Content-Type (GET request)", shouldSucceed: true, method: "GET", queryString: "?title=Glorious+Post+Title&content=Lorem+ipsum+dolor+sit+amet", payload: ``, expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, }, { description: "Embed struct pointer", shouldSucceed: true, deepEqual: true, method: "GET", queryString: "?name=Glorious+Post+Title&email=Lorem+ipsum+dolor+sit+amet", payload: ``, expected: EmbedPerson{&Person{Name: "Glorious Post Title", Email: "Lorem ipsum dolor sit amet"}}, }, { description: "Embed struct pointer remain nil if not binded", shouldSucceed: true, deepEqual: true, method: "GET", queryString: "?", payload: ``, expected: EmbedPerson{nil}, }, { description: "Custom error handler", shouldSucceed: true, deepEqual: true, method: "GET", queryString: "?", payload: ``, expected: CustomErrorHandle{}, }, { description: "pointer form", shouldSucceed: true, deepEqual: true, payload: fmt.Sprintf("Id=%d&IdPointer=%d&Url=%s&UrlPointer=%s&AlphaDash=%s&AlphaDashPointer=%s", idInt, idInt, urlStr, urlStr, alphaDashStr, alphaDashStr), contentType: formContentType, expected: PointerForm{ID: idInt, IDPointer: &idInt, URL: urlStr, URLPointer: &urlStr, AlphaDash: alphaDashStr, AlphaDashPointer: &alphaDashStr}, }, { description: "Trim spacing", shouldSucceed: true, payload: `title=Glorious+Post+Title%20%20&description=A%20description%20with%20intentional%20spaces%20%20&slug=glorious-post%20`, contentType: formContentType, expected: TrimSpaceForm{Title: "Glorious Post Title", Description: "A description with intentional spaces ", Slug: "glorious-post"}, }, { description: "Trim spacing and validating", shouldSucceed: false, payload: `title=Glorious+Post+Title%20%20&description=A%20description%20with%20intentional%20spaces%20%20&slug=%20`, contentType: formContentType, expected: TrimSpaceForm{Title: "Glorious Post Title", Description: "A description with intentional spaces "}, }, } func init() { AddRule(&Rule{ func(rule string) bool { return rule == "CustomRule" }, func(errs Errors, _ string, _ any) (bool, Errors) { return false, errs }, }) SetNameMapper(nameMapper) } func Test_Form(t *testing.T) { for _, testCase := range formTestCases { t.Run(testCase.description, func(t *testing.T) { performFormTest(t, Form, testCase) }) } } func performFormTest(t *testing.T, binder handlerFunc, testCase formTestCase) { resp := httptest.NewRecorder() m := chi.NewRouter() formTestHandler := func(actual any, errs Errors) { if testCase.shouldSucceed { assert.Empty(t, errs) } else if !testCase.shouldSucceed { assert.NotEmpty(t, errs) } expString := fmt.Sprintf("%+v", testCase.expected) actString := fmt.Sprintf("%+v", actual) if actString != expString && (!testCase.deepEqual || !reflect.DeepEqual(testCase.expected, actual)) { assert.Equal(t, expString, actString) } } switch p := testCase.expected.(type) { case Post: if testCase.withInterface { m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { var actual Post errs := binder(req, &actual) assert.Equal(t, p.Title, actual.Title) formTestHandler(actual, errs) }) } else { m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { var actual Post errs := binder(req, &actual) formTestHandler(actual, errs) }) m.Get(testRoute, func(_ http.ResponseWriter, req *http.Request) { var actual Post errs := binder(req, &actual) formTestHandler(actual, errs) }) } case BlogPost: if testCase.withInterface { m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { var actual BlogPost errs := binder(req, &actual) assert.Equal(t, p.Title, actual.Title) formTestHandler(actual, errs) }) } else { m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { var actual BlogPost errs := binder(req, &actual) formTestHandler(actual, errs) }) } case EmbedPerson: m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { var actual EmbedPerson errs := binder(req, &actual) formTestHandler(actual, errs) }) m.Get(testRoute, func(_ http.ResponseWriter, req *http.Request) { var actual EmbedPerson errs := binder(req, &actual) formTestHandler(actual, errs) }) case CustomErrorHandle: m.Get(testRoute, func(_ http.ResponseWriter, req *http.Request) { var actual CustomErrorHandle errs := binder(req, &actual) formTestHandler(actual, errs) }) case PointerForm: m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { var actual PointerForm errs := binder(req, &actual) formTestHandler(actual, errs) }) case TrimSpaceForm: m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { var actual TrimSpaceForm errs := binder(req, &actual) formTestHandler(actual, errs) }) } if len(testCase.method) == 0 { testCase.method = "POST" } req, err := http.NewRequest(testCase.method, testRoute+testCase.queryString, strings.NewReader(testCase.payload)) if err != nil { panic(err) } req.Header.Set("Content-Type", testCase.contentType) m.ServeHTTP(resp, req) switch resp.Code { case http.StatusNotFound: panic("Routing is messed up in test fixture (got 404): check methods and paths") case http.StatusInternalServerError: panic("Something bad happened on '" + testCase.description + "'") } } type ( formTestCase struct { description string shouldSucceed bool deepEqual bool withInterface bool queryString string payload string contentType string expected any method string } ) type defaultForm struct { Default string `binding:"Default(hello world)"` } func Test_Default(t *testing.T) { m := chi.NewRouter() m.Get("/", func(_ http.ResponseWriter, req *http.Request) { var f defaultForm Bind(req, &f) assert.Equal(t, "hello world", f.Default) }) resp := httptest.NewRecorder() req, err := http.NewRequest("GET", "/", nil) require.NoError(t, err) m.ServeHTTP(resp, req) }