From 28a3f81b743f03e9ad008382e93ea9578d2bd5fe Mon Sep 17 00:00:00 2001 From: Brandur Date: Sun, 9 Mar 2025 22:48:29 -0700 Subject: [PATCH] Add `apitest` package for testing service handlers This is a small augmentation to the API framework intended to provide a package for testing service handlers. Service handlers are normal functions and can be invoked as normal functions, but `apitest` provides some extra niceties: * Request structs are validated and an API error is emitted in case they're invalid. * Response structs are validated and an error returned in case they're invalid. We may add more things down the road as they're needed as well. For example, `apitest` might inject a valid looking IP address into context to simulate a more realistic request for purposes that need an IP like rate limiting. (Nothing we're doing needs this yet, so I didn't bother.) Sample usage: endpoint := &testEndpoint{} resp, err := apitest.InvokeHandler(ctx, endpoint.Execute, &testRequest{ReqField: "string"}) require.NoError(t, err) --- apitest/apitest.go | 41 +++++++++++++++++++++++++++++++ apitest/apitest_test.go | 53 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 apitest/apitest.go create mode 100644 apitest/apitest_test.go diff --git a/apitest/apitest.go b/apitest/apitest.go new file mode 100644 index 0000000..45f52a0 --- /dev/null +++ b/apitest/apitest.go @@ -0,0 +1,41 @@ +package apitest + +import ( + "context" + "fmt" + + "github.com/riverqueue/apiframe/apierror" + "github.com/riverqueue/apiframe/internal/validate" +) + +// InvokeHandler invokes a service handler and returns its results. +// +// Service handlers are normal functions and can be invoked directly, but it's +// preferable to invoke them with this function because a few extra niceties are +// observed that are normally only available from the API framework: +// +// - Incoming request structs are validated and an API error is emitted in case +// they're invalid (any `validate` tags are checked). +// - Outgoing response structs are validated. +// +// Sample invocation: +// +// endpoint := &testEndpoint{} +// resp, err := apitest.InvokeHandler(ctx, endpoint.Execute, &testRequest{ReqField: "string"}) +// require.NoError(t, err) +func InvokeHandler[TReq any, TResp any](ctx context.Context, handler func(context.Context, *TReq) (*TResp, error), req *TReq) (*TResp, error) { + if err := validate.StructCtx(ctx, req); err != nil { + return nil, apierror.NewBadRequest(validate.PublicFacingMessage(err)) + } + + resp, err := handler(ctx, req) + if err != nil { + return nil, err + } + + if err := validate.StructCtx(ctx, resp); err != nil { + return nil, fmt.Errorf("apitest: error validating response API resource: %w", err) + } + + return resp, nil +} diff --git a/apitest/apitest_test.go b/apitest/apitest_test.go new file mode 100644 index 0000000..58a7714 --- /dev/null +++ b/apitest/apitest_test.go @@ -0,0 +1,53 @@ +package apitest + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/riverqueue/apiframe/apierror" +) + +func TestInvokeHandler(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + type testRequest struct { + RequiredReqField string `json:"req_field" validate:"required"` + } + type testResponse struct { + RequiredRespField string `json:"resp_field" validate:"required"` + } + + handler := func(_ context.Context, req *testRequest) (*testResponse, error) { + return &testResponse{RequiredRespField: "response value"}, nil + } + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + resp, err := InvokeHandler(ctx, handler, &testRequest{RequiredReqField: "string"}) + require.NoError(t, err) + require.Equal(t, &testResponse{RequiredRespField: "response value"}, resp) + }) + + t.Run("ValidatesRequest", func(t *testing.T) { + t.Parallel() + + _, err := InvokeHandler(ctx, handler, &testRequest{RequiredReqField: ""}) + require.Equal(t, apierror.NewBadRequestf("Field `req_field` is required."), err) + }) + + t.Run("ValidatesResponse", func(t *testing.T) { + t.Parallel() + + handler := func(_ context.Context, _ *testRequest) (*testResponse, error) { + return &testResponse{RequiredRespField: ""}, nil + } + + _, err := InvokeHandler(ctx, handler, &testRequest{RequiredReqField: "string"}) + require.EqualError(t, err, "apitest: error validating response API resource: Key: 'testResponse.resp_field' Error:Field validation for 'resp_field' failed on the 'required' tag") + }) +}