diff --git a/internal/test/apicall.go b/internal/test/apicall.go new file mode 100644 index 000000000..7a5610b48 --- /dev/null +++ b/internal/test/apicall.go @@ -0,0 +1,99 @@ +package test + +import ( + "bytes" + "context" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// APICall is a small function object for testing HTTP APIs +type APICall struct { + // http.Handler to run the call against + Handler http.Handler + + // HTTP method, e.g. http.MethodPatch + Method string + + // Request Path + Path string + + // Request body. If nil, an empty body is sent + RequestBody RequestBody + + // Request header. If nil, default header is sent + Header http.Header + + // Request context. If nil, default context is used + Context context.Context + + // Status that's expected to be received. If set to 0, the status is not checked + ExpectedStatus int + + // Validator for the response body. If set to nil, the body is not validated + ExpectedBody BodyValidator +} + +// Do performs the request as defined in the APICall struct. +// +// If any errors occur when doing the request, or any of the validators fail, t.FailNow() is called +// Note that HTTP error status is not checked if ExpectedStatus == 0 +// +// The result of the HTTP call is returned +func (a APICall) Do(t *testing.T) APICallResult { + t.Helper() + + var bodyReader io.Reader + if a.RequestBody != nil { + bodyReader = bytes.NewReader(a.RequestBody.Body()) + } + + req := httptest.NewRequest(a.Method, a.Path, bodyReader) + + if a.Context != nil { + req = req.WithContext(a.Context) + } + + req.Header = a.Header + if req.Header == nil { + req.Header = http.Header{} + } + + if a.RequestBody != nil && a.RequestBody.ContentType() != "" { + req.Header.Set("Content-Type", a.RequestBody.ContentType()) + } + respRecorder := httptest.NewRecorder() + a.Handler.ServeHTTP(respRecorder, req) + resp := respRecorder.Result() + + body, err := ioutil.ReadAll(resp.Body) + require.NoErrorf(t, err, "%s: could not read response body", a.Path) + + if a.ExpectedStatus != 0 { + assert.Equalf(t, a.ExpectedStatus, resp.StatusCode, "%s: SendHTTP failed for path", a.Path) + } + if a.ExpectedBody != nil { + err = a.ExpectedBody.Validate(body) + require.NoError(t, err, "%s: cannot validate response body", a.Path) + } + + return APICallResult{ + Body: body, + StatusCode: resp.StatusCode, + } +} + +// APICallResult holds a parsed response for an APICall +type APICallResult struct { + // Full body as read from the server + Body []byte + + // Status code returned from the server + StatusCode int +} diff --git a/internal/test/request.go b/internal/test/request.go new file mode 100644 index 000000000..893dab465 --- /dev/null +++ b/internal/test/request.go @@ -0,0 +1,23 @@ +package test + +// RequestBody is an abstract interface for defining request bodies for APICall +type RequestBody interface { + // Body returns the intended request body as a slice of bytes + Body() []byte + + // ContentType returns value for Content-Type request header + ContentType() string +} + +// JSONRequestBody is just a simple wrapper over plain string. +// +// Body is just the string converted to a slice of bytes and content type is set to application/json +type JSONRequestBody string + +func (b JSONRequestBody) Body() []byte { + return []byte(b) +} + +func (b JSONRequestBody) ContentType() string { + return "application/json" +} diff --git a/internal/test/validator.go b/internal/test/validator.go new file mode 100644 index 000000000..c568e47d9 --- /dev/null +++ b/internal/test/validator.go @@ -0,0 +1,48 @@ +package test + +import ( + "encoding/json" + "fmt" + + "github.com/google/go-cmp/cmp" +) + +// BodyValidator is an abstract interface for defining validators for response bodies +type BodyValidator interface { + // Validate returns nil if the body is valid. If the body isn't valid, a descriptive error is returned. + Validate(body []byte) error +} + +// JSONValidator is a simple validator for validating JSON responses +type JSONValidator struct { + // Content is the expected json content of the body + // + // Note that the key order of maps is arbitrary + Content string + + // IgnoreFields is a list of JSON keys that should be removed from both expected body and actual body + IgnoreFields []string +} + +func (b JSONValidator) Validate(body []byte) error { + var reply, expected interface{} + err := json.Unmarshal(body, &reply) + if err != nil { + return fmt.Errorf("json.Unmarshal failed: %s\n%w", string(body), err) + } + + err = json.Unmarshal([]byte(b.Content), &expected) + if err != nil { + return fmt.Errorf("expected JSON is invalid: %s\n%w", string(b.Content), err) + } + + dropFields(reply, b.IgnoreFields...) + dropFields(expected, b.IgnoreFields...) + + diff := cmp.Diff(expected, reply) + if diff != "" { + return fmt.Errorf("bodies don't match: %s", diff) + } + + return nil +}