DEV Community

loading...
Cover image for 14 - Add Signin Handler

14 - Add Signin Handler

jacobsngoodwin profile image Jacob Goodwin ・7 min read

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!

Account Overview - Signin

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
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
    })
}
Enter fullscreen mode Exit fullscreen mode

We do the following in this handler method.

  1. Bind data to signinReq using the helper bind_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 for bind_data (turns out this tutorial is flawed 😂).
  2. If we do have valid data, we create a partially filled *model.User from the request's email and password fields.
  3. Extract the request context from the gin context using c.Request.Context().
  4. Reach out to the UserService.Signin method and handle any errors. If there is any error, we use our apperrors.Status() function to determine is the error is among those defined in our custom apperrors.
  5. 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.

  1. 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.
  2. Error returned by UserService.Signin
  3. Successful call to TokenService.NewPairFromUser (which means a successful handler result).
  4. 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
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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")
    })
Enter fullscreen mode Exit fullscreen mode

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)
    })
Enter fullscreen mode Exit fullscreen mode

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...)
    })
Enter fullscreen mode Exit fullscreen mode

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...)
    })
Enter fullscreen mode Exit fullscreen mode

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!

Discussion (0)

pic
Editor guide