From 851fc059d6a9e259528d11d98ab7360769f02fd4 Mon Sep 17 00:00:00 2001 From: Felipe Luz Oliveira <75860661+felipebrsk@users.noreply.github.com> Date: Wed, 6 Nov 2024 19:54:27 -0300 Subject: [PATCH] tests: adding tests for comments and refactoring responsibles --- internal/adapters/api/auth_handler.go | 2 +- internal/adapters/api/comment_handler.go | 61 ++---- internal/adapters/api/handler_interface.go | 10 +- internal/ports/comment_repository.go | 7 + internal/usecases/comment_service.go | 42 +++- tests/data/mocks/has_dummy_comment.go | 45 ++++ tests/feature/api/auth_handler_test.go | 25 ++- tests/feature/api/comment_handler_test.go | 227 +++++++++++++++++++++ 8 files changed, 363 insertions(+), 56 deletions(-) create mode 100644 tests/data/mocks/has_dummy_comment.go create mode 100644 tests/feature/api/comment_handler_test.go diff --git a/internal/adapters/api/auth_handler.go b/internal/adapters/api/auth_handler.go index 6d30f8a..23f0e4f 100644 --- a/internal/adapters/api/auth_handler.go +++ b/internal/adapters/api/auth_handler.go @@ -53,7 +53,7 @@ func (h *AuthHandler) Register(c *gin.Context) { return } - c.JSON(http.StatusOK, response) + c.JSON(http.StatusCreated, response) } func (h *AuthHandler) Logout(c *gin.Context) { diff --git a/internal/adapters/api/comment_handler.go b/internal/adapters/api/comment_handler.go index 2dcc34b..5e463af 100644 --- a/internal/adapters/api/comment_handler.go +++ b/internal/adapters/api/comment_handler.go @@ -1,12 +1,9 @@ package api import ( - "gcstatus/internal/domain" - "gcstatus/internal/errors" - "gcstatus/internal/resources" + "gcstatus/internal/ports" "gcstatus/internal/usecases" "gcstatus/internal/utils" - "gcstatus/pkg/s3" "net/http" "strconv" @@ -19,52 +16,33 @@ type CommentHandler struct { } func NewCommentHandler( - userServuce *usecases.UserService, + userService *usecases.UserService, commentService *usecases.CommentService, ) *CommentHandler { return &CommentHandler{ - userService: userServuce, + userService: userService, commentService: commentService, } } func (h *CommentHandler) Create(c *gin.Context) { - user, err := utils.Auth(c, h.userService.GetUserByID) - if err != nil { - RespondWithError(c, http.StatusUnauthorized, "Unauthorized: "+err.Error()) - return - } - - var request struct { - ParentID *uint `json:"parent_id"` - Comment string `json:"comment" binding:"required"` - CommentableID uint `json:"commentable_id" binding:"required"` - CommentableType string `json:"commentable_type" binding:"required"` - } + var request ports.CommentStorePayload if err := c.ShouldBindJSON(&request); err != nil { RespondWithError(c, http.StatusUnprocessableEntity, "Invalid request data") return } - commentable := domain.Commentable{ - UserID: user.ID, - Comment: request.Comment, - CommentableID: request.CommentableID, - CommentableType: request.CommentableType, - ParentID: request.ParentID, - } - - comment, err := h.commentService.Create(commentable) + user, err := utils.Auth(c, h.userService.GetUserByID) if err != nil { - RespondWithError(c, http.StatusInternalServerError, "Failed to create comment.") + RespondWithError(c, http.StatusUnauthorized, "Failed to create comment: could not authenticate user.") return } - transformedComment := resources.TransformCommentable(*comment, s3.GlobalS3Client, user.ID) - - response := resources.Response{ - Data: transformedComment, + response, httpErr := h.commentService.Create(user, request) + if httpErr != nil { + RespondWithError(c, httpErr.Code, httpErr.Error()) + return } c.JSON(http.StatusCreated, response) @@ -72,26 +50,23 @@ func (h *CommentHandler) Create(c *gin.Context) { func (h *CommentHandler) Delete(c *gin.Context) { commentIDStr := c.Param("id") - user, err := utils.Auth(c, h.userService.GetUserByID) + commentID, err := strconv.ParseUint(commentIDStr, 10, 32) if err != nil { - RespondWithError(c, http.StatusUnauthorized, "Unauthorized: "+err.Error()) + RespondWithError(c, http.StatusBadRequest, "Invalid comment ID: "+err.Error()) return } - commentID, err := strconv.ParseUint(commentIDStr, 10, 32) + user, err := utils.Auth(c, h.userService.GetUserByID) if err != nil { - RespondWithError(c, http.StatusBadRequest, "Invalid comment ID: "+err.Error()) + RespondWithError(c, http.StatusUnauthorized, "Unauthorized: "+err.Error()) return } - if err := h.commentService.Delete(uint(commentID), user.ID); err != nil { - if httpErr, ok := err.(*errors.HttpError); ok { - RespondWithError(c, httpErr.Code, httpErr.Error()) - } else { - RespondWithError(c, http.StatusInternalServerError, "Failed to delete comment: "+err.Error()) - } + response, httpErr := h.commentService.Delete(uint(commentID), user.ID) + if httpErr != nil { + RespondWithError(c, httpErr.Code, httpErr.Error()) return } - c.JSON(http.StatusOK, gin.H{"message": "Your comment was successfully removed!"}) + c.JSON(http.StatusOK, response) } diff --git a/internal/adapters/api/handler_interface.go b/internal/adapters/api/handler_interface.go index 4fb1cc0..d58e7de 100644 --- a/internal/adapters/api/handler_interface.go +++ b/internal/adapters/api/handler_interface.go @@ -1,8 +1,14 @@ package api -import "github.com/gin-gonic/gin" +import ( + "gcstatus/internal/resources" + + "github.com/gin-gonic/gin" +) // Helper to respond with error func RespondWithError(c *gin.Context, statusCode int, message string) { - c.JSON(statusCode, gin.H{"message": message}) + c.JSON(statusCode, resources.Response{ + Data: gin.H{"message": message}, + }) } diff --git a/internal/ports/comment_repository.go b/internal/ports/comment_repository.go index 0696c01..6d2027a 100644 --- a/internal/ports/comment_repository.go +++ b/internal/ports/comment_repository.go @@ -2,6 +2,13 @@ package ports import "gcstatus/internal/domain" +type CommentStorePayload struct { + ParentID *uint `json:"parent_id"` + Comment string `json:"comment" binding:"required"` + CommentableID uint `json:"commentable_id" binding:"required"` + CommentableType string `json:"commentable_type" binding:"required"` +} + type CommentRepository interface { FindByID(id uint) (*domain.Commentable, error) Create(commentable domain.Commentable) (*domain.Commentable, error) diff --git a/internal/usecases/comment_service.go b/internal/usecases/comment_service.go index 0851576..a634f42 100644 --- a/internal/usecases/comment_service.go +++ b/internal/usecases/comment_service.go @@ -4,7 +4,12 @@ import ( "gcstatus/internal/domain" "gcstatus/internal/errors" "gcstatus/internal/ports" + "gcstatus/internal/resources" + "gcstatus/pkg/s3" + "log" "net/http" + + "github.com/gin-gonic/gin" ) type CommentService struct { @@ -15,23 +20,46 @@ func NewCommentService(repo ports.CommentRepository) *CommentService { return &CommentService{repo: repo} } -func (h *CommentService) Create(commentable domain.Commentable) (*domain.Commentable, error) { - return h.repo.Create(commentable) +func (h *CommentService) Create(user *domain.User, payload ports.CommentStorePayload) (resources.Response, *errors.HttpError) { + commentable := domain.Commentable{ + UserID: user.ID, + Comment: payload.Comment, + CommentableID: payload.CommentableID, + CommentableType: payload.CommentableType, + ParentID: payload.ParentID, + } + + comment, err := h.repo.Create(commentable) + if err != nil { + log.Printf("failed to create comment: %+v.\n err: %+v", commentable, err) + return resources.Response{}, errors.NewHttpError(http.StatusInternalServerError, "Failed to create comment. Please, try again later.") + } + + transformedComment := resources.TransformCommentable(*comment, s3.GlobalS3Client, user.ID) + + response := resources.Response{ + Data: transformedComment, + } + + return response, nil } -func (h *CommentService) Delete(id uint, userID uint) error { +func (h *CommentService) Delete(id uint, userID uint) (resources.Response, *errors.HttpError) { comment, err := h.repo.FindByID(id) if err != nil { - return err + return resources.Response{}, errors.NewHttpError(http.StatusNotFound, "Could not found the given comment!") } if comment.UserID != userID { - return errors.NewHttpError(http.StatusForbidden, "This comment does not belongs to you user!") + return resources.Response{}, errors.NewHttpError(http.StatusForbidden, "This comment does not belongs to you user!") } if err := h.repo.Delete(id); err != nil { - return err + log.Printf("failed to delete comment: %+v.\n err: %+v", comment, err) + return resources.Response{}, errors.NewHttpError(http.StatusInternalServerError, "We could not delete the given comment. Please, try again later.") } - return nil + return resources.Response{ + Data: gin.H{"message": "Your comment was successfully removed!"}, + }, nil } diff --git a/tests/data/mocks/has_dummy_comment.go b/tests/data/mocks/has_dummy_comment.go new file mode 100644 index 0000000..fa46390 --- /dev/null +++ b/tests/data/mocks/has_dummy_comment.go @@ -0,0 +1,45 @@ +package test_mocks + +import ( + "gcstatus/internal/domain" + "testing" + + "gorm.io/gorm" +) + +func CreateDummyComment(t *testing.T, dbConn *gorm.DB, overrides *domain.Commentable) (*domain.Commentable, error) { + + defaultComment := domain.Commentable{ + Comment: "Testing comment", + CommentableID: 1, + CommentableType: "games", + } + + if overrides != nil { + if overrides.Comment != "" { + defaultComment.Comment = overrides.Comment + } + if overrides.CommentableID != 0 { + defaultComment.CommentableID = overrides.CommentableID + } + if overrides.CommentableType != "" { + defaultComment.CommentableType = overrides.CommentableType + } + if overrides.User.ID != 0 { + defaultComment.User = overrides.User + } else { + user, err := CreateDummyUser(t, dbConn, &overrides.User) + if err != nil { + t.Fatalf("failed to create dummy user for comment: %+v", err) + } + + defaultComment.User = *user + } + } + + if err := dbConn.Create(&defaultComment).Error; err != nil { + return nil, err + } + + return &defaultComment, nil +} diff --git a/tests/feature/api/auth_handler_test.go b/tests/feature/api/auth_handler_test.go index d9f81f1..27d3df8 100644 --- a/tests/feature/api/auth_handler_test.go +++ b/tests/feature/api/auth_handler_test.go @@ -169,8 +169,11 @@ func TestAuthHandler_Me(t *testing.T) { } } } else { - assert.Contains(t, responseBody, "message") - assert.Equal(t, tc.expectResponse["message"], responseBody["message"]) + if data, exists := tc.expectResponse["data"]; exists { + if message, exists := data.(map[string]any)["message"]; exists { + assert.Equal(t, message, responseBody["message"], "unexpected response message") + } + } } }) } @@ -238,7 +241,7 @@ func TestAuthHandler_Register(t *testing.T) { "password": "Password@123", "password_confirmation": "Password@123" }`, - expectCode: http.StatusOK, + expectCode: http.StatusCreated, expectResponse: "User registered successfully", }, "password mismatch": { @@ -315,6 +318,22 @@ func TestAuthHandler_Register(t *testing.T) { assert.Equal(t, tc.expectCode, w.Code) assert.Contains(t, w.Body.String(), tc.expectResponse) + + if w.Code == http.StatusCreated { + var payloadData map[string]any + json.Unmarshal([]byte(tc.payload), &payloadData) + + email := payloadData["email"].(string) + + var createdUser domain.User + err := dbConn.Where("email = ?", email).First(&createdUser).Error + assert.NoError(t, err, "User record should exist in the database") + assert.Equal(t, payloadData["name"], createdUser.Name) + assert.Equal(t, payloadData["email"], createdUser.Email) + assert.Equal(t, payloadData["nickname"], createdUser.Nickname) + assert.Equal(t, payloadData["birthdate"], createdUser.Birthdate.UTC().Format("2006-01-02")) + assert.True(t, utils.IsHashEqualsValue(createdUser.Password, payloadData["password"].(string))) + } }) } diff --git a/tests/feature/api/comment_handler_test.go b/tests/feature/api/comment_handler_test.go new file mode 100644 index 0000000..ecc48ea --- /dev/null +++ b/tests/feature/api/comment_handler_test.go @@ -0,0 +1,227 @@ +package feature_tests + +import ( + "encoding/json" + "fmt" + "gcstatus/internal/adapters/api" + "gcstatus/internal/adapters/db" + "gcstatus/internal/domain" + "gcstatus/internal/usecases" + test_mocks "gcstatus/tests/data/mocks" + testutils "gcstatus/tests/utils" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "gorm.io/gorm" +) + +var commentTruncateModels = []any{ + &domain.User{}, + &domain.Wallet{}, + &domain.Profile{}, + &domain.Commentable{}, +} + +func setupCommentHandler(dbConn *gorm.DB) *api.CommentHandler { + userService := usecases.NewUserService(db.NewUserRepositoryMySQL(dbConn)) + commentService := usecases.NewCommentService(db.NewCommentRepositoryMySQL(dbConn)) + return api.NewCommentHandler(userService, commentService) +} + +func TestCommentHandler_Create(t *testing.T) { + commentHandler := setupCommentHandler(dbConn) + + tests := map[string]struct { + payload string + expectCode int + expectResponse map[string]any + }{ + "valid comment payload": { + payload: fmt.Sprintf(`{ + "commentable_id": %d, + "commentable_type": "games", + "comment": "Just testing comment" + }`, uint(1)), + expectCode: http.StatusCreated, + expectResponse: map[string]any{ + "comment": "Just testing comment", + "commentable_id": float64(1), + "commentable_type": "games", + }, + }, + "invalid payload": { + payload: `{}`, + expectCode: http.StatusUnprocessableEntity, + expectResponse: map[string]any{"message": "Invalid request data"}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodPost, "/comments", strings.NewReader(tc.payload)) + req.Header.Set("Content-Type", "application/json") + user, err := test_mocks.ActingAsDummyUser(t, dbConn, &domain.User{}, req, env) + if err != nil { + t.Fatalf("failed to create dummy user: %+v", err) + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + + commentHandler.Create(c) + + assert.Equal(t, tc.expectCode, w.Code) + var responseBody map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &responseBody); err != nil { + t.Fatalf("failed to parse JSON response: %+v", err) + } + + if w.Code == http.StatusCreated { + data, ok := responseBody["data"].(map[string]any) + if assert.True(t, ok, "response should contain 'data' field") { + for key, expectedValue := range tc.expectResponse { + if key == "comment" { // the only value from response + actualValue := data[key] + + assert.Equal(t, expectedValue, actualValue, "unexpected value for '%s'", key) + } + } + } + + var createdComment domain.Commentable + err := dbConn.First(&createdComment).Error + assert.NoError(t, err, "Comment record should exist in the database") + + var payloadData map[string]any + json.Unmarshal([]byte(tc.payload), &payloadData) + + assert.Equal(t, payloadData["comment"], createdComment.Comment) + assert.Equal(t, uint(payloadData["commentable_id"].(float64)), createdComment.CommentableID) + assert.Equal(t, payloadData["commentable_type"], createdComment.CommentableType) + assert.Equal(t, user.ID, createdComment.UserID) + } else { + if data, exists := tc.expectResponse["data"]; exists { + if message, exists := data.(map[string]any)["message"]; exists { + assert.Equal(t, message, responseBody["message"], "unexpected response message") + } + } + } + }) + } + + t.Cleanup(func() { + testutils.RefreshDatabase(t, dbConn, commentTruncateModels) + }) +} + +func TestCommentHandler_Delete(t *testing.T) { + commentHandler := setupCommentHandler(dbConn) + + tests := map[string]struct { + expectCode int + expectResponse map[string]any + setupComment bool + anotherUser bool + }{ + "can delete a comment": { + expectCode: http.StatusOK, + expectResponse: map[string]any{"message": "Your comment was successfully removed!"}, + setupComment: true, + anotherUser: false, + }, + "comment not found": { + expectCode: http.StatusNotFound, + expectResponse: map[string]any{"message": "Could not found the given comment!"}, + setupComment: false, + anotherUser: false, + }, + "cannot delete another user's comment": { + expectCode: http.StatusForbidden, + expectResponse: map[string]any{"message": "This comment does not belongs to you user!"}, + setupComment: true, + anotherUser: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + var comment *domain.Commentable + var err error + var req *http.Request + + user, err := test_mocks.CreateDummyUser(t, dbConn, &domain.User{}) + if err != nil { + t.Fatalf("failed to create dummy user: %+v", err) + } + + commentOwner := user + if tc.anotherUser { + commentOwner, err = test_mocks.CreateDummyUser(t, dbConn, &domain.User{}) + if err != nil { + t.Fatalf("failed to create another dummy user: %+v", err) + } + } + + if tc.setupComment { + comment, err = test_mocks.CreateDummyComment(t, dbConn, &domain.Commentable{User: *commentOwner}) + if err != nil { + t.Fatalf("failed to create dummy comment: %+v", err) + } + req = httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/comments/%d", comment.ID), nil) + } else { + req = httptest.NewRequest(http.MethodDelete, "/comments/999999", nil) + } + + req.Header.Set("Content-Type", "application/json") + if token := testutils.GenerateAuthTokenForUser(t, user); token != "" { + req.AddCookie(&http.Cookie{ + Name: env.AccessTokenKey, + Value: token, + Path: "/", + Domain: env.Domain, + HttpOnly: true, + Secure: false, + MaxAge: 86400, + }) + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + if tc.setupComment { + c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", comment.ID)}} + } else { + c.Params = gin.Params{{Key: "id", Value: "999999"}} + } + + commentHandler.Delete(c) + + assert.Equal(t, tc.expectCode, w.Code) + + var responseBody map[string]any + err = json.Unmarshal(w.Body.Bytes(), &responseBody) + if err != nil { + t.Fatalf("failed to parse JSON response: %+v", err) + } + + if data, exists := tc.expectResponse["data"]; exists { + if message, exists := data.(map[string]any)["message"]; exists { + assert.Equal(t, message, responseBody["message"], "unexpected response message") + } + } + }) + } + + t.Cleanup(func() { + testutils.RefreshDatabase(t, dbConn, commentTruncateModels) + }) +}