diff --git a/.env.testing b/.env.testing index e5c94b6..d2a8c69 100644 --- a/.env.testing +++ b/.env.testing @@ -1,9 +1,14 @@ ENV="testing" -JWT_SECRET=HyMlUMZSbWLm65lURmMISWWHHK6K6dgH -ACCESS_TOKEN_KEY=access_token_key +JWT_SECRET=FtSBkAoc1UbijMIRAioj1fiQMeP4uBx2 +ACCESS_TOKEN_KEY=_gc_9hp1b73cGDCmAPgaVTYOlS6cjPsnDYho JWT_TTL=15 IS_AUTH_KEY=auth_key HTTP_SECURE=false HTTP_ONLY=false HTTP_DOMAIN=localhost PORT=8080 +DB_HOST=localhost +DB_PORT=3306 +DB_USER=root +DB_PASSWORD= +DB_NAME=test_db diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ff4be8..9818b6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,10 +14,25 @@ env: DOCKER_HUB_PAT: ${{ secrets.DOCKER_HUB_PAT }} DOCKER_CONTAINER: gcstatus-api DOCKER_PORT: ${{ secrets.DOCKER_PORT }} + DB_NAME: test_db + DB_HOST: 127.0.0.1 jobs: tests: runs-on: ubuntu-latest + services: + mysql: + image: mysql:latest + env: + MYSQL_ALLOW_EMPTY_PASSWORD: "yes" + MYSQL_DATABASE: ${{ env.DB_NAME }} + options: >- + --health-cmd="mysqladmin ping --silent" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + ports: + - 3306:3306 strategy: fail-fast: true matrix: @@ -46,6 +61,13 @@ jobs: go mod download go mod vendor + - name: Wait for MySQL to be ready + run: | + until mysqladmin ping -h ${{ env.DB_HOST }} --silent; do + echo "Waiting for MySQL..." + sleep 1 + done + - name: Execute tests run: go test ./tests/... -coverprofile=coverage.txt diff --git a/di/di.go b/di/di.go index d8d9481..4a6fc72 100644 --- a/di/di.go +++ b/di/di.go @@ -69,7 +69,7 @@ func InitDependencies() ( adminTagService, adminGameService, heartService, - commentService := Setup(dbConn) + commentService := Setup(dbConn, *cfg) // Setup clients for non-test environment if cfg.ENV != "testing" { diff --git a/di/migrations.go b/di/migrations.go index d4a13bd..4e63600 100644 --- a/di/migrations.go +++ b/di/migrations.go @@ -8,7 +8,17 @@ import ( ) func MigrateModels(dbConn *gorm.DB) { - models := []any{ + models := GetModels() + + for _, model := range models { + if err := dbConn.AutoMigrate(model); err != nil { + log.Fatalf("Failed to migrate model %T: %v", model, err) + } + } +} + +func GetModels() []any { + return []any{ &domain.Reward{}, &domain.Level{}, &domain.Wallet{}, @@ -70,10 +80,4 @@ func MigrateModels(dbConn *gorm.DB) { &domain.Roleable{}, &domain.Permissionable{}, } - - for _, model := range models { - if err := dbConn.AutoMigrate(model); err != nil { - log.Fatalf("Failed to migrate model %T: %v", model, err) - } - } } diff --git a/di/setup_dependencies.go b/di/setup_dependencies.go index 54319cb..8e52769 100644 --- a/di/setup_dependencies.go +++ b/di/setup_dependencies.go @@ -1,6 +1,7 @@ package di import ( + "gcstatus/config" "gcstatus/internal/adapters/db" db_admin "gcstatus/internal/adapters/db/admin" "gcstatus/internal/usecases" @@ -9,7 +10,7 @@ import ( "gorm.io/gorm" ) -func Setup(dbConn *gorm.DB) ( +func Setup(dbConn *gorm.DB, env config.Config) ( *usecases.UserService, *usecases.AuthService, *usecases.PasswordResetService, @@ -54,7 +55,7 @@ func Setup(dbConn *gorm.DB) ( // Create service instances userService := usecases.NewUserService(userRepo) - authService := usecases.NewAuthService(nil) + authService := usecases.NewAuthService(env, nil) passwordResetService := usecases.NewPasswordResetService(passwordResetRepo) levelService := usecases.NewLevelService(levelRepo) profileService := usecases.NewProfileService(profileRepo) @@ -74,6 +75,9 @@ func Setup(dbConn *gorm.DB) ( heartService := usecases.NewHeartService(heartRepo) commentService := usecases.NewCommentService(commentRepo) + // Set dependencies that require service instances to avoid circular references + authService.SetUserService(userService) + return userService, authService, passwordResetService, diff --git a/go.mod b/go.mod index 30c4272..dbb4ddc 100644 --- a/go.mod +++ b/go.mod @@ -76,7 +76,7 @@ require ( golang.org/x/arch v0.10.0 // indirect golang.org/x/net v0.29.0 // indirect golang.org/x/sys v0.25.0 // indirect - golang.org/x/text v0.18.0 // indirect + golang.org/x/text v0.19.0 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 50cf07f..8984c99 100644 --- a/go.sum +++ b/go.sum @@ -161,8 +161,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= diff --git a/internal/adapters/api/auth_handler.go b/internal/adapters/api/auth_handler.go index bf0be83..23f0e4f 100644 --- a/internal/adapters/api/auth_handler.go +++ b/internal/adapters/api/auth_handler.go @@ -1,19 +1,10 @@ package api import ( - "errors" - "fmt" - "gcstatus/config" - "gcstatus/internal/domain" - "gcstatus/internal/resources" "gcstatus/internal/usecases" - "gcstatus/internal/utils" - "gcstatus/pkg/s3" "net/http" - "time" "github.com/gin-gonic/gin" - "gorm.io/gorm" ) type AuthHandler struct { @@ -21,196 +12,66 @@ type AuthHandler struct { userService *usecases.UserService } -func NewAuthHandler(authService *usecases.AuthService, userService *usecases.UserService) *AuthHandler { - return &AuthHandler{authService: authService, userService: userService} +func NewAuthHandler( + authService *usecases.AuthService, + userService *usecases.UserService, +) *AuthHandler { + return &AuthHandler{ + authService: authService, + userService: userService, + } } func (h *AuthHandler) Login(c *gin.Context) { - var loginData struct { - Identifier string `json:"identifier" binding:"required"` - Password string `json:"password" binding:"required"` - } - - env := config.LoadConfig() - - if err := c.ShouldBindJSON(&loginData); err != nil { - RespondWithError(c, http.StatusBadRequest, "Please provide valid credentials.") - return - } - - user, err := h.userService.AuthenticateUser(loginData.Identifier, loginData.Password) - if err != nil { - RespondWithError(c, http.StatusUnauthorized, "Invalid credentials. Please try again.") - return - } - - if user.Blocked { - RespondWithError(c, http.StatusForbidden, "You are blocked on GCStatus platform. If you think this is an error, please, contact support!") - return - } - - expirationSeconds, err := h.authService.GetExpirationSeconds(env.JwtTtl) - if err != nil { - RespondWithError(c, http.StatusInternalServerError, "Could not parse token expiration.") - return - } - - httpSecure, httpOnly, err := h.authService.GetCookieSettings(env.HttpSecure, env.HttpOnly) - if err != nil { - RespondWithError(c, http.StatusInternalServerError, "Could not parse cookie settings.") - return - } + var request usecases.LoginPayload - tokenString, err := h.authService.CreateJWTToken(user.ID, expirationSeconds) - if err != nil { - RespondWithError(c, http.StatusInternalServerError, "Could not create token.") + if err := c.ShouldBindJSON(&request); err != nil { + RespondWithError(c, http.StatusBadRequest, "Please provide a valid data.") return } - encryptedToken, err := h.authService.EncryptToken(tokenString, env.JwtSecret) + response, err := h.authService.Login(c, request) if err != nil { - RespondWithError(c, http.StatusInternalServerError, fmt.Sprintf("Encryption error: %v", err)) + RespondWithError(c, err.Code, err.Error()) return } - h.authService.SetAuthCookies(c, env.AccessTokenKey, encryptedToken, env.IsAuthKey, expirationSeconds, httpSecure, httpOnly, env.Domain) - - c.JSON(http.StatusOK, resources.Response{ - Data: gin.H{"message": "Logged in successfully"}, - }) + c.JSON(http.StatusOK, response) } func (h *AuthHandler) Register(c *gin.Context) { - var registrationData struct { - Name string `json:"name" binding:"required"` - Email string `json:"email" binding:"required,email"` - Nickname string `json:"nickname" binding:"required"` - Birthdate string `json:"birthdate" binding:"required"` - Password string `json:"password" binding:"required"` - PasswordConfirmation string `json:"password_confirmation" binding:"required"` - } + var request usecases.RegisterPayload - if err := c.ShouldBindJSON(®istrationData); err != nil { + if err := c.ShouldBindJSON(&request); err != nil { RespondWithError(c, http.StatusBadRequest, "Please, provide a valid data to proceed.") return } - if registrationData.Password != registrationData.PasswordConfirmation { - RespondWithError(c, http.StatusBadRequest, "Password confirmation does not match.") - return - } - - birthdate, err := time.Parse("2006-01-02", registrationData.Birthdate) + response, err := h.authService.Register(c, request) if err != nil { - RespondWithError(c, http.StatusBadRequest, "Invalid birthdate format.") + RespondWithError(c, err.Code, err.Error()) return } - if time.Since(birthdate).Hours() < 14*365*24 { - RespondWithError(c, http.StatusBadRequest, "You must be at least 14 years old to register.") - return - } - - if !utils.ValidatePassword(registrationData.Password) { - RespondWithError(c, http.StatusBadRequest, "Password must be at least 8 characters long and include a lowercase letter, an uppercase letter, a number, and a symbol.") - return - } - - existingUserByEmail, err := h.userService.FindUserByEmailOrNickname(registrationData.Email) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - RespondWithError(c, http.StatusInternalServerError, err.Error()) - return - } - - if existingUserByEmail != nil { - RespondWithError(c, http.StatusConflict, "Email already in use.") - return - } - - existingUserByNickname, err := h.userService.FindUserByEmailOrNickname(registrationData.Nickname) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - RespondWithError(c, http.StatusInternalServerError, err.Error()) - return - } - - if existingUserByNickname != nil { - RespondWithError(c, http.StatusConflict, "Nickname already in use.") - return - } - - hashedPassword, err := utils.HashPassword(registrationData.Password) - if err != nil { - RespondWithError(c, http.StatusInternalServerError, "Error hashing password.") - return - } - - user := domain.User{ - Name: registrationData.Name, - Email: registrationData.Email, - Nickname: registrationData.Nickname, - Birthdate: birthdate, - Password: string(hashedPassword), - LevelID: 1, - Profile: domain.Profile{Share: false}, - Wallet: domain.Wallet{Amount: 0}, - } - - if err := h.userService.CreateWithProfile(&user); err != nil { - RespondWithError(c, http.StatusInternalServerError, "Error creating user.") - return - } - - env := config.LoadConfig() - - expirationSeconds, err := h.authService.GetExpirationSeconds(env.JwtTtl) - if err != nil { - RespondWithError(c, http.StatusInternalServerError, "Could not parse token expiration.") - return - } - - httpSecure, httpOnly, err := h.authService.GetCookieSettings(env.HttpSecure, env.HttpOnly) - if err != nil { - RespondWithError(c, http.StatusInternalServerError, "Could not parse cookie settings.") - return - } - - tokenString, err := h.authService.CreateJWTToken(user.ID, expirationSeconds) - if err != nil { - RespondWithError(c, http.StatusInternalServerError, "Could not create token.") - return - } + c.JSON(http.StatusCreated, response) +} - encryptedToken, err := utils.Encrypt(tokenString, env.JwtSecret) +func (h *AuthHandler) Logout(c *gin.Context) { + response, err := h.authService.Logout(c) if err != nil { - RespondWithError(c, http.StatusInternalServerError, fmt.Sprintf("Encryption error: %v", err)) + RespondWithError(c, err.Code, err.Error()) return } - h.authService.SetAuthCookies(c, env.AccessTokenKey, encryptedToken, env.IsAuthKey, expirationSeconds, httpSecure, httpOnly, env.Domain) - - c.JSON(http.StatusOK, resources.Response{ - Data: gin.H{"message": "User registered successfully"}, - }) -} - -func (h *AuthHandler) Logout(c *gin.Context) { - env := config.LoadConfig() - - h.authService.ClearAuthCookies(c, env.AccessTokenKey, env.IsAuthKey, env.Domain) - - c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"}) + c.JSON(http.StatusOK, response) } func (h *AuthHandler) Me(c *gin.Context) { - user, err := utils.Auth(c, h.userService.GetUserByID) + response, err := h.authService.Me(c) if err != nil { - RespondWithError(c, http.StatusUnauthorized, err.Error()) + RespondWithError(c, err.Code, err.Error()) return } - transformedUser := resources.TransformUser(*user, s3.GlobalS3Client) - - c.JSON(http.StatusOK, resources.Response{ - Data: transformedUser, - }) + c.JSON(http.StatusOK, response) } 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/adapters/api/heart_handler.go b/internal/adapters/api/heart_handler.go index cf94d88..26b3495 100644 --- a/internal/adapters/api/heart_handler.go +++ b/internal/adapters/api/heart_handler.go @@ -1,9 +1,9 @@ package api import ( + "gcstatus/internal/ports" "gcstatus/internal/usecases" "gcstatus/internal/utils" - "log" "net/http" "github.com/gin-gonic/gin" @@ -25,13 +25,10 @@ func NewHeartHandler( } func (h *HeartHandler) ToggleHeartable(c *gin.Context) { - var request struct { - HeartableID uint `json:"heartable_id" binding:"required"` - HeartableType string `json:"heartable_type" binding:"required"` - } + var request ports.HeartTogglePayload if err := c.ShouldBindJSON(&request); err != nil { - RespondWithError(c, http.StatusBadRequest, "Invalid request data") + RespondWithError(c, http.StatusUnprocessableEntity, "Invalid request data") return } @@ -41,9 +38,11 @@ func (h *HeartHandler) ToggleHeartable(c *gin.Context) { return } - if err := h.heartService.ToggleHeartable(request.HeartableID, request.HeartableType, user.ID); err != nil { - RespondWithError(c, http.StatusInternalServerError, "Failed to save heart.") - log.Printf("failed to save user heart: %+v", err) + response, httpErr := h.heartService.ToggleHeartable(request.HeartableID, request.HeartableType, user.ID) + if httpErr != nil { + RespondWithError(c, httpErr.Code, httpErr.Error()) return } + + c.JSON(http.StatusOK, response) } diff --git a/internal/adapters/api/level_handler.go b/internal/adapters/api/level_handler.go index 5f31531..b72a89f 100644 --- a/internal/adapters/api/level_handler.go +++ b/internal/adapters/api/level_handler.go @@ -1,7 +1,6 @@ package api import ( - "gcstatus/internal/resources" "gcstatus/internal/usecases" "net/http" @@ -17,17 +16,11 @@ func NewLevelHandler(levelService *usecases.LevelService) *LevelHandler { } func (h *LevelHandler) GetAll(c *gin.Context) { - levels, err := h.levelService.GetAll() + response, err := h.levelService.GetAll() if err != nil { - RespondWithError(c, http.StatusInternalServerError, err.Error()) + RespondWithError(c, err.Code, err.Error()) return } - transformedLevels := resources.TransformLevels(levels) - - response := resources.Response{ - Data: transformedLevels, - } - c.JSON(http.StatusOK, response) } 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/ports/heart_repository.go b/internal/ports/heart_repository.go index 823dc1e..98ea1bd 100644 --- a/internal/ports/heart_repository.go +++ b/internal/ports/heart_repository.go @@ -2,6 +2,11 @@ package ports import "gcstatus/internal/domain" +type HeartTogglePayload struct { + HeartableID uint `json:"heartable_id" binding:"required"` + HeartableType string `json:"heartable_type" binding:"required"` +} + type HeartRepositry interface { FindForUser(heartableID uint, heartableType string, userID uint) (*domain.Heartable, error) Create(*domain.Heartable) error diff --git a/internal/usecases/auth_service.go b/internal/usecases/auth_service.go index 3c631e7..2a26493 100644 --- a/internal/usecases/auth_service.go +++ b/internal/usecases/auth_service.go @@ -1,34 +1,216 @@ package usecases import ( + goErr "errors" "gcstatus/config" - "gcstatus/internal/ports" + "gcstatus/internal/domain" + "gcstatus/internal/errors" + "gcstatus/internal/resources" "gcstatus/internal/utils" + "gcstatus/pkg/s3" + "log" + "net/http" "strconv" "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" + "gorm.io/gorm" ) +type UserAuthenticator interface { + GetUserByID(id uint) (*domain.User, error) + CreateWithProfile(user *domain.User) error + AuthenticateUser(identifier, password string) (*domain.User, error) + FindUserByEmailOrNickname(emailOrNickname string) (*domain.User, error) +} + type AuthService struct { - repo ports.AuthRepository + env config.Config + userService UserAuthenticator } -func NewAuthService(repo ports.AuthRepository) *AuthService { - return &AuthService{repo: repo} +type LoginPayload struct { + Identifier string `json:"identifier" binding:"required"` + Password string `json:"password" binding:"required"` } -func (s *AuthService) Login(c *gin.Context) { - s.repo.Login(c) +type RegisterPayload struct { + Name string `json:"name" binding:"required"` + Email string `json:"email" binding:"required,email"` + Nickname string `json:"nickname" binding:"required"` + Birthdate string `json:"birthdate" binding:"required"` + Password string `json:"password" binding:"required"` + PasswordConfirmation string `json:"password_confirmation" binding:"required"` } -func (s *AuthService) Register(c *gin.Context) { - s.repo.Register(c) +func (h *AuthService) SetUserService(userService *UserService) { + h.userService = userService +} + +func NewAuthService( + env config.Config, + userService UserAuthenticator, +) *AuthService { + return &AuthService{ + env: env, + userService: userService, + } } -func (s *AuthService) Me(c *gin.Context) { - s.repo.Me(c) +func (s *AuthService) Login(c *gin.Context, payload LoginPayload) (resources.Response, *errors.HttpError) { + errGeneric := errors.NewHttpError(http.StatusInternalServerError, "Failed to authenticate user. Please, try again later.") + + user, err := s.userService.AuthenticateUser(payload.Identifier, payload.Password) + if err != nil { + return resources.Response{}, errors.NewHttpError(http.StatusUnauthorized, "Invalid credentials. Please, double check it and try again!") + } + + if user.Blocked { + return resources.Response{}, errors.NewHttpError(http.StatusForbidden, "Your user has been blocked on GCStatus platform. If you think this is a mistake, please, contact support.") + } + + expirationSeconds, err := s.GetExpirationSeconds(s.env.JwtTtl) + if err != nil { + log.Printf("failed to get expiration seconds: %+v", err) + return resources.Response{}, errGeneric + } + + httpSecure, httpOnly, err := s.GetCookieSettings(s.env.HttpSecure, s.env.HttpOnly) + if err != nil { + log.Printf("could not parse cookie settings: %+v", err) + return resources.Response{}, errGeneric + } + + tokenString, err := s.CreateJWTToken(user.ID, expirationSeconds) + if err != nil { + log.Printf("could not create token: %+v", err) + return resources.Response{}, errGeneric + } + + encryptedToken, err := s.EncryptToken(tokenString, s.env.JwtSecret) + if err != nil { + log.Printf("encryption error: %+v", err) + return resources.Response{}, errGeneric + } + + s.SetAuthCookies(c, s.env.AccessTokenKey, encryptedToken, s.env.IsAuthKey, expirationSeconds, httpSecure, httpOnly, s.env.Domain) + + return resources.Response{ + Data: gin.H{"message": "Logged in successfully"}, + }, nil +} + +func (s *AuthService) Register(c *gin.Context, payload RegisterPayload) (resources.Response, *errors.HttpError) { + errGeneric := errors.NewHttpError(http.StatusInternalServerError, "Failed to create user. Please, try again later.") + + if payload.Password != payload.PasswordConfirmation { + return resources.Response{}, errors.NewHttpError(http.StatusBadRequest, "Password confirmation does not match.") + } + + birthdate, err := time.Parse("2006-01-02", payload.Birthdate) + if err != nil { + return resources.Response{}, errors.NewHttpError(http.StatusBadRequest, "Invalid birthdate format.") + } + + if time.Since(birthdate).Hours() < 14*365*24 { + return resources.Response{}, errors.NewHttpError(http.StatusBadRequest, "You must be at least 14 years old to register.") + } + + if !utils.ValidatePassword(payload.Password) { + return resources.Response{}, errors.NewHttpError(http.StatusBadRequest, "Password must be at least 8 characters long and include a lowercase letter, an uppercase letter, a number, and a symbol.") + } + + existingUserByEmail, err := s.userService.FindUserByEmailOrNickname(payload.Email) + if err != nil && !goErr.Is(err, gorm.ErrRecordNotFound) { + return resources.Response{}, errors.NewHttpError(http.StatusInternalServerError, err.Error()) + } + + if existingUserByEmail != nil { + return resources.Response{}, errors.NewHttpError(http.StatusConflict, "Email already in use.") + } + + existingUserByNickname, err := s.userService.FindUserByEmailOrNickname(payload.Nickname) + if err != nil && !goErr.Is(err, gorm.ErrRecordNotFound) { + return resources.Response{}, errors.NewHttpError(http.StatusInternalServerError, err.Error()) + } + + if existingUserByNickname != nil { + return resources.Response{}, errors.NewHttpError(http.StatusConflict, "Nickname already in use.") + } + + hashedPassword, err := utils.HashPassword(payload.Password) + if err != nil { + return resources.Response{}, errors.NewHttpError(http.StatusInternalServerError, "Error hashing password.") + } + + user := domain.User{ + Name: payload.Name, + Email: payload.Email, + Nickname: payload.Nickname, + Birthdate: birthdate, + Password: string(hashedPassword), + LevelID: 1, + Profile: domain.Profile{Share: false}, + Wallet: domain.Wallet{Amount: 0}, + } + + if err := s.userService.CreateWithProfile(&user); err != nil { + log.Printf("failed to create user with profile: %+v", err) + return resources.Response{}, errors.NewHttpError(http.StatusInternalServerError, "Error creating user.") + } + + expirationSeconds, err := s.GetExpirationSeconds(s.env.JwtTtl) + if err != nil { + log.Printf("failed to get expiration seconds: %+v", err) + return resources.Response{}, errGeneric + } + + httpSecure, httpOnly, err := s.GetCookieSettings(s.env.HttpSecure, s.env.HttpOnly) + if err != nil { + log.Printf("could not parse cookie settings: %+v", err) + return resources.Response{}, errGeneric + } + + tokenString, err := s.CreateJWTToken(user.ID, expirationSeconds) + if err != nil { + log.Printf("could not create token: %+v", err) + return resources.Response{}, errGeneric + } + + encryptedToken, err := s.EncryptToken(tokenString, s.env.JwtSecret) + if err != nil { + log.Printf("encryption error: %+v", err) + return resources.Response{}, errGeneric + } + + s.SetAuthCookies(c, s.env.AccessTokenKey, encryptedToken, s.env.IsAuthKey, expirationSeconds, httpSecure, httpOnly, s.env.Domain) + + return resources.Response{ + Data: gin.H{"message": "User registered successfully"}, + }, nil +} + +func (s *AuthService) Me(c *gin.Context) (resources.Response, *errors.HttpError) { + user, err := utils.Auth(c, s.userService.GetUserByID) + if err != nil { + log.Printf("failed to authenticate user: %+v", err) + return resources.Response{}, errors.NewHttpError(http.StatusUnauthorized, "Failed to authenticate user.") + } + + transformedUser := resources.TransformUser(*user, s3.GlobalS3Client) + + return resources.Response{ + Data: transformedUser, + }, nil +} + +func (s *AuthService) Logout(c *gin.Context) (resources.Response, *errors.HttpError) { + s.ClearAuthCookies(c, s.env.AccessTokenKey, s.env.IsAuthKey, s.env.Domain) + + return resources.Response{ + Data: gin.H{"message": "Logged out successfully"}, + }, nil } func (s *AuthService) GetExpirationSeconds(jwtTtl string) (int, 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/internal/usecases/heart_service.go b/internal/usecases/heart_service.go index d0e3a4e..2cefd73 100644 --- a/internal/usecases/heart_service.go +++ b/internal/usecases/heart_service.go @@ -3,8 +3,12 @@ package usecases import ( "errors" "gcstatus/internal/domain" + httpErr "gcstatus/internal/errors" "gcstatus/internal/ports" + "gcstatus/internal/resources" + "net/http" + "github.com/gin-gonic/gin" "gorm.io/gorm" ) @@ -16,14 +20,20 @@ func NewHeartService(repo ports.HeartRepositry) *HeartService { return &HeartService{repo: repo} } -func (h *HeartService) ToggleHeartable(heartableID uint, heartableType string, userID uint) error { +func (h *HeartService) ToggleHeartable(heartableID uint, heartableType string, userID uint) (resources.Response, *httpErr.HttpError) { heart, err := h.repo.FindForUser(heartableID, heartableType, userID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err + return resources.Response{}, httpErr.NewHttpError(http.StatusInternalServerError, "Failed to find item heart for user.") } if heart != nil { - return h.repo.Delete(heart.ID) + if err := h.repo.Delete(heart.ID); err != nil { + return resources.Response{}, httpErr.NewHttpError(http.StatusInternalServerError, "Failed to remove the heart from item.") + } + + return resources.Response{ + Data: gin.H{"message": "Heart removed successfully"}, + }, nil } newHeart := domain.Heartable{ @@ -32,5 +42,11 @@ func (h *HeartService) ToggleHeartable(heartableID uint, heartableType string, u HeartableType: heartableType, } - return h.repo.Create(&newHeart) + if err := h.repo.Create(&newHeart); err != nil { + return resources.Response{}, httpErr.NewHttpError(http.StatusInternalServerError, "Failed to heart the given item.") + } + + return resources.Response{ + Data: gin.H{"message": "Heart added successfully"}, + }, nil } diff --git a/internal/usecases/level_service.go b/internal/usecases/level_service.go index 7cab8ae..6eff1a4 100644 --- a/internal/usecases/level_service.go +++ b/internal/usecases/level_service.go @@ -2,7 +2,10 @@ package usecases import ( "gcstatus/internal/domain" + "gcstatus/internal/errors" "gcstatus/internal/ports" + "gcstatus/internal/resources" + "net/http" ) type LevelService struct { @@ -13,8 +16,23 @@ func NewLevelService(repo ports.LevelRepository) *LevelService { return &LevelService{repo: repo} } -func (h *LevelService) GetAll() ([]*domain.Level, error) { - return h.repo.GetAll() +func (h *LevelService) GetAll() (resources.Response, *errors.HttpError) { + levels, err := h.repo.GetAll() + if err != nil { + return resources.Response{}, errors.NewHttpError(http.StatusInternalServerError, "Failed to fetch platform levels.") + } + + var transformedLevels []resources.LevelResource + + if len(levels) > 0 { + transformedLevels = resources.TransformLevels(levels) + } else { + transformedLevels = []resources.LevelResource{} + } + + return resources.Response{ + Data: transformedLevels, + }, nil } func (h *LevelService) FindById(id uint) (*domain.Level, error) { diff --git a/tests/data/mocks/has_dummy_comment.go b/tests/data/mocks/has_dummy_comment.go new file mode 100644 index 0000000..97eafda --- /dev/null +++ b/tests/data/mocks/has_dummy_comment.go @@ -0,0 +1,44 @@ +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/data/mocks/has_dummy_heartable.go b/tests/data/mocks/has_dummy_heartable.go new file mode 100644 index 0000000..0480654 --- /dev/null +++ b/tests/data/mocks/has_dummy_heartable.go @@ -0,0 +1,40 @@ +package test_mocks + +import ( + "gcstatus/internal/domain" + "testing" + + "gorm.io/gorm" +) + +func CreateDummyHeartable(t *testing.T, dbConn *gorm.DB, overrides *domain.Heartable) (*domain.Heartable, error) { + defaultHeartable := domain.Heartable{ + HeartableID: 1, + HeartableType: "games", + } + + if overrides != nil { + if overrides.HeartableID != 0 { + defaultHeartable.HeartableID = overrides.HeartableID + } + if overrides.HeartableType != "" { + defaultHeartable.HeartableType = overrides.HeartableType + } + if overrides.User.ID != 0 { + defaultHeartable.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) + } + + defaultHeartable.User = *user + } + } + + if err := dbConn.Create(&defaultHeartable).Error; err != nil { + return nil, err + } + + return &defaultHeartable, nil +} diff --git a/tests/data/mocks/has_dummy_level.go b/tests/data/mocks/has_dummy_level.go new file mode 100644 index 0000000..005be1e --- /dev/null +++ b/tests/data/mocks/has_dummy_level.go @@ -0,0 +1,38 @@ +package test_mocks + +import ( + "gcstatus/internal/domain" + "testing" + + "gorm.io/gorm" +) + +func CreateDummyLevel(t *testing.T, dbConn *gorm.DB, overrides *domain.Level) (*domain.Level, error) { + defaultLevel := domain.Level{ + Level: 1, + Experience: 50, + Coins: 100, + Rewards: []domain.Reward{}, + } + + if overrides != nil { + if overrides.Level != 0 { + defaultLevel.Level = overrides.Level + } + if overrides.Experience != 0 { + defaultLevel.Experience = overrides.Experience + } + if overrides.Coins != 0 { + defaultLevel.Coins = overrides.Coins + } + if len(overrides.Rewards) > 0 { + defaultLevel.Rewards = overrides.Rewards + } + } + + if err := dbConn.Create(&defaultLevel).Error; err != nil { + return nil, err + } + + return &defaultLevel, nil +} diff --git a/tests/data/mocks/has_dummy_user.go b/tests/data/mocks/has_dummy_user.go new file mode 100644 index 0000000..ca10b95 --- /dev/null +++ b/tests/data/mocks/has_dummy_user.go @@ -0,0 +1,106 @@ +package test_mocks + +import ( + "fmt" + "gcstatus/config" + "gcstatus/internal/domain" + "gcstatus/internal/utils" + testingutils "gcstatus/tests/utils" + "math/rand" + "net/http" + "testing" + "time" + + "gorm.io/gorm" +) + +func randomString(n int) string { + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + rand.New(rand.NewSource(time.Now().UnixNano())) + b := make([]byte, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} + +func CreateDummyUser(t *testing.T, dbConn *gorm.DB, overrides *domain.User) (*domain.User, error) { + hashedPassword, err := utils.HashPassword("admin1234") + if err != nil { + t.Fatalf("failed to hash dummy user password: %+v", err) + } + + randomSuffix := randomString(8) + + defaultUser := domain.User{ + Name: fmt.Sprintf("User_%s", randomSuffix), + Email: fmt.Sprintf("user_%s@example.com", randomSuffix), + Nickname: fmt.Sprintf("nickname_%s", randomSuffix), + Experience: 0, + Blocked: false, + Birthdate: time.Now(), + Password: hashedPassword, + LevelID: 1, + Profile: domain.Profile{Share: false}, + Wallet: domain.Wallet{Amount: 0}, + } + + if overrides != nil { + if overrides.Name != "" { + defaultUser.Name = overrides.Name + } + if overrides.Email != "" { + defaultUser.Email = overrides.Email + } + if overrides.Nickname != "" { + defaultUser.Nickname = overrides.Nickname + } + if overrides.Experience != 0 { + defaultUser.Experience = overrides.Experience + } + if overrides.Password != "" { + hashedPassword, err := utils.HashPassword(overrides.Password) + if err != nil { + t.Fatalf("failed to hash dummy user password: %+v", err) + } + defaultUser.Password = hashedPassword + } + if !overrides.Birthdate.IsZero() { + defaultUser.Birthdate = overrides.Birthdate + } + } + + if err := dbConn.Create(&defaultUser).Error; err != nil { + return nil, err + } + + return &defaultUser, nil +} + +func ActingAsDummyUser( + t *testing.T, + dbConn *gorm.DB, + overrides *domain.User, + req *http.Request, + env *config.Config, +) (*domain.User, error) { + user, err := CreateDummyUser(t, dbConn, overrides) + if err != nil { + t.Fatalf("failed to create user on acting method: %+v", err) + } + + token := testingutils.GenerateAuthTokenForUser(t, user) + if token != "" { + req.AddCookie(&http.Cookie{ + Name: env.AccessTokenKey, + Value: token, + Path: "/", + Domain: env.Domain, + HttpOnly: true, + Secure: false, + MaxAge: 86400, + }) + } + + return user, err +} diff --git a/tests/data/seeders.go b/tests/data/seeders.go new file mode 100644 index 0000000..24f48d8 --- /dev/null +++ b/tests/data/seeders.go @@ -0,0 +1,28 @@ +package data_test + +import ( + "gcstatus/internal/domain" + "testing" + + "gorm.io/gorm" +) + +func CreateDefaultLevels(dbConn *gorm.DB) error { + levels := []domain.Level{ + {ID: 1, Level: 1, Experience: 0, Coins: 0}, + {ID: 2, Level: 2, Experience: 50, Coins: 100}, + {ID: 3, Level: 3, Experience: 100, Coins: 200}, + } + for _, level := range levels { + if err := dbConn.FirstOrCreate(&level, level).Error; err != nil { + return err + } + } + return nil +} + +func Seed(t *testing.T, dbConn *gorm.DB) { + if err := CreateDefaultLevels(dbConn); err != nil { + t.Fatalf("failed to seed database: %+v", err) + } +} diff --git a/tests/feature/api/auth_handler_test.go b/tests/feature/api/auth_handler_test.go new file mode 100644 index 0000000..174e088 --- /dev/null +++ b/tests/feature/api/auth_handler_test.go @@ -0,0 +1,345 @@ +package feature_tests + +import ( + "encoding/json" + "fmt" + "gcstatus/config" + "gcstatus/internal/adapters/api" + "gcstatus/internal/adapters/db" + "gcstatus/internal/domain" + "gcstatus/internal/usecases" + "gcstatus/internal/utils" + test_mocks "gcstatus/tests/data/mocks" + testutils "gcstatus/tests/utils" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "gorm.io/gorm" +) + +var authTruncateModels = []any{ + &domain.User{}, + &domain.Wallet{}, + &domain.Profile{}, +} + +func setupAuthHandler(dbConn *gorm.DB) *api.AuthHandler { + userService := usecases.NewUserService(db.NewUserRepositoryMySQL(dbConn)) + authService := usecases.NewAuthService(*config.LoadConfig(), userService) + return api.NewAuthHandler(authService, userService) +} + +func TestAuthHandler_Login(t *testing.T) { + dummyUser, err := test_mocks.CreateDummyUser(t, dbConn, &domain.User{}) + if err != nil { + t.Fatalf("failed to create dummy user: %+v", err) + } + + authHandler := setupAuthHandler(dbConn) + + tests := map[string]struct { + payload string + expectCode int + expectResponse string + }{ + "successful login": { + payload: fmt.Sprintf(`{"identifier": "%s", "password": "admin1234"}`, dummyUser.Email), + expectCode: 200, + expectResponse: "Logged in successfully", + }, + "invalid identifier": { + payload: `{"identifier": "invalid@example.com", "password": "admin1234"}`, + expectCode: 401, + expectResponse: "Invalid credentials. Please, double check it and try again!", + }, + "invalid password": { + payload: fmt.Sprintf(`{"identifier": "%s", "password": "invalidpass"}`, dummyUser.Email), + expectCode: 401, + expectResponse: "Invalid credentials. Please, double check it and try again!", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(tc.payload)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + + authHandler.Login(c) + + assert.Equal(t, tc.expectCode, w.Code) + assert.Contains(t, w.Body.String(), tc.expectResponse) + }) + } + + t.Cleanup(func() { + testutils.RefreshDatabase(t, dbConn, authTruncateModels) + }) +} + +func TestAuthHandler_Me(t *testing.T) { + user, err := test_mocks.CreateDummyUser(t, dbConn, &domain.User{ + Email: "default@example.com", + Password: "admin1234", + }) + if err != nil { + t.Fatalf("failed to create dummy user: %+v", err) + } + + authHandler := setupAuthHandler(dbConn) + + tests := map[string]struct { + authToken string + expectCode int + expectResponse map[string]any + }{ + "successful authentication": { + authToken: testutils.GenerateAuthTokenForUser(t, user), + expectCode: http.StatusOK, + expectResponse: map[string]any{ + "id": float64(user.ID), + "name": user.Name, + "email": user.Email, + "level": float64(user.LevelID), + "experience": float64(user.Experience), + "nickname": user.Nickname, + "birthdate": utils.FormatTimestamp(user.Birthdate), + "created_at": utils.FormatTimestamp(user.CreatedAt), + "updated_at": utils.FormatTimestamp(user.UpdatedAt), + "wallet": nil, + }, + }, + "missing token": { + authToken: "", + expectCode: http.StatusUnauthorized, + expectResponse: map[string]any{"message": "Failed to authenticate user."}, + }, + "invalid token": { + authToken: "invalidtoken", + expectCode: http.StatusUnauthorized, + expectResponse: map[string]any{"message": "Failed to authenticate user."}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "/me", nil) + if tc.authToken != "" { + req.AddCookie(&http.Cookie{ + Name: env.AccessTokenKey, + Value: tc.authToken, + Path: "/", + Domain: env.Domain, + HttpOnly: true, + Secure: false, + MaxAge: 86400, + }) + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + + authHandler.Me(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 tc.expectCode == http.StatusOK { + data, ok := responseBody["data"].(map[string]any) + if assert.True(t, ok, "response should contain 'data' field") { + for key, expectedValue := range tc.expectResponse { + assert.Equal(t, expectedValue, data[key], "unexpected value for '%s'", key) + } + } + } 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, authTruncateModels) + }) +} + +func TestAuthHandler_Logout(t *testing.T) { + authHandler := setupAuthHandler(dbConn) + + tests := map[string]struct { + expectCode int + expectResponse string + }{ + "successful logout": { + expectCode: 200, + expectResponse: "Logged out successfully", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodPost, "/logout", nil) + req.Header.Set("Content-Type", "application/json") + + _, 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 + + authHandler.Logout(c) + + assert.Equal(t, tc.expectCode, w.Code) + assert.Contains(t, w.Body.String(), tc.expectResponse) + }) + } + + t.Cleanup(func() { + testutils.RefreshDatabase(t, dbConn, authTruncateModels) + }) +} + +func TestAuthHandler_Register(t *testing.T) { + authHandler := setupAuthHandler(dbConn) + + tests := map[string]struct { + payload string + expectCode int + expectResponse string + }{ + "successful registration": { + payload: `{ + "name": "John Doe", + "email": "johndoe@example.com", + "nickname": "johnd", + "birthdate": "2000-01-01", + "password": "Password@123", + "password_confirmation": "Password@123" + }`, + expectCode: http.StatusCreated, + expectResponse: "User registered successfully", + }, + "password mismatch": { + payload: `{ + "name": "John Doe", + "email": "johndoe@example.com", + "nickname": "johnd", + "birthdate": "2000-01-01", + "password": "Password@123", + "password_confirmation": "Mismatch123" + }`, + expectCode: http.StatusBadRequest, + expectResponse: "Password confirmation does not match.", + }, + "invalid birthdate format": { + payload: `{ + "name": "John Doe", + "email": "johndoe@example.com", + "nickname": "johnd", + "birthdate": "01-01-2000", + "password": "Password@123", + "password_confirmation": "Password@123" + }`, + expectCode: http.StatusBadRequest, + expectResponse: "Invalid birthdate format.", + }, + "underage user": { + payload: fmt.Sprintf(`{ + "name": "Young User", + "email": "younguser@example.com", + "nickname": "youngie", + "birthdate": "%s", + "password": "Password@123", + "password_confirmation": "Password@123" + }`, time.Now().Format("2006-01-02")), + expectCode: http.StatusBadRequest, + expectResponse: "You must be at least 14 years old to register.", + }, + "duplicate email": { + payload: `{ + "name": "John Doe", + "email": "existing@example.com", + "nickname": "newnick", + "birthdate": "2000-01-01", + "password": "Password@123", + "password_confirmation": "Password@123" + }`, + expectCode: http.StatusConflict, + expectResponse: "Email already in use.", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if name == "duplicate email" { + _, err := test_mocks.CreateDummyUser(t, dbConn, &domain.User{ + Email: "existing@example.com", + }) + if err != nil { + t.Fatalf("failed to create dummy user: %+v", err) + } + } + + req := httptest.NewRequest(http.MethodPost, "/register", strings.NewReader(tc.payload)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + + authHandler.Register(c) + + 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 + if err := json.Unmarshal([]byte(tc.payload), &payloadData); err != nil { + t.Fatalf("failed to unmarshal payload body: %+v", err) + } + + 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))) + } + }) + } + + t.Cleanup(func() { + testutils.RefreshDatabase(t, dbConn, authTruncateModels) + }) +} diff --git a/tests/feature/api/comment_handler_test.go b/tests/feature/api/comment_handler_test.go new file mode 100644 index 0000000..74eab2a --- /dev/null +++ b/tests/feature/api/comment_handler_test.go @@ -0,0 +1,228 @@ +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 + if err := json.Unmarshal([]byte(tc.payload), &payloadData); err != nil { + t.Fatalf("failed to unmarshal payload body: %+v", err) + } + + 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 + if err = json.Unmarshal(w.Body.Bytes(), &responseBody); 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) + }) +} diff --git a/tests/feature/api/heart_handler_test.go b/tests/feature/api/heart_handler_test.go new file mode 100644 index 0000000..92911f7 --- /dev/null +++ b/tests/feature/api/heart_handler_test.go @@ -0,0 +1,111 @@ +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 heartTruncateModels = []any{ + &domain.User{}, + &domain.Wallet{}, + &domain.Profile{}, + &domain.Heartable{}, +} + +func setupHeartHandler(dbConn *gorm.DB) *api.HeartHandler { + userService := usecases.NewUserService(db.NewUserRepositoryMySQL(dbConn)) + heartService := usecases.NewHeartService(db.NewHeartRepositoryMySQL(dbConn)) + return api.NewHeartHandler(userService, heartService) +} + +func TestHeartHandler_Create(t *testing.T) { + heartableHandler := setupHeartHandler(dbConn) + + tests := map[string]struct { + payload string + expectCode int + expectResponse map[string]any + }{ + "valid heartable payload": { + payload: fmt.Sprintf(`{ + "heartable_id": %d, + "heartable_type": "games" + }`, uint(1)), + expectCode: http.StatusOK, + expectResponse: map[string]any{"message": "The heart operation runned successfully!"}, + }, + "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, "/hearts", 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) + } + + var payloadData map[string]any + if err := json.Unmarshal([]byte(tc.payload), &payloadData); err != nil { + t.Fatalf("failed to unmarshal payload body: %+v", err) + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + + heartableHandler.ToggleHeartable(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.StatusOK { + var heartable domain.Heartable + heartableID := uint(payloadData["heartable_id"].(float64)) + + err := dbConn.First(&heartable, heartableID).Error + assert.NoError(t, err, "Heart record should exist in the database") + assert.Equal(t, user.ID, heartable.UserID) + assert.Equal(t, payloadData["heartable_type"], heartable.HeartableType) + assert.Equal(t, uint(payloadData["heartable_id"].(float64)), heartable.HeartableID) + assert.False(t, heartable.DeletedAt.Valid, "Expected DeletedAt to be nil for active record") + } 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, heartTruncateModels) + }) +} diff --git a/tests/feature/api/level_handler_test.go b/tests/feature/api/level_handler_test.go new file mode 100644 index 0000000..e8aff83 --- /dev/null +++ b/tests/feature/api/level_handler_test.go @@ -0,0 +1,145 @@ +package feature_tests + +import ( + "encoding/json" + "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" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "gorm.io/gorm" +) + +var levelTruncateModels = []any{ + &domain.User{}, + &domain.Wallet{}, + &domain.Profile{}, + &domain.Level{}, +} + +func setupLevelHandler(dbConn *gorm.DB) *api.LevelHandler { + levelService := usecases.NewLevelService(db.NewLevelRepositoryMySQL(dbConn)) + return api.NewLevelHandler(levelService) +} + +func TestLevelHandler_GetAll(t *testing.T) { + + levelHandler := setupLevelHandler(dbConn) + + tests := map[string]struct { + expectCode int + expectResponse []map[string]any + }{ + "successful get all": { + expectCode: http.StatusOK, + expectResponse: []map[string]any{ + { + "id": float64(1), + "level": float64(1), + "coins": float64(0), + "experience": float64(0), + }, + { + "id": float64(2), + "level": float64(2), + "coins": float64(100), + "experience": float64(50), + }, + { + "id": float64(3), + "level": float64(3), + "coins": float64(200), + "experience": float64(100), + }, + { + "id": float64(4), + "level": float64(4), + "coins": float64(400), + "experience": float64(200), + "rewards": []map[string]any{ + { + "id": float64(1), + "rewardable_type": "titles", + "sourceable_type": "levels", + }, + }, + }, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "/levels", nil) + req.Header.Set("Content-Type", "application/json") + + _, err := test_mocks.CreateDummyLevel(t, dbConn, &domain.Level{ + Level: 4, + Experience: 200, + Coins: 400, + Rewards: []domain.Reward{ + { + ID: 1, + SourceableID: 1, + SourceableType: "levels", + RewardableID: 1, + RewardableType: "titles", + }, + }, + }) + if err != nil { + t.Fatalf("failed to create dummy level: %+v", err) + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + + levelHandler.GetAll(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) + } + + data, ok := responseBody["data"].([]any) + if assert.True(t, ok, "response should contain 'data' field as an array") { + for i, expectedLevel := range tc.expectResponse { + levelData, ok := data[i].(map[string]any) + if assert.True(t, ok, "each level should be a map") { + for key, expectedValue := range expectedLevel { + if key == "rewards" { + rewards, ok := levelData["rewards"].([]any) + if assert.True(t, ok, "rewards should be an array") { + for j, expectedReward := range expectedLevel["rewards"].([]map[string]any) { + rewardData := rewards[j].(map[string]any) + for rewardKey, rewardValue := range expectedReward { + assert.Equal(t, rewardValue, rewardData[rewardKey], "unexpected value for '%s'", rewardKey) + } + } + } + } else { + assert.Equal(t, expectedValue, levelData[key], "unexpected value for '%s'", key) + } + } + } + } + } + }) + } + + t.Cleanup(func() { + testutils.RefreshDatabase(t, dbConn, levelTruncateModels) + }) +} diff --git a/tests/feature/api/main_test.go b/tests/feature/api/main_test.go new file mode 100644 index 0000000..c101f35 --- /dev/null +++ b/tests/feature/api/main_test.go @@ -0,0 +1,17 @@ +package feature_tests + +import ( + "gcstatus/config" + testutils "gcstatus/tests/utils" + + "gorm.io/gorm" +) + +var ( + dbConn *gorm.DB + env *config.Config +) + +func init() { + dbConn, env = testutils.SetupTestDB(nil) +} diff --git a/tests/utils/main.go b/tests/utils/main.go index 5cf810a..aaa9870 100644 --- a/tests/utils/main.go +++ b/tests/utils/main.go @@ -2,7 +2,12 @@ package testutils import ( "errors" + "fmt" + "gcstatus/config" + "gcstatus/di" "gcstatus/internal/domain" + "gcstatus/internal/utils" + data_test "gcstatus/tests/data" "net/http/httptest" "path/filepath" "runtime" @@ -12,6 +17,7 @@ import ( "github.com/DATA-DOG/go-sqlmock" "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" "github.com/joho/godotenv" "gorm.io/driver/mysql" "gorm.io/gorm" @@ -100,3 +106,76 @@ func BoolPtr(b bool) *bool { func StringPtr(s string) *string { return &s } + +func SetupTestDB(t *testing.T) (*gorm.DB, *config.Config) { + err := LoadEnv() + if err != nil { + t.Fatalf("failed to load env file: %+v", err) + } + + env := config.LoadConfig() + dsn := GetDBConnectionURL(env) + dbConn, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("Failed to connect to test database: %v", err) + } + + models := di.GetModels() + for _, model := range models { + if err := dbConn.AutoMigrate(model); err != nil { + t.Fatalf("Failed to migrate table for model %T: %v", model, err) + } + } + + data_test.Seed(t, dbConn) + + return dbConn, env +} + +func GetDBConnectionURL(config *config.Config) string { + return fmt.Sprintf( + "%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", + config.DBUser, config.DBPassword, config.DBHost, config.DBPort, config.DBName, + ) +} + +func RefreshDatabase(t *testing.T, dbConn *gorm.DB, models []any) { + dbConn.Exec("SET FOREIGN_KEY_CHECKS=0;") + + for _, model := range models { + stmt := &gorm.Statement{DB: dbConn} + err := stmt.Parse(model) + if err != nil { + t.Fatalf("failed to parse model table: %+v", err) + } + + tableName := stmt.Schema.Table + + if err := dbConn.Exec(fmt.Sprintf("TRUNCATE TABLE %s", tableName)).Error; err != nil { + t.Fatalf("Failed to truncate table for model %T: %v", model, err) + } + } + + dbConn.Exec("SET FOREIGN_KEY_CHECKS=1;") +} + +func GenerateAuthTokenForUser(t *testing.T, user *domain.User) string { + env := config.LoadConfig() + + secret := []byte(env.JwtSecret) + + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "user_id": user.ID, + "exp": time.Now().Add(24 * time.Hour).Unix(), + }).SignedString(secret) + if err != nil { + t.Fatalf("failed to generate user token: %+v", err) + } + + encryptedToken, err := utils.Encrypt(token, env.JwtSecret) + if err != nil { + t.Fatalf("encryption error: %+v", err) + } + + return encryptedToken +}