package jsonclient_test import ( "errors" "net" "net/http" "testing" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" "github.com/onsi/gomega/ghttp" "github.com/nicholas-fedor/shoutrrr/pkg/util/jsonclient" ) func TestJSONClient(t *testing.T) { gomega.RegisterFailHandler(ginkgo.Fail) ginkgo.RunSpecs(t, "JSONClient Suite") } var _ = ginkgo.Describe("JSONClient", func() { var server *ghttp.Server var client jsonclient.Client ginkgo.BeforeEach(func() { server = ghttp.NewServer() client = jsonclient.NewClient() }) ginkgo.When("the server returns an invalid JSON response", func() { ginkgo.It("should return an error", func() { server.AppendHandlers(ghttp.RespondWith(http.StatusOK, "invalid json")) res := &mockResponse{} err := client.Get(server.URL(), res) gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) gomega.Expect(err). To(gomega.MatchError("invalid character 'i' looking for beginning of value")) gomega.Expect(res.Status).To(gomega.BeEmpty()) }) }) ginkgo.When("the server returns an empty response", func() { ginkgo.It("should return an error", func() { server.AppendHandlers(ghttp.RespondWith(http.StatusOK, nil)) res := &mockResponse{} err := client.Get(server.URL(), res) gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) gomega.Expect(err).To(gomega.MatchError("unexpected end of JSON input")) gomega.Expect(res.Status).To(gomega.BeEmpty()) }) }) ginkgo.It("should deserialize GET response", func() { server.AppendHandlers( ghttp.RespondWithJSONEncoded(http.StatusOK, mockResponse{Status: "OK"}), ) res := &mockResponse{} err := client.Get(server.URL(), res) gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(res.Status).To(gomega.Equal("OK")) }) ginkgo.Describe("Top-level Functions", func() { ginkgo.It("should handle GET via DefaultClient", func() { server.AppendHandlers( ghttp.RespondWithJSONEncoded(http.StatusOK, mockResponse{Status: "Default OK"}), ) res := &mockResponse{} err := jsonclient.Get(server.URL(), res) gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(res.Status).To(gomega.Equal("Default OK")) }) ginkgo.It("should handle POST via DefaultClient", func() { req := &mockRequest{Number: 10} res := &mockResponse{} server.AppendHandlers(ghttp.CombineHandlers( ghttp.VerifyRequest("POST", "/"), ghttp.VerifyJSONRepresenting(&req), ghttp.RespondWithJSONEncoded(http.StatusOK, mockResponse{Status: "Default POST"})), ) err := jsonclient.Post(server.URL(), req, res) gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(res.Status).To(gomega.Equal("Default POST")) }) }) ginkgo.Describe("POST", func() { ginkgo.It("should de-/serialize request and response", func() { req := &mockRequest{Number: 5} res := &mockResponse{} server.AppendHandlers(ghttp.CombineHandlers( ghttp.VerifyRequest("POST", "/"), ghttp.VerifyJSONRepresenting(&req), ghttp.RespondWithJSONEncoded( http.StatusOK, &mockResponse{Status: "That's Numberwang!"}, ), )) err := client.Post(server.URL(), req, res) gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(res.Status).To(gomega.Equal("That's Numberwang!")) }) ginkgo.It("should return error on error status responses", func() { server.AppendHandlers(ghttp.RespondWith(http.StatusNotFound, "Not found!")) err := client.Post(server.URL(), &mockRequest{}, &mockResponse{}) gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) gomega.Expect(err).To(gomega.MatchError("got unexpected HTTP status: 404 Not Found")) }) ginkgo.It("should return error on invalid request", func() { server.AppendHandlers(ghttp.VerifyRequest("POST", "/")) err := client.Post(server.URL(), func() {}, &mockResponse{}) gomega.Expect(server.ReceivedRequests()).Should(gomega.BeEmpty()) gomega.Expect(err). To(gomega.MatchError("marshaling request to JSON: json: unsupported type: func()")) }) ginkgo.It("should return error on invalid response type", func() { res := &mockResponse{Status: "cool skirt"} server.AppendHandlers(ghttp.CombineHandlers( ghttp.VerifyRequest("POST", "/"), ghttp.RespondWithJSONEncoded(http.StatusOK, res)), ) err := client.Post(server.URL(), nil, &[]bool{}) gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) gomega.Expect(err). To(gomega.MatchError("json: cannot unmarshal object into Go value of type []bool")) gomega.Expect(jsonclient.ErrorBody(err)).To(gomega.MatchJSON(`{"Status":"cool skirt"}`)) }) ginkgo.It("should handle string request without marshaling", func() { rawJSON := `{"Number": 42}` res := &mockResponse{} server.AppendHandlers(ghttp.CombineHandlers( ghttp.VerifyRequest("POST", "/"), ghttp.VerifyBody([]byte(rawJSON)), ghttp.RespondWithJSONEncoded(http.StatusOK, mockResponse{Status: "String Worked"})), ) err := client.Post(server.URL(), rawJSON, res) gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(res.Status).To(gomega.Equal("String Worked")) }) ginkgo.It("should return error when NewRequest fails", func() { err := client.Post("://invalid-url", &mockRequest{}, &mockResponse{}) gomega.Expect(err).To(gomega.HaveOccurred()) gomega.Expect(err.Error()). To(gomega.ContainSubstring("creating POST request for \"://invalid-url\": parse \"://invalid-url\": missing protocol scheme")) }) ginkgo.It("should return error when http.Client.Do fails", func() { brokenClient := jsonclient.NewWithHTTPClient(&http.Client{ Transport: &http.Transport{ Dial: func(_, _ string) (net.Conn, error) { return nil, errors.New("forced network error") }, }, }) err := brokenClient.Post(server.URL(), &mockRequest{}, &mockResponse{}) gomega.Expect(err).To(gomega.HaveOccurred()) gomega.Expect(err.Error()). To(gomega.ContainSubstring("sending POST request to \"" + server.URL() + "\": Post \"" + server.URL() + "\": forced network error")) }) ginkgo.It("should set multiple custom headers in request", func() { customClient := jsonclient.NewWithHTTPClient(&http.Client{}) headers := customClient.Headers() headers.Set("X-Custom-Header", "CustomValue") headers.Set("X-Another-Header", "AnotherValue") req := &mockRequest{Number: 99} res := &mockResponse{} server.AppendHandlers(ghttp.CombineHandlers( ghttp.VerifyRequest("POST", "/"), ghttp.VerifyHeader(http.Header{ "Content-Type": []string{jsonclient.ContentType}, "X-Custom-Header": []string{"CustomValue"}, "X-Another-Header": []string{"AnotherValue"}, }), ghttp.VerifyJSONRepresenting(&req), ghttp.RespondWithJSONEncoded( http.StatusOK, mockResponse{Status: "Headers Worked"}, ), )) err := customClient.Post(server.URL(), req, res) gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(res.Status).To(gomega.Equal("Headers Worked")) }) }) ginkgo.Describe("Headers", func() { ginkgo.It("should return default headers with Content-Type", func() { headers := client.Headers() gomega.Expect(headers.Get("Content-Type")).To(gomega.Equal(jsonclient.ContentType)) }) }) ginkgo.Describe("ErrorResponse", func() { ginkgo.It("should return false for non-jsonclient.Error", func() { res := &mockResponse{} result := client.ErrorResponse(errors.New("generic error"), res) gomega.Expect(result).To(gomega.BeFalse()) gomega.Expect(res.Status).To(gomega.BeEmpty()) }) ginkgo.It("should populate response from jsonclient.Error body", func() { res := &mockResponse{} jsonErr := jsonclient.Error{ StatusCode: http.StatusBadRequest, Body: `{"Status": "Bad Request"}`, } result := client.ErrorResponse(jsonErr, res) gomega.Expect(result).To(gomega.BeTrue()) gomega.Expect(res.Status).To(gomega.Equal("Bad Request")) }) ginkgo.It("should return false for invalid JSON in error body", func() { res := &mockResponse{} jsonErr := jsonclient.Error{ StatusCode: http.StatusBadRequest, Body: "not json", } result := client.ErrorResponse(jsonErr, res) gomega.Expect(result).To(gomega.BeFalse()) gomega.Expect(res.Status).To(gomega.BeEmpty()) }) }) ginkgo.Describe("Edge Cases", func() { ginkgo.It("should handle network failure in Get", func() { res := &mockResponse{} err := client.Get("http://127.0.0.1:54321", res) gomega.Expect(err).To(gomega.HaveOccurred()) gomega.Expect(err.Error()).To(gomega.ContainSubstring("dial tcp")) gomega.Expect(res.Status).To(gomega.BeEmpty()) }) ginkgo.It("should handle invalid JSON with success status", func() { server.AppendHandlers(ghttp.RespondWith(http.StatusOK, "bad json")) res := &mockResponse{} err := client.Get(server.URL(), res) gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) gomega.Expect(err). To(gomega.MatchError("invalid character 'b' looking for beginning of value")) gomega.Expect(res.Status).To(gomega.BeEmpty()) }) ginkgo.It("should handle nil body in error response", func() { brokenClient := jsonclient.NewWithHTTPClient(&http.Client{ Transport: &mockTransport{ response: &http.Response{ StatusCode: http.StatusBadRequest, Status: "400 Bad Request", Body: &failingReader{}, Header: make(http.Header), }, }, }) res := &mockResponse{} err := brokenClient.Get(server.URL(), res) gomega.Expect(err).To(gomega.HaveOccurred()) gomega.Expect(err.Error()). To(gomega.ContainSubstring("got unexpected HTTP status: 400 Bad Request")) gomega.Expect(jsonclient.ErrorBody(err)).To(gomega.Equal("")) }) }) ginkgo.AfterEach(func() { server.Close() }) }) var _ = ginkgo.Describe("Error", func() { ginkgo.When("no internal error has been set", func() { ginkgo.It("should return a generic message with status code", func() { errorWithNoError := jsonclient.Error{StatusCode: http.StatusEarlyHints} gomega.Expect(errorWithNoError.String()).To(gomega.Equal("unknown error (HTTP 103)")) }) }) ginkgo.Describe("ErrorBody", func() { ginkgo.When("passed a non-json error", func() { ginkgo.It("should return an empty string", func() { gomega.Expect(jsonclient.ErrorBody(errors.New("unrelated error"))). To(gomega.BeEmpty()) }) }) ginkgo.When("passed a jsonclient.Error", func() { ginkgo.It("should return the request body from that error", func() { errorBody := `{"error": "bad user"}` jsonErr := jsonclient.Error{Body: errorBody} gomega.Expect(jsonclient.ErrorBody(jsonErr)).To(gomega.MatchJSON(errorBody)) }) }) }) }) type mockResponse struct { Status string } type mockRequest struct { Number int } // mockTransport returns a predefined response. type mockTransport struct { response *http.Response } func (mt *mockTransport) RoundTrip(*http.Request) (*http.Response, error) { return mt.response, nil } // failingReader simulates an io.Reader that fails on Read. type failingReader struct{} func (fr *failingReader) Read([]byte) (int, error) { return 0, errors.New("simulated read failure") } func (fr *failingReader) Close() error { return nil }