// Copyright 2014 Martini Authors // Copyright 2014 The Macaron Authors // Copyright 2024 The Forgejo 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" "io" "net/http" "net/http/httptest" "reflect" "runtime" "strings" "testing" chi "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" ) var jsonTestCases = []jsonTestCase{ { description: "Happy path", shouldSucceedOnJSON: true, payload: `{"title": "Glorious Post Title", "content": "Lorem ipsum dolor sit amet"}`, contentType: jsonContentType, expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, }, { description: "Happy path with interface", shouldSucceedOnJSON: true, withInterface: true, payload: `{"title": "Glorious Post Title", "content": "Lorem ipsum dolor sit amet"}`, contentType: jsonContentType, expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, }, { description: "Nil payload", shouldSucceedOnJSON: false, payload: `-nil-`, contentType: jsonContentType, expected: Post{}, }, { description: "Empty payload", shouldSucceedOnJSON: false, payload: ``, contentType: jsonContentType, expected: Post{}, }, { description: "Empty content type", shouldSucceedOnJSON: true, shouldFailOnBind: true, payload: `{"title": "Glorious Post Title", "content": "Lorem ipsum dolor sit amet"}`, contentType: ``, expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, }, { description: "Unsupported content type", shouldSucceedOnJSON: true, shouldFailOnBind: true, payload: `{"title": "Glorious Post Title", "content": "Lorem ipsum dolor sit amet"}`, contentType: `BoGuS`, expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, }, { description: "Malformed JSON", shouldSucceedOnJSON: false, payload: `{"title":"foo"`, contentType: jsonContentType, expected: Post{Title: "foo"}, }, { description: "Deserialization with nested and embedded struct", shouldSucceedOnJSON: true, payload: `{"title":"Glorious Post Title", "id":1, "author":{"name":"Matt Holt"}}`, contentType: jsonContentType, expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}}, }, { description: "Deserialization with nested and embedded struct with interface", shouldSucceedOnJSON: true, withInterface: true, payload: `{"title":"Glorious Post Title", "id":1, "author":{"name":"Matt Holt"}}`, contentType: jsonContentType, expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}}, }, { description: "Required nested struct field not specified", shouldSucceedOnJSON: false, payload: `{"title":"Glorious Post Title", "id":1, "author":{}}`, contentType: jsonContentType, expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1}, }, { description: "Required embedded struct field not specified", shouldSucceedOnJSON: false, payload: `{"id":1, "author":{"name":"Matt Holt"}}`, contentType: jsonContentType, expected: BlogPost{Id: 1, Author: Person{Name: "Matt Holt"}}, }, { description: "Slice of Posts", shouldSucceedOnJSON: true, payload: `[{"title": "First Post"}, {"title": "Second Post"}]`, contentType: jsonContentType, expected: []Post{{Title: "First Post"}, {Title: "Second Post"}}, }, { description: "Slice of structs", shouldSucceedOnJSON: true, payload: `{"name": "group1", "people": [{"name":"awoods"}, {"name": "anthony"}]}`, contentType: jsonContentType, expected: Group{Name: "group1", People: []Person{{Name: "awoods"}, {Name: "anthony"}}}, }, } func Test_Json(t *testing.T) { for _, testCase := range jsonTestCases { performJSONTest(t, JSON, testCase) } } func performJSONTest(t *testing.T, binder handlerFunc, testCase jsonTestCase) { fnName := runtime.FuncForPC(reflect.ValueOf(binder).Pointer()).Name() t.Run(testCase.description, func(t *testing.T) { var payload io.Reader httpRecorder := httptest.NewRecorder() m := chi.NewRouter() jsonTestHandler := func(actual any, errs Errors) { switch fnName { case "JSON": if testCase.shouldSucceedOnJSON { assert.Empty(t, errs, errs) assert.Equal(t, fmt.Sprintf("%+v", testCase.expected), fmt.Sprintf("%+v", actual)) } else { assert.NotEmpty(t, errs) } case "Bind": if !testCase.shouldFailOnBind { assert.Empty(t, errs, errs) } else { assert.NotEmpty(t, errs) assert.Equal(t, fmt.Sprintf("%+v", testCase.expected), fmt.Sprintf("%+v", actual)) } } } 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) for i, a := range actual { assert.Equal(t, p[i].Title, a.Title) jsonTestHandler(a, errs) } }) } else { m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { var actual []Post errs := binder(req, &actual) jsonTestHandler(actual, errs) }) } 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) jsonTestHandler(actual, errs) }) } else { m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { var actual Post errs := binder(req, &actual) jsonTestHandler(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) jsonTestHandler(actual, errs) }) } else { m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { var actual BlogPost errs := binder(req, &actual) jsonTestHandler(actual, errs) }) } case Group: if testCase.withInterface { m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { var actual Group errs := binder(req, &actual) assert.Equal(t, p.Name, actual.Name) jsonTestHandler(actual, errs) }) } else { m.Post(testRoute, func(_ http.ResponseWriter, req *http.Request) { var actual Group errs := binder(req, &actual) jsonTestHandler(actual, errs) }) } } if testCase.payload == "-nil-" { payload = nil } else { payload = strings.NewReader(testCase.payload) } req, err := http.NewRequest("POST", testRoute, payload) if err != nil { panic(err) } req.Header.Set("Content-Type", testCase.contentType) m.ServeHTTP(httpRecorder, req) switch httpRecorder.Code { case http.StatusNotFound: panic("Routing is messed up in test fixture (got 404): check method and path") case http.StatusInternalServerError: panic("Something bad happened on '" + testCase.description + "'") default: if testCase.shouldSucceedOnJSON && httpRecorder.Code != http.StatusOK && !testCase.shouldFailOnBind { assert.Equal(t, http.StatusOK, httpRecorder.Code) } } }) } type ( jsonTestCase struct { description string withInterface bool shouldSucceedOnJSON bool shouldFailOnBind bool payload string contentType string expected any } )