From 313c4eacef1352e4d343fe19ad4616b2924acaf1 Mon Sep 17 00:00:00 2001 From: Felipe Luz Oliveira <75860661+felipebrsk@users.noreply.github.com> Date: Sun, 20 Oct 2024 19:27:20 -0300 Subject: [PATCH] feat: adding categories CRUD for admin --- DONE.md | 5 + cmd/server/main.go | 2 + cmd/server/routes/init_handlers.go | 7 +- cmd/server/routes/routes.go | 14 +- di/di.go | 6 +- di/setup_dependencies.go | 8 +- .../adapters/api/admin/category_handler.go | 109 ++++++++ .../db/admin/category_repository_mysql.go | 54 ++++ internal/middlewares/permission_middleware.go | 54 ++-- internal/ports/admin/category_repository.go | 15 + internal/usecases/admin/category_service.go | 37 +++ internal/utils/utils.go | 2 +- .../admin/category_repository_mysql_test.go | 262 ++++++++++++++++++ .../ports/admin/category_repository_test.go | 255 +++++++++++++++++ 14 files changed, 798 insertions(+), 32 deletions(-) create mode 100644 internal/adapters/api/admin/category_handler.go create mode 100644 internal/adapters/db/admin/category_repository_mysql.go create mode 100644 internal/ports/admin/category_repository.go create mode 100644 internal/usecases/admin/category_service.go create mode 100644 tests/unit/db/admin/category_repository_mysql_test.go create mode 100644 tests/unit/ports/admin/category_repository_test.go diff --git a/DONE.md b/DONE.md index 5ec70e1..33a9fb3 100644 --- a/DONE.md +++ b/DONE.md @@ -147,5 +147,10 @@ - [x] Create me method for admin - [x] Return user permissions - [x] Return user roles + - [x] Create categories CRUD + - [x] Create method to list all categories + - [x] Create method to create a new category + - [x] Create method to update a category + - [x] Create method to delete a category ### Post MVP diff --git a/cmd/server/main.go b/cmd/server/main.go index 65f8e0e..f73e22c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -29,6 +29,7 @@ func main() { missionService, gameService, bannerService, + adminCategoryService, db := di.InitDependencies() // Setup routes with dependency injection @@ -46,6 +47,7 @@ func main() { missionService, gameService, bannerService, + adminCategoryService, db, ) diff --git a/cmd/server/routes/init_handlers.go b/cmd/server/routes/init_handlers.go index d063956..6310325 100644 --- a/cmd/server/routes/init_handlers.go +++ b/cmd/server/routes/init_handlers.go @@ -4,6 +4,7 @@ import ( "gcstatus/internal/adapters/api" api_admin "gcstatus/internal/adapters/api/admin" "gcstatus/internal/usecases" + usecases_admin "gcstatus/internal/usecases/admin" "gorm.io/gorm" ) @@ -22,6 +23,7 @@ func InitHandlers( missionService *usecases.MissionService, gameService *usecases.GameService, bannerService *usecases.BannerService, + adminCategoryService *usecases_admin.AdminCategoryService, db *gorm.DB, ) ( authHandler *api.AuthHandler, @@ -37,6 +39,7 @@ func InitHandlers( homeHandler *api.HomeHandler, steamHandler *api_admin.SteamHandler, adminAuthHandler *api_admin.AuthHandler, + adminCategoryHandler *api_admin.AdminCategoryHandler, ) { userHandler = api.NewUserHandler(userService) authHandler = api.NewAuthHandler(authService, userService) @@ -51,6 +54,7 @@ func InitHandlers( homeHandler = api.NewHomeHandler(userService, gameService, bannerService) steamHandler = api_admin.NewSteamHandler(gameService, db) adminAuthHandler = api_admin.NewAuthHandler(authService, userService) + adminCategoryHandler = api_admin.NewAdminCategoryHandler(adminCategoryService) return authHandler, passwordResetHandler, @@ -64,5 +68,6 @@ func InitHandlers( gameHandler, homeHandler, steamHandler, - adminAuthHandler + adminAuthHandler, + adminCategoryHandler } diff --git a/cmd/server/routes/routes.go b/cmd/server/routes/routes.go index e65270c..aa452c2 100644 --- a/cmd/server/routes/routes.go +++ b/cmd/server/routes/routes.go @@ -4,6 +4,7 @@ import ( "gcstatus/config" "gcstatus/internal/middlewares" "gcstatus/internal/usecases" + usecases_admin "gcstatus/internal/usecases/admin" "strings" "github.com/gin-contrib/cors" @@ -26,6 +27,7 @@ func SetupRouter( missionService *usecases.MissionService, gameService *usecases.GameService, bannerService *usecases.BannerService, + adminCategoryService *usecases_admin.AdminCategoryService, db *gorm.DB, ) *gin.Engine { r := gin.Default() @@ -59,7 +61,8 @@ func SetupRouter( gameHandler, homeHandler, steamHandler, - adminAuthHandler := InitHandlers( + adminAuthHandler, + adminCategoryHandler := InitHandlers( authService, userService, passwordResetService, @@ -73,11 +76,13 @@ func SetupRouter( missionService, gameService, bannerService, + adminCategoryService, db, ) // Define the middlewares r.Use(middlewares.LimitThrottleMiddleware()) + permissionMiddleware := middlewares.NewPermissionMiddleware(userService) protected := r.Group("/") admin := r.Group("/admin") @@ -127,7 +132,12 @@ func SetupRouter( { admin.GET("/me", adminAuthHandler.Me) admin.POST("/logout", adminAuthHandler.Logout) - admin.POST("/steam/register/:appID", middlewares.PermissionMiddleware(userService, "create:steam-jobs-games"), steamHandler.RegisterByAppID) + admin.POST("/steam/register/:appID", permissionMiddleware("create:steam-jobs-games"), steamHandler.RegisterByAppID) + + admin.GET("/categories", permissionMiddleware("view:categories"), adminCategoryHandler.GetAll) + admin.POST("/categories", permissionMiddleware("view:categories", "create:categories"), adminCategoryHandler.Create) + admin.PUT("/categories/:id", permissionMiddleware("view:categories", "update:categories"), adminCategoryHandler.Update) + admin.DELETE("/categories/:id", permissionMiddleware("view:categories", "delete:categories"), adminCategoryHandler.Delete) } // Common routes diff --git a/di/di.go b/di/di.go index 7aa9c0d..1828eb9 100644 --- a/di/di.go +++ b/di/di.go @@ -4,6 +4,7 @@ import ( "context" "gcstatus/config" "gcstatus/internal/usecases" + usecases_admin "gcstatus/internal/usecases/admin" "gcstatus/pkg/cache" "gcstatus/pkg/s3" "gcstatus/pkg/sqs" @@ -27,6 +28,7 @@ func InitDependencies() ( *usecases.MissionService, *usecases.GameService, *usecases.BannerService, + *usecases_admin.AdminCategoryService, *gorm.DB, ) { cfg := config.LoadConfig() @@ -54,7 +56,8 @@ func InitDependencies() ( notificationService, missionService, gameService, - bannerService := Setup(dbConn) + bannerService, + adminCategoryService := Setup(dbConn) // Setup clients for non-test environment if cfg.ENV != "testing" { @@ -90,5 +93,6 @@ func InitDependencies() ( missionService, gameService, bannerService, + adminCategoryService, dbConn } diff --git a/di/setup_dependencies.go b/di/setup_dependencies.go index 95524e4..a869f5b 100644 --- a/di/setup_dependencies.go +++ b/di/setup_dependencies.go @@ -2,7 +2,9 @@ package di import ( "gcstatus/internal/adapters/db" + db_admin "gcstatus/internal/adapters/db/admin" "gcstatus/internal/usecases" + usecases_admin "gcstatus/internal/usecases/admin" "gorm.io/gorm" ) @@ -21,6 +23,7 @@ func Setup(dbConn *gorm.DB) ( *usecases.MissionService, *usecases.GameService, *usecases.BannerService, + *usecases_admin.AdminCategoryService, ) { // Create repository instances userRepo := db.NewUserRepositoryMySQL(dbConn) @@ -35,6 +38,7 @@ func Setup(dbConn *gorm.DB) ( missionRepo := db.NewMissionRepositoryMySQL(dbConn) gameRepo := db.NewGameRepositoryMySQL(dbConn) bannerRepo := db.NewBannerRepositoryMySQL(dbConn) + adminCategoryRepo := db_admin.NewAdminCategoryRepositoryMySQL(dbConn) // Create service instances userService := usecases.NewUserService(userRepo) @@ -50,6 +54,7 @@ func Setup(dbConn *gorm.DB) ( missionService := usecases.NewMissionService(missionRepo) gameService := usecases.NewGameService(gameRepo) bannerService := usecases.NewBannerService(bannerRepo) + adminCategoryService := usecases_admin.NewAdminCategoryService(adminCategoryRepo) return userService, authService, @@ -63,5 +68,6 @@ func Setup(dbConn *gorm.DB) ( notificationService, missionService, gameService, - bannerService + bannerService, + adminCategoryService } diff --git a/internal/adapters/api/admin/category_handler.go b/internal/adapters/api/admin/category_handler.go new file mode 100644 index 0000000..5a23bfd --- /dev/null +++ b/internal/adapters/api/admin/category_handler.go @@ -0,0 +1,109 @@ +package api_admin + +import ( + "gcstatus/internal/adapters/api" + "gcstatus/internal/domain" + "gcstatus/internal/errors" + ports_admin "gcstatus/internal/ports/admin" + "gcstatus/internal/resources" + usecases_admin "gcstatus/internal/usecases/admin" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +type AdminCategoryHandler struct { + categoryService *usecases_admin.AdminCategoryService +} + +func NewAdminCategoryHandler( + categoryService *usecases_admin.AdminCategoryService, +) *AdminCategoryHandler { + return &AdminCategoryHandler{ + categoryService: categoryService, + } +} + +func (h *AdminCategoryHandler) GetAll(c *gin.Context) { + categories, err := h.categoryService.GetAll() + if err != nil { + api.RespondWithError(c, http.StatusInternalServerError, "Failed to fetch categories: "+err.Error()) + return + } + + transformedCategories := resources.TransformCategories(categories) + + response := resources.Response{ + Data: transformedCategories, + } + + c.JSON(http.StatusOK, response) +} + +func (h *AdminCategoryHandler) Create(c *gin.Context) { + var request struct { + Name string `json:"name" binding:"required"` + } + + if err := c.ShouldBindJSON(&request); err != nil { + api.RespondWithError(c, http.StatusUnprocessableEntity, "Please, provide a category name.") + return + } + + category := &domain.Category{ + Name: request.Name, + } + + if err := h.categoryService.Create(category); err != nil { + api.RespondWithError(c, http.StatusInternalServerError, "Failed to create category: "+err.Error()) + return + } + + c.JSON(http.StatusCreated, gin.H{"message": "The category was successfully created!"}) +} + +func (h *AdminCategoryHandler) Update(c *gin.Context) { + categoryIdStr := c.Param("id") + + categoryID, err := strconv.ParseUint(categoryIdStr, 10, 32) + if err != nil { + api.RespondWithError(c, http.StatusBadRequest, "Invalid category ID: "+err.Error()) + return + } + + var request ports_admin.UpdateCategoryInterface + + if err := c.ShouldBindJSON(&request); err != nil { + api.RespondWithError(c, http.StatusUnprocessableEntity, "Please, provide a category name.") + return + } + + if err := h.categoryService.Update(uint(categoryID), request); err != nil { + api.RespondWithError(c, http.StatusInternalServerError, "Failed to update category: "+err.Error()) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "The category was successfully updated!"}) +} + +func (h *AdminCategoryHandler) Delete(c *gin.Context) { + categoryIdStr := c.Param("id") + + categoryID, err := strconv.ParseUint(categoryIdStr, 10, 32) + if err != nil { + api.RespondWithError(c, http.StatusBadRequest, "Invalid category ID: "+err.Error()) + return + } + + if err := h.categoryService.Delete(uint(categoryID)); err != nil { + if httpErr, ok := err.(*errors.HttpError); ok { + api.RespondWithError(c, httpErr.Code, httpErr.Error()) + } else { + api.RespondWithError(c, http.StatusInternalServerError, "Failed to delete category: "+err.Error()) + } + return + } + + c.JSON(http.StatusOK, gin.H{"message": "The category was successfully removed!"}) +} diff --git a/internal/adapters/db/admin/category_repository_mysql.go b/internal/adapters/db/admin/category_repository_mysql.go new file mode 100644 index 0000000..7e72869 --- /dev/null +++ b/internal/adapters/db/admin/category_repository_mysql.go @@ -0,0 +1,54 @@ +package db_admin + +import ( + "fmt" + "gcstatus/internal/domain" + "gcstatus/internal/errors" + ports_admin "gcstatus/internal/ports/admin" + "net/http" + + "gorm.io/gorm" +) + +type AdminCategoryRepositoryMySQL struct { + db *gorm.DB +} + +func NewAdminCategoryRepositoryMySQL(db *gorm.DB) ports_admin.AdminCategoryRepository { + return &AdminCategoryRepositoryMySQL{ + db: db, + } +} + +func (h *AdminCategoryRepositoryMySQL) GetAll() ([]domain.Category, error) { + var categories []domain.Category + err := h.db.Model(&domain.Category{}). + Find(&categories). + Error + + return categories, err +} + +func (h *AdminCategoryRepositoryMySQL) Create(category *domain.Category) error { + return h.db.Create(&category).Error +} + +func (h *AdminCategoryRepositoryMySQL) Update(id uint, request ports_admin.UpdateCategoryInterface) error { + updateFields := map[string]any{ + "name": request.Name, + "slug": request.Slug, + } + + if err := h.db.Model(&domain.Category{}).Where("id = ?", id).Updates(updateFields).Error; err != nil { + return fmt.Errorf("failed to update category: %+s", err.Error()) + } + + return nil +} + +func (h *AdminCategoryRepositoryMySQL) Delete(id uint) error { + if err := h.db.Delete(&domain.Category{}, id).Error; err != nil { + return errors.NewHttpError(http.StatusNotFound, "category not found.") + } + return nil +} diff --git a/internal/middlewares/permission_middleware.go b/internal/middlewares/permission_middleware.go index f6ce72e..706c914 100644 --- a/internal/middlewares/permission_middleware.go +++ b/internal/middlewares/permission_middleware.go @@ -13,39 +13,41 @@ type UserServiceInterface interface { GetUserByIDForAdmin(userID uint) (*domain.User, error) } -func PermissionMiddleware(userService UserServiceInterface, requiredScopes ...string) gin.HandlerFunc { - return func(c *gin.Context) { - user, err := utils.Auth(c, userService.GetUserByIDForAdmin) - if err != nil { - api.RespondWithError(c, http.StatusUnauthorized, err.Error()) - c.Abort() - return - } +func NewPermissionMiddleware(userService UserServiceInterface) func(requiredScopes ...string) gin.HandlerFunc { + return func(requiredScopes ...string) gin.HandlerFunc { + return func(c *gin.Context) { + user, err := utils.Auth(c, userService.GetUserByIDForAdmin) + if err != nil { + api.RespondWithError(c, http.StatusUnauthorized, err.Error()) + c.Abort() + return + } - hasFullAccess := false - for _, role := range user.Roles { - if role.Role.Name == "Technology" { - hasFullAccess = true - break + hasFullAccess := false + for _, role := range user.Roles { + if role.Role.Name == "Technology" { + hasFullAccess = true + break + } } - } - if hasFullAccess { - c.Next() - return - } + if hasFullAccess { + c.Next() + return + } - userPermissions := collectPermissions(user) + userPermissions := collectPermissions(user) - for _, requiredScope := range requiredScopes { - if !userHasPermission(userPermissions, requiredScope) { - api.RespondWithError(c, http.StatusForbidden, "insufficient permissions") - c.Abort() - return + for _, requiredScope := range requiredScopes { + if !userHasPermission(userPermissions, requiredScope) { + api.RespondWithError(c, http.StatusForbidden, "insufficient permissions") + c.Abort() + return + } } - } - c.Next() + c.Next() + } } } diff --git a/internal/ports/admin/category_repository.go b/internal/ports/admin/category_repository.go new file mode 100644 index 0000000..a7bcc11 --- /dev/null +++ b/internal/ports/admin/category_repository.go @@ -0,0 +1,15 @@ +package ports_admin + +import "gcstatus/internal/domain" + +type UpdateCategoryInterface struct { + Name string `json:"name" binding:"required"` + Slug string `json:"slug" binding:"required"` +} + +type AdminCategoryRepository interface { + GetAll() ([]domain.Category, error) + Create(category *domain.Category) error + Update(id uint, request UpdateCategoryInterface) error + Delete(id uint) error +} diff --git a/internal/usecases/admin/category_service.go b/internal/usecases/admin/category_service.go new file mode 100644 index 0000000..1773104 --- /dev/null +++ b/internal/usecases/admin/category_service.go @@ -0,0 +1,37 @@ +package usecases_admin + +import ( + "gcstatus/internal/domain" + ports_admin "gcstatus/internal/ports/admin" + "gcstatus/internal/utils" +) + +type AdminCategoryService struct { + repo ports_admin.AdminCategoryRepository +} + +func NewAdminCategoryService(repo ports_admin.AdminCategoryRepository) *AdminCategoryService { + return &AdminCategoryService{ + repo: repo, + } +} + +func (h *AdminCategoryService) GetAll() ([]domain.Category, error) { + return h.repo.GetAll() +} + +func (h *AdminCategoryService) Create(category *domain.Category) error { + category.Slug = utils.Slugify(category.Name) + + return h.repo.Create(category) +} + +func (h *AdminCategoryService) Update(id uint, request ports_admin.UpdateCategoryInterface) error { + request.Slug = utils.Slugify(request.Name) + + return h.repo.Update(id, request) +} + +func (h *AdminCategoryService) Delete(id uint) error { + return h.repo.Delete(id) +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go index e2a5b99..f4216e9 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -333,7 +333,7 @@ func NormalizeWhitespace(str string) string { func Slugify(s string) string { s = strings.ToLower(s) - reg, _ := regexp.Compile(`[^a-z0-9\s]+`) + reg, _ := regexp.Compile(`[^a-z0-9\s\-]+`) s = reg.ReplaceAllString(s, "") s = strings.ReplaceAll(s, " ", "-") diff --git a/tests/unit/db/admin/category_repository_mysql_test.go b/tests/unit/db/admin/category_repository_mysql_test.go new file mode 100644 index 0000000..5576efc --- /dev/null +++ b/tests/unit/db/admin/category_repository_mysql_test.go @@ -0,0 +1,262 @@ +package tests + +import ( + "fmt" + db_admin "gcstatus/internal/adapters/db/admin" + "gcstatus/internal/domain" + "gcstatus/internal/errors" + ports_admin "gcstatus/internal/ports/admin" + "gcstatus/internal/utils" + testutils "gcstatus/tests/utils" + "net/http" + "regexp" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" +) + +func TestAdminCategoryRepositoryMySQL_GetAll(t *testing.T) { + fixedTime := time.Now() + gormDB, mock := testutils.Setup(t) + + repo := db_admin.NewAdminCategoryRepositoryMySQL(gormDB) + + testCases := map[string]struct { + mockBehavior func() + expectedLen int + expectedErr error + }{ + "success case": { + mockBehavior: func() { + rows := sqlmock.NewRows([]string{"id", "name", "created_at", "updated_at"}). + AddRow(1, "Category 1", fixedTime, fixedTime). + AddRow(2, "Category 2", fixedTime, fixedTime) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `categories` WHERE `categories`.`deleted_at` IS NULL")). + WillReturnRows(rows) + }, + expectedLen: 2, + expectedErr: nil, + }, + "no records found": { + mockBehavior: func() { + rows := sqlmock.NewRows([]string{"id", "name", "created_at", "updated_at"}) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `categories` WHERE `categories`.`deleted_at` IS NULL")). + WillReturnRows(rows) + }, + expectedLen: 0, + expectedErr: nil, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + tc.mockBehavior() + + levels, err := repo.GetAll() + + assert.Equal(t, tc.expectedErr, err) + assert.Len(t, levels, tc.expectedLen) + + assert.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestAdminCategoryRepositoryMySQL_Create(t *testing.T) { + fixedTime := time.Now() + + testCases := map[string]struct { + category *domain.Category + mockBehavior func(mock sqlmock.Sqlmock, category *domain.Category) + expectedErr error + expectedSlug string + }{ + "success case": { + category: &domain.Category{ + Name: "Category 1", + CreatedAt: fixedTime, + UpdatedAt: fixedTime, + }, + mockBehavior: func(mock sqlmock.Sqlmock, category *domain.Category) { + expectedSlug := utils.Slugify(category.Name) + + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO `categories`"). + WithArgs( + sqlmock.AnyArg(), + sqlmock.AnyArg(), + sqlmock.AnyArg(), + category.Name, + expectedSlug, + ). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + }, + expectedErr: nil, + expectedSlug: utils.Slugify("Category 1"), + }, + "Failure - Insert Error": { + category: &domain.Category{ + Name: "Category 1", + CreatedAt: fixedTime, + UpdatedAt: fixedTime, + }, + mockBehavior: func(mock sqlmock.Sqlmock, category *domain.Category) { + expectedSlug := utils.Slugify(category.Name) + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO `categories`"). + WithArgs( + sqlmock.AnyArg(), + sqlmock.AnyArg(), + sqlmock.AnyArg(), + category.Name, + expectedSlug, + ). + WillReturnError(fmt.Errorf("database error")) + mock.ExpectRollback() + }, + expectedErr: fmt.Errorf("database error"), + expectedSlug: utils.Slugify("Category 1"), + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + gormDB, mock := testutils.Setup(t) + + repo := db_admin.NewAdminCategoryRepositoryMySQL(gormDB) + + tc.category.Slug = utils.Slugify(tc.category.Name) + + tc.mockBehavior(mock, tc.category) + + err := repo.Create(tc.category) + + assert.Equal(t, tc.expectedSlug, tc.category.Slug) + assert.Equal(t, tc.expectedErr, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestUpdatePicture(t *testing.T) { + gormDB, mock := testutils.Setup(t) + + repo := db_admin.NewAdminCategoryRepositoryMySQL(gormDB) + + tests := map[string]struct { + categoryID uint + request ports_admin.UpdateCategoryInterface + mock func(request ports_admin.UpdateCategoryInterface) + expectErr bool + }{ + "successful picture update": { + categoryID: 1, + request: ports_admin.UpdateCategoryInterface{ + Name: "Category 1", + Slug: "category-1", + }, + mock: func(request ports_admin.UpdateCategoryInterface) { + mock.ExpectBegin() + mock.ExpectExec(regexp.QuoteMeta("UPDATE `categories` SET `name`=?,`slug`=?,`updated_at`=? WHERE id = ? AND `categories`.`deleted_at` IS NULL")). + WithArgs( + request.Name, + request.Slug, + sqlmock.AnyArg(), + 1, + ). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + }, + expectErr: false, + }, + "failed picture update due to database error": { + categoryID: 2, + request: ports_admin.UpdateCategoryInterface{ + Name: "Category 1", + Slug: "category-1", + }, + mock: func(request ports_admin.UpdateCategoryInterface) { + mock.ExpectBegin() + mock.ExpectExec(regexp.QuoteMeta("UPDATE `categories` SET `name`=?,`slug`=?,`updated_at`=? WHERE id = ? AND `categories`.`deleted_at` IS NULL")). + WithArgs( + request.Name, + request.Slug, + sqlmock.AnyArg(), + 2, + ). + WillReturnError(fmt.Errorf("database error")) + mock.ExpectRollback() + }, + expectErr: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + tt.mock(tt.request) + err := repo.Update(tt.categoryID, tt.request) + + if (err != nil) != tt.expectErr { + t.Errorf("expected error: %v, got: %v", tt.expectErr, err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("there were unfulfilled expectations: %s", err) + } + }) + } +} + +func TestAdminCategoryRepositoryMySQL_Delete(t *testing.T) { + gormDB, mock := testutils.Setup(t) + repo := db_admin.NewAdminCategoryRepositoryMySQL(gormDB) + + testCases := map[string]struct { + categoryID uint + mockBehavior func() + expectedErr error + }{ + "successful delete": { + categoryID: 1, + mockBehavior: func() { + mock.ExpectBegin() + mock.ExpectExec(regexp.QuoteMeta("UPDATE `categories` SET `deleted_at`=? WHERE `categories`.`id` = ? AND `categories`.`deleted_at` IS NULL")). + WithArgs(sqlmock.AnyArg(), 1). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + }, + expectedErr: nil, + }, + "category not found": { + categoryID: 99, + mockBehavior: func() { + mock.ExpectBegin() + mock.ExpectExec(regexp.QuoteMeta("UPDATE `categories` SET `deleted_at`=? WHERE `categories`.`id` = ? AND `categories`.`deleted_at` IS NULL")). + WithArgs(sqlmock.AnyArg(), 99). + WillReturnError(errors.NewHttpError(http.StatusNotFound, "category not found")) + mock.ExpectRollback() + }, + expectedErr: errors.NewHttpError(http.StatusNotFound, "category not found."), + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + + tc.mockBehavior() + + err := repo.Delete(tc.categoryID) + + if tc.expectedErr != nil { + assert.EqualError(t, err, tc.expectedErr.Error()) + } else { + assert.NoError(t, err) + } + + assert.NoError(t, mock.ExpectationsWereMet()) + }) + } +} diff --git a/tests/unit/ports/admin/category_repository_test.go b/tests/unit/ports/admin/category_repository_test.go new file mode 100644 index 0000000..62a0c49 --- /dev/null +++ b/tests/unit/ports/admin/category_repository_test.go @@ -0,0 +1,255 @@ +package tests + +import ( + "errors" + "gcstatus/internal/domain" + ports_admin "gcstatus/internal/ports/admin" + "gcstatus/internal/utils" + "testing" + "time" +) + +type MockAdminCategoryRepository struct { + categories map[uint]*domain.Category +} + +func NewMockAdminCategoryRepository() *MockAdminCategoryRepository { + return &MockAdminCategoryRepository{ + categories: make(map[uint]*domain.Category), + } +} + +func (m *MockAdminCategoryRepository) GetAll() ([]domain.Category, error) { + var categories []domain.Category + for _, categorie := range m.categories { + categories = append(categories, *categorie) + } + return categories, nil +} + +func (m *MockAdminCategoryRepository) CreateCategory(category *domain.Category) error { + if category == nil { + return errors.New("invalid category data") + } + m.categories[category.ID] = category + return nil +} + +func (m *MockAdminCategoryRepository) Update(id uint, request ports_admin.UpdateCategoryInterface) error { + if request.Name == "" || request.Slug == "" { + return errors.New("invalid payload data") + } + if _, exists := m.categories[id]; !exists { + return errors.New("category not found") + } + for _, category := range m.categories { + if category.ID == id { + category.Name = request.Name + category.Slug = utils.Slugify(request.Name) + } + } + + return nil +} + +func (m *MockAdminCategoryRepository) Delete(id uint) error { + if _, exists := m.categories[id]; !exists { + return errors.New("category not found") + } + delete(m.categories, id) + return nil +} + +func TestMockAdminCategoryRepository_GetAll(t *testing.T) { + fixedTime := time.Now() + + testCases := map[string]struct { + expectedCategoriesCount int + mockCreateCategories func(repo *MockAdminCategoryRepository) + }{ + "multiple categories": { + expectedCategoriesCount: 2, + mockCreateCategories: func(repo *MockAdminCategoryRepository) { + if err := repo.CreateCategory(&domain.Category{ + ID: 1, + Name: "Category 1", + CreatedAt: fixedTime, + UpdatedAt: fixedTime, + }); err != nil { + t.Fatalf("failed to create the category: %s", err.Error()) + } + if err := repo.CreateCategory(&domain.Category{ + ID: 2, + Name: "Category 2", + CreatedAt: fixedTime, + UpdatedAt: fixedTime, + }); err != nil { + t.Fatalf("failed to create the category: %s", err.Error()) + } + }, + }, + "no categories": { + expectedCategoriesCount: 0, + mockCreateCategories: func(repo *MockAdminCategoryRepository) {}, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + mockRepo := NewMockAdminCategoryRepository() + + tc.mockCreateCategories(mockRepo) + + categories, err := mockRepo.GetAll() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if len(categories) != tc.expectedCategoriesCount { + t.Fatalf("expected %d categories, got %d", tc.expectedCategoriesCount, len(categories)) + } + }) + } +} + +func TestMockAdminCategoryRepository_Update(t *testing.T) { + testCases := map[string]struct { + categoryID uint + updateRequest ports_admin.UpdateCategoryInterface + setupCategories func(repo *MockAdminCategoryRepository) + expectedError error + expectedUpdatedName string + }{ + "successful update": { + categoryID: 1, + updateRequest: ports_admin.UpdateCategoryInterface{ + Name: "Updated Category 1", + Slug: "updated-category-1", + }, + setupCategories: func(repo *MockAdminCategoryRepository) { + if err := repo.CreateCategory(&domain.Category{ID: 1, Name: "Category 1"}); err != nil { + t.Fatalf("failed to create category: %+v", err) + } + }, + expectedError: nil, + expectedUpdatedName: "Updated Category 1", + }, + "invalid payload - empty name": { + categoryID: 1, + updateRequest: ports_admin.UpdateCategoryInterface{ + Name: "", + Slug: "some-slug", + }, + setupCategories: func(repo *MockAdminCategoryRepository) { + if err := repo.CreateCategory(&domain.Category{ID: 1, Name: "Category 1"}); err != nil { + t.Fatalf("failed to create category: %+v", err) + } + }, + expectedError: errors.New("invalid payload data"), + expectedUpdatedName: "Category 1", + }, + "invalid payload - empty slug": { + categoryID: 1, + updateRequest: ports_admin.UpdateCategoryInterface{ + Name: "Category 1", + Slug: "", + }, + setupCategories: func(repo *MockAdminCategoryRepository) { + if err := repo.CreateCategory(&domain.Category{ID: 1, Name: "Category 1"}); err != nil { + t.Fatalf("failed to create category: %+v", err) + } + }, + expectedError: errors.New("invalid payload data"), + expectedUpdatedName: "Category 1", + }, + "category not found": { + categoryID: 99, + updateRequest: ports_admin.UpdateCategoryInterface{ + Name: "Nonexistent Category", + Slug: "nonexistent-category", + }, + setupCategories: func(repo *MockAdminCategoryRepository) {}, + expectedError: errors.New("category not found"), + expectedUpdatedName: "", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + mockRepo := NewMockAdminCategoryRepository() + + tc.setupCategories(mockRepo) + + err := mockRepo.Update(tc.categoryID, tc.updateRequest) + + if tc.expectedError != nil { + if err == nil || err.Error() != tc.expectedError.Error() { + t.Fatalf("expected error %v, got %v", tc.expectedError, err) + } + } else if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if category, exists := mockRepo.categories[tc.categoryID]; exists { + if category.Name != tc.expectedUpdatedName { + t.Fatalf("expected category name to be %s, got %s", tc.expectedUpdatedName, category.Name) + } + } else if tc.expectedUpdatedName != "" { + t.Fatalf("expected category %d to exist, but it does not", tc.categoryID) + } + }) + } +} + +func TestMockAdminCategoryRepository_Delete(t *testing.T) { + testCases := map[string]struct { + categoryToDelete uint + expectedError error + setupCategories func(repo *MockAdminCategoryRepository) + }{ + "successful deletion": { + categoryToDelete: 1, + expectedError: nil, + setupCategories: func(repo *MockAdminCategoryRepository) { + if err := repo.CreateCategory(&domain.Category{ID: 1, Name: "Category 1"}); err != nil { + t.Fatalf("failed to create category: %+v", err) + } + }, + }, + "category does not exist": { + categoryToDelete: 99, + expectedError: errors.New("category not found"), + setupCategories: func(repo *MockAdminCategoryRepository) {}, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + mockRepo := NewMockAdminCategoryRepository() + + tc.setupCategories(mockRepo) + + err := mockRepo.Delete(tc.categoryToDelete) + + if tc.expectedError != nil { + if err == nil || err.Error() != tc.expectedError.Error() { + t.Fatalf("expected error %v, got %v", tc.expectedError, err) + } + } else { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if _, exists := mockRepo.categories[tc.categoryToDelete]; exists { + t.Fatalf("expected category %d to be deleted, but it still exists", tc.categoryToDelete) + } + } + }) + } +}