Today's tutorial will be the first of 2 parts on implementing sign in functionality. We'll follow many of the same patterns we used to sign up users, implementing some new methods in the process!
As seen in the diagram, we'll start with the handler
layer signin in method, and next time flush out the details of all the other layers, some of which functionality we've already implemented!
As always, checkout the repo on Github with all of the code for this tutorial, including a branch for each lesson!
If you prefer video, check out the video version below!
Add Signin to UserService Interface
When a user signs in, we want to accept a user's email and password, just like we would when a user signs up. While the implementation details of signing in and signing up differ, the method signatures are the same. Let's add Signin
to the UserService
interface in ~/model/interfaces.go
. As with Signin
, we'll pass a partially filled *model.User
, and return an error if signing in fails. If signing in is successful, then the user passed to the method will be modified to contain all user details.
// UserService defines methods the handler layer expects
// any service it interacts with to implement
type UserService interface {
Get(ctx context.Context, uid uuid.UUID) (*User, error)
Signup(ctx context.Context, u *User) error
Signin(ctx context.Context, u *User) error
}
This update will break our mockUserService
and service-layer UserService
as they do not implement this method. Let's add an incomplete implementation of Signup
in ~/service/user_service.go
, which we'll complete in the next tutorial.
// Signin reaches our to a UserRepository check if the user exists
// and then compares the supplied password with the provided password
// if a valid email/password combo is provided, u will hold all
// available user fields
func (s *userService) Signin(ctx context.Context, u *model.User) error {
panic("Not implemented")
}
Add Mock Signin Method
Before adding our handler logic and unit test, let's update our ~/model/mocks/user_service.go
so that with a Signin
method. See previous tutorials on testing and testify to learn more!
func (m *MockUserService) Signin(ctx context.Context, u *model.User) error {
ret := m.Called(ctx, u)
var r0 error
if ret.Get(0) != nil {
r0 = ret.Get(0).(error)
}
return r0
}
Add Handler
Since we'll be adding a lot of code for this handler, let's copy the Signin
handler from ~/handler/handler.go
into its own file, ~/handler/signin.go
. We'll also update the contents of this method as follows.
package handler
// IMPORTS OMITTED
// signinReq is not exported
type signinReq struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,gte=6,lte=30"`
}
// Signin used to authenticate extant user
func (h *Handler) Signin(c *gin.Context) {
var req signinReq
if ok := bindData(c, &req); !ok {
return
}
u := &model.User{
Email: req.Email,
Password: req.Password,
}
ctx := c.Request.Context()
err := h.UserService.Signin(ctx, u)
if err != nil {
log.Printf("Failed to sign in user: %v\n", err.Error())
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
tokens, err := h.TokenService.NewPairFromUser(ctx, u, "")
if err != nil {
log.Printf("Failed to create tokens for user: %v\n", err.Error())
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
c.JSON(http.StatusOK, gin.H{
"tokens": tokens,
})
}
We do the following in this handler method.
- Bind data to
signinReq
using the helperbind_data
function we previously created. This function sends a "bad request" HTTP status 400 error if the data cannot be bound or fails validation. In reality, it would be wise to create a separate test forbind_data
(turns out this tutorial is flawed 😂). - If we do have valid data, we create a partially filled
*model.User
from the request's email and password fields. - Extract the request context from the gin context using
c.Request.Context()
. - Reach out to the
UserService.Signin
method and handle any errors. If there is any error, we use ourapperrors.Status()
function to determine is the error is among those defined in our customapperrors
. - If the user signs in successfully, we'll send a new token pair, just as we do when signing up. We also make sure to handle any errors.
Testing Signin
Let's create a new file for this test, ~/handler/signin_test.go
. In this file we'll test the following.
- Invalid request data - Although we won't test all validation error combinations, we will test one case to make sure that we receive the proper error JSON response for a bad request.
- Error returned by UserService.Signin
- Successful call to
TokenService.NewPairFromUser
(which means a successful handler result). - Error from
TokenService.NewPairFromUser
. Again, we just want to make sure the handler sends the proper HTTP response in this case.
Test Body and Setup
We'll instantiate our mock services, gin engine/router, and handler in the setup portion of our test (before the t.Run
blocks).
However, I decided that it will be clearer to define our mock method responses, i.e. mock.On(...)
, inside of the individual t.Run
blocks instead of in the setup. I did this because I ended up creating so many mock method call definitions and variables in the setup that it was hard to understand what variables corresponded to which test cases.
package handler
// IMPORTS OMITTED
func TestSignin(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
// setup mock services, gin engine/router, handler layer
mockUserService := new(mocks.MockUserService)
mockTokenService := new(mocks.MockTokenService)
router := gin.Default()
NewHandler(&Config{
R: router,
UserService: mockUserService,
TokenService: mockTokenService,
})
// Tests will be added here below
// ...
}
Bad Request Data Case
In this case we create a JSON request body with an invalid email address and send this to our handler which we instantiated in the setup portion of the test. We assert that an HTTP Status of 400, http.StatusBadRequest
, is sent and that the mockUserService.Signin
and mockTokenService.NewPairFromUser
methods don't get called as the handler should return before reaching these methods.
t.Run("Bad request data", func(t *testing.T) {
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// create a request body with invalid fields
reqBody, err := json.Marshal(gin.H{
"email": "notanemail",
"password": "short",
})
assert.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, "/signin", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusBadRequest, rr.Code)
mockUserService.AssertNotCalled(t, "Signin")
mockTokenService.AssertNotCalled(t, "NewTokensFromUser")
})
Error from UserService.Signin Case
In this case, we define a mock response for Signin
which returns an error. We create an example of such an error and store it in mockError
. We then assert that this error is relayed and sent as JSON to the user with HTTP status 401, http.StatusUnauthorized
, and the assert that mockUserService.Signin
method is called, but that mockTokenService.NewPairFromUser
is not called.
t.Run("Error Returned from UserService.Signin", func(t *testing.T) {
email := "bob@bob.com"
password := "pwdoesnotmatch123"
mockUSArgs := mock.Arguments{
mock.AnythingOfType("*context.emptyCtx"),
&model.User{Email: email, Password: password},
}
// so we can check for a known status code
mockError := apperrors.NewAuthorization("invalid email/password combo")
mockUserService.On("Signin", mockUSArgs...).Return(mockError)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// create a request body with valid fields
reqBody, err := json.Marshal(gin.H{
"email": email,
"password": password,
})
assert.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, "/signin", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
mockUserService.AssertCalled(t, "Signin", mockUSArgs...)
mockTokenService.AssertNotCalled(t, "NewTokensFromUser")
assert.Equal(t, http.StatusUnauthorized, rr.Code)
})
Success Case
In this case, we respond to mockUserService.Signin
with no error (nil
). We also mock a valid token response from mockTokenService.NewPairFromUser
. We assert that both of the above methods are called and that a valid HTTP response with HTTP status 200, http.StatusOK
, is returned.
t.Run("Successful Token Creation", func(t *testing.T) {
email := "bob@bob.com"
password := "pwworksgreat123"
mockUSArgs := mock.Arguments{
mock.AnythingOfType("*context.emptyCtx"),
&model.User{Email: email, Password: password},
}
mockUserService.On("Signin", mockUSArgs...).Return(nil)
mockTSArgs := mock.Arguments{
mock.AnythingOfType("*context.emptyCtx"),
&model.User{Email: email, Password: password},
"",
}
mockTokenPair := &model.TokenPair{
IDToken: "idToken",
RefreshToken: "refreshToken",
}
mockTokenService.On("NewPairFromUser", mockTSArgs...).Return(mockTokenPair, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// create a request body with valid fields
reqBody, err := json.Marshal(gin.H{
"email": email,
"password": password,
})
assert.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, "/signin", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"tokens": mockTokenPair,
})
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockUserService.AssertCalled(t, "Signin", mockUSArgs...)
mockTokenService.AssertCalled(t, "NewPairFromUser", mockTSArgs...)
})
Error from TokenService.NewPairFromUser Case
In this case, we want the mockUserService.Signin
method to be called successfully, but want mockTokenService.NewPairFromUser
to produce an error. We then expect an HTTP 500, http.StatusInternalServerError
, as a response.
t.Run("Failed Token Creation", func(t *testing.T) {
email := "cannotproducetoken@bob.com"
password := "cannotproducetoken"
mockUSArgs := mock.Arguments{
mock.AnythingOfType("*context.emptyCtx"),
&model.User{Email: email, Password: password},
}
mockUserService.On("Signin", mockUSArgs...).Return(nil)
mockTSArgs := mock.Arguments{
mock.AnythingOfType("*context.emptyCtx"),
&model.User{Email: email, Password: password},
"",
}
mockError := apperrors.NewInternal()
mockTokenService.On("NewPairFromUser", mockTSArgs...).Return(nil, mockError)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// create a request body with valid fields
reqBody, err := json.Marshal(gin.H{
"email": email,
"password": password,
})
assert.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, "/signin", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockUserService.AssertCalled(t, "Signin", mockUSArgs...)
mockTokenService.AssertCalled(t, "NewPairFromUser", mockTSArgs...)
})
Conclusion
That's all for today, chicos! Next time, we'll write the concrete implementation of UserService.Signin
as well as the needed UserRepository
methods to get a user from the database and verify their entered password.
¡Chau, hasta luego!
Top comments (0)