334 lines
12 KiB
Go
334 lines
12 KiB
Go
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
|
|
}
|