first commit

This commit is contained in:
2026-07-30 09:51:25 +07:00
commit e3aca176b7
82 changed files with 7847 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
package user
import (
"cardverse/internal/pkg/utils"
"time"
)
type CreateUserRequest struct {
Name string `json:"name" binding:"required,min=2,max=100"`
Email string `json:"email" binding:"required,email,max=150"`
Password string `json:"password" binding:"required,min=6"`
Role string `json:"role" binding:"omitempty,oneof=user admin superadmin"`
}
type UpdateUserRequest struct {
Name string `json:"name" binding:"omitempty,min=2,max=100"`
Email string `json:"email" binding:"omitempty,email,max=150"`
AvatarURL string `json:"avatar_url" binding:"omitempty,max=500"`
Role string `json:"role" binding:"omitempty,oneof=user admin superadmin"`
Status string `json:"status" binding:"omitempty,oneof=active suspended unverified"`
}
type UpdateProfileRequest struct {
Name string `json:"name" binding:"omitempty,min=2,max=100"`
AvatarURL string `json:"avatar_url" binding:"omitempty,max=500"`
}
type ListUserQuery struct {
Page int `form:"page,default=1" binding:"omitempty,min=1"`
Limit int `form:"limit,default=20" binding:"omitempty,min=1,max=100"`
Search string `form:"search"`
Role string `form:"role" binding:"omitempty"`
Status string `form:"status" binding:"omitempty"`
}
type UserResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL *string `json:"avatar_url"`
Role string `json:"role"`
Status string `json:"status"`
IsEmailVerified bool `json:"is_email_verified"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy *uint `json:"created_by,omitempty"`
UpdatedBy *uint `json:"updated_by,omitempty"`
}
func ToUserResponse(u User) UserResponse {
return UserResponse{
ID: u.ID,
Name: u.Name,
Email: u.Email,
AvatarURL: utils.FormatMediaURLPtr(u.AvatarURL),
Role: u.Role,
Status: u.Status,
IsEmailVerified: u.IsEmailVerified,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
CreatedBy: u.CreatedBy,
UpdatedBy: u.UpdatedBy,
}
}
func ToUserResponseList(users []User) []UserResponse {
result := make([]UserResponse, 0, len(users))
for _, u := range users {
result = append(result, ToUserResponse(u))
}
return result
}

View File

@@ -0,0 +1,315 @@
package user
import (
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
"image/png"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"cardverse/internal/pkg/response"
"cardverse/internal/pkg/validator"
"github.com/gin-gonic/gin"
)
type Handler struct {
service Service
}
func NewHandler(service Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) GetProfile(c *gin.Context) {
userIDVal, exists := c.Get("user_id")
if !exists {
response.Error(c, http.StatusUnauthorized, "autentikasi diperlukan", nil)
return
}
userID := userIDVal.(uint)
u, err := h.service.GetByID(userID)
if err != nil {
response.Error(c, http.StatusNotFound, err.Error(), nil)
return
}
response.Success(c, http.StatusOK, "berhasil mengambil profil user", ToUserResponse(*u))
}
func (h *Handler) UpdateProfile(c *gin.Context) {
userIDVal, exists := c.Get("user_id")
if !exists {
response.Error(c, http.StatusUnauthorized, "autentikasi diperlukan", nil)
return
}
userID := userIDVal.(uint)
var req UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
return
}
updatedUser, err := h.service.UpdateProfile(userID, req)
if err != nil {
response.Error(c, http.StatusInternalServerError, "gagal mengupdate profil", err.Error())
return
}
response.Success(c, http.StatusOK, "profil berhasil diupdate", ToUserResponse(*updatedUser))
}
func (h *Handler) UploadAvatar(c *gin.Context) {
userIDVal, exists := c.Get("user_id")
if !exists {
response.Error(c, http.StatusUnauthorized, "autentikasi diperlukan", nil)
return
}
userID := userIDVal.(uint)
fileHeader, err := c.FormFile("avatar")
if err != nil {
response.Error(c, http.StatusBadRequest, "file avatar wajib diunggah (form field: avatar)", nil)
return
}
// Batas ukuran file 5 MB
if fileHeader.Size > 5*1024*1024 {
response.Error(c, http.StatusBadRequest, "ukuran file maksimal 5MB", nil)
return
}
srcFile, err := fileHeader.Open()
if err != nil {
response.Error(c, http.StatusInternalServerError, "gagal membaca file avatar", err.Error())
return
}
defer srcFile.Close()
// Dekode gambar (support JPG, JPEG, PNG, GIF)
img, _, err := image.Decode(srcFile)
if err != nil {
response.Error(c, http.StatusBadRequest, "file yang diunggah bukan format gambar yang valid", err.Error())
return
}
// Simpan ke direktori: ./public/images/avatars
uploadDir := "./public/images/avatars"
if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil {
response.Error(c, http.StatusInternalServerError, "gagal membuat direktori penyimpanan avatar", err.Error())
return
}
filename := fmt.Sprintf("avatar_%d_%d.png", userID, time.Now().UnixNano())
filepathDst := filepath.Join(uploadDir, filename)
out, err := os.Create(filepathDst)
if err != nil {
response.Error(c, http.StatusInternalServerError, "gagal membuat file avatar", err.Error())
return
}
defer out.Close()
// Encode & kompresi ke format PNG
encoder := png.Encoder{CompressionLevel: png.BestCompression}
if err := encoder.Encode(out, img); err != nil {
response.Error(c, http.StatusInternalServerError, "gagal mengompresi gambar ke format PNG", err.Error())
return
}
avatarURL := fmt.Sprintf("/images/avatars/%s", filename)
updatedUser, err := h.service.UpdateAvatar(userID, avatarURL)
if err != nil {
response.Error(c, http.StatusInternalServerError, "gagal memperbarui avatar user", err.Error())
return
}
response.Success(c, http.StatusOK, "avatar berhasil diunggah", ToUserResponse(*updatedUser))
}
func (h *Handler) Create(c *gin.Context) {
creatorID := getContextUserID(c)
var req CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
return
}
newUser, err := h.service.Create(creatorID, req)
if err != nil {
if errors.Is(err, ErrEmailTaken) {
response.Error(c, http.StatusConflict, err.Error(), nil)
return
}
if errors.Is(err, ErrForbiddenRoleCreation) {
response.Error(c, http.StatusForbidden, err.Error(), nil)
return
}
response.Error(c, http.StatusInternalServerError, "gagal membuat user", err.Error())
return
}
response.Success(c, http.StatusCreated, "user berhasil dibuat", ToUserResponse(*newUser))
}
func (h *Handler) GetAll(c *gin.Context) {
var query ListUserQuery
if err := c.ShouldBindQuery(&query); err != nil {
response.Error(c, http.StatusBadRequest, "query parameter tidak valid", validator.TranslateError(err))
return
}
users, total, err := h.service.GetAllPaginated(query)
if err != nil {
response.Error(c, http.StatusInternalServerError, "gagal mengambil data user", err.Error())
return
}
pagination := response.NewPagination(query.Page, query.Limit, total)
response.SuccessWithPagination(c, http.StatusOK, "berhasil mengambil data user", ToUserResponseList(users), pagination)
}
func (h *Handler) GetByID(c *gin.Context) {
id, err := parseID(c)
if err != nil {
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
{Field: "id", Message: "id harus berupa angka"},
})
return
}
u, err := h.service.GetByID(id)
if err != nil {
response.Error(c, http.StatusNotFound, err.Error(), nil)
return
}
response.Success(c, http.StatusOK, "berhasil mengambil data user", ToUserResponse(*u))
}
func (h *Handler) Update(c *gin.Context) {
modifierID := getContextUserIDValue(c)
id, err := parseID(c)
if err != nil {
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
{Field: "id", Message: "id harus berupa angka"},
})
return
}
var req UpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
return
}
updatedUser, err := h.service.Update(modifierID, id, req)
if err != nil {
if errors.Is(err, ErrEmailTaken) {
response.Error(c, http.StatusConflict, err.Error(), nil)
return
}
if errors.Is(err, ErrForbiddenRoleCreation) {
response.Error(c, http.StatusForbidden, err.Error(), nil)
return
}
response.Error(c, http.StatusNotFound, err.Error(), nil)
return
}
response.Success(c, http.StatusOK, "user berhasil diupdate", ToUserResponse(*updatedUser))
}
func (h *Handler) Suspend(c *gin.Context) {
modifierID := getContextUserIDValue(c)
id, err := parseID(c)
if err != nil {
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
{Field: "id", Message: "id harus berupa angka"},
})
return
}
updatedUser, err := h.service.SuspendUser(modifierID, id)
if err != nil {
response.Error(c, http.StatusNotFound, err.Error(), nil)
return
}
response.Success(c, http.StatusOK, "user berhasil disuspensi", ToUserResponse(*updatedUser))
}
func (h *Handler) Unsuspend(c *gin.Context) {
modifierID := getContextUserIDValue(c)
id, err := parseID(c)
if err != nil {
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
{Field: "id", Message: "id harus berupa angka"},
})
return
}
updatedUser, err := h.service.UnsuspendUser(modifierID, id)
if err != nil {
response.Error(c, http.StatusNotFound, err.Error(), nil)
return
}
response.Success(c, http.StatusOK, "suspensi user berhasil dicabut", ToUserResponse(*updatedUser))
}
func (h *Handler) Delete(c *gin.Context) {
id, err := parseID(c)
if err != nil {
response.Error(c, http.StatusBadRequest, "id tidak valid", []validator.FieldError{
{Field: "id", Message: "id harus berupa angka"},
})
return
}
if err := h.service.Delete(id); err != nil {
response.Error(c, http.StatusNotFound, err.Error(), nil)
return
}
response.Success(c, http.StatusOK, "user berhasil dihapus", nil)
}
func parseID(c *gin.Context) (uint, error) {
idParam := c.Param("id")
id, err := strconv.ParseUint(idParam, 10, 64)
if err != nil {
return 0, err
}
return uint(id), nil
}
func getContextUserID(c *gin.Context) *uint {
if val, exists := c.Get("user_id"); exists {
if userID, ok := val.(uint); ok {
return &userID
}
}
return nil
}
func getContextUserIDValue(c *gin.Context) uint {
if val, exists := c.Get("user_id"); exists {
if userID, ok := val.(uint); ok {
return userID
}
}
return 0
}

View File

@@ -0,0 +1,36 @@
package user
import "time"
const (
RoleSuperAdmin = "superadmin"
RoleAdmin = "admin"
RoleUser = "user"
StatusActive = "active"
StatusSuspended = "suspended"
StatusUnverified = "unverified"
)
type User struct {
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"type:varchar(100);not null"`
Email string `json:"email" gorm:"type:varchar(150);uniqueIndex;not null"`
Password string `json:"-" gorm:"type:varchar(255)"`
GoogleID *string `json:"google_id,omitempty" gorm:"type:varchar(255);uniqueIndex;default:null"`
AvatarURL *string `json:"avatar_url,omitempty" gorm:"type:varchar(500);default:null"`
Role string `json:"role" gorm:"type:varchar(30);not null;default:'user';index"`
Status string `json:"status" gorm:"type:varchar(30);not null;default:'active';index"`
IsEmailVerified bool `json:"is_email_verified" gorm:"default:false"`
VerificationToken string `json:"-" gorm:"type:varchar(255)"`
ResetPasswordToken string `json:"-" gorm:"type:varchar(255)"`
ResetPasswordExpiresAt *time.Time `json:"-"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
CreatedBy *uint `json:"created_by" gorm:"default:null"`
UpdatedBy *uint `json:"updated_by" gorm:"default:null"`
}
func (User) TableName() string {
return "users"
}

View File

@@ -0,0 +1,125 @@
package user
import "gorm.io/gorm"
type Repository interface {
Create(u *User) error
FindAll() ([]User, error)
FindAllPaginated(page, limit int, query ListUserQuery) ([]User, int64, error)
FindByID(id uint) (*User, error)
FindByEmail(email string) (*User, error)
FindByGoogleID(googleID string) (*User, error)
FindByVerificationToken(token string) (*User, error)
FindByResetToken(token string) (*User, error)
Update(u *User) error
Delete(id uint) error
}
type repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) Repository {
return &repository{db: db}
}
var userSelectColumns = []string{
"id", "name", "email", "password", "role", "status", "avatar_url",
"google_id", "is_email_verified", "verification_token",
"reset_password_token", "reset_password_expires_at", "created_at", "updated_at",
}
func (r *repository) Create(u *User) error {
return r.db.Create(u).Error
}
func (r *repository) FindAll() ([]User, error) {
var users []User
err := r.db.Select(userSelectColumns).Find(&users).Error
return users, err
}
func (r *repository) FindAllPaginated(page, limit int, queryParams ListUserQuery) ([]User, int64, error) {
var users []User
var total int64
query := r.db.Model(&User{}).Select(userSelectColumns)
if queryParams.Role != "" {
query = query.Where("role = ?", queryParams.Role)
}
if queryParams.Status != "" {
query = query.Where("status = ?", queryParams.Status)
}
if queryParams.Search != "" {
pattern := "%" + queryParams.Search + "%"
query = query.Where("name ILIKE ? OR email ILIKE ?", pattern, pattern)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * limit
err := query.Order("id DESC").Limit(limit).Offset(offset).Find(&users).Error
if err != nil {
return nil, 0, err
}
return users, total, nil
}
func (r *repository) FindByID(id uint) (*User, error) {
var u User
err := r.db.Select(userSelectColumns).First(&u, id).Error
if err != nil {
return nil, err
}
return &u, nil
}
func (r *repository) FindByEmail(email string) (*User, error) {
var u User
err := r.db.Select(userSelectColumns).Where("email = ?", email).First(&u).Error
if err != nil {
return nil, err
}
return &u, nil
}
func (r *repository) FindByGoogleID(googleID string) (*User, error) {
var u User
err := r.db.Select(userSelectColumns).Where("google_id = ?", googleID).First(&u).Error
if err != nil {
return nil, err
}
return &u, nil
}
func (r *repository) FindByVerificationToken(token string) (*User, error) {
var u User
err := r.db.Select(userSelectColumns).Where("verification_token = ?", token).First(&u).Error
if err != nil {
return nil, err
}
return &u, nil
}
func (r *repository) FindByResetToken(token string) (*User, error) {
var u User
err := r.db.Select(userSelectColumns).Where("reset_password_token = ?", token).First(&u).Error
if err != nil {
return nil, err
}
return &u, nil
}
func (r *repository) Update(u *User) error {
return r.db.Save(u).Error
}
func (r *repository) Delete(id uint) error {
return r.db.Delete(&User{}, id).Error
}

View File

@@ -0,0 +1,84 @@
package user
type mockRepository struct {
createFunc func(u *User) error
findAllFunc func() ([]User, error)
findAllPaginatedFunc func(page, limit int, query ListUserQuery) ([]User, int64, error)
findByIDFunc func(id uint) (*User, error)
findByEmailFunc func(email string) (*User, error)
findByGoogleIDFunc func(googleID string) (*User, error)
findByVerificationTokenFunc func(token string) (*User, error)
findByResetTokenFunc func(token string) (*User, error)
updateFunc func(u *User) error
deleteFunc func(id uint) error
}
func (m *mockRepository) Create(u *User) error {
if m.createFunc != nil {
return m.createFunc(u)
}
return nil
}
func (m *mockRepository) FindAll() ([]User, error) {
if m.findAllFunc != nil {
return m.findAllFunc()
}
return nil, nil
}
func (m *mockRepository) FindAllPaginated(page, limit int, query ListUserQuery) ([]User, int64, error) {
if m.findAllPaginatedFunc != nil {
return m.findAllPaginatedFunc(page, limit, query)
}
return nil, 0, nil
}
func (m *mockRepository) FindByID(id uint) (*User, error) {
if m.findByIDFunc != nil {
return m.findByIDFunc(id)
}
return nil, nil
}
func (m *mockRepository) FindByEmail(email string) (*User, error) {
if m.findByEmailFunc != nil {
return m.findByEmailFunc(email)
}
return nil, nil
}
func (m *mockRepository) FindByGoogleID(googleID string) (*User, error) {
if m.findByGoogleIDFunc != nil {
return m.findByGoogleIDFunc(googleID)
}
return nil, nil
}
func (m *mockRepository) FindByVerificationToken(token string) (*User, error) {
if m.findByVerificationTokenFunc != nil {
return m.findByVerificationTokenFunc(token)
}
return nil, nil
}
func (m *mockRepository) FindByResetToken(token string) (*User, error) {
if m.findByResetTokenFunc != nil {
return m.findByResetTokenFunc(token)
}
return nil, nil
}
func (m *mockRepository) Update(u *User) error {
if m.updateFunc != nil {
return m.updateFunc(u)
}
return nil
}
func (m *mockRepository) Delete(id uint) error {
if m.deleteFunc != nil {
return m.deleteFunc(id)
}
return nil
}

View File

@@ -0,0 +1,37 @@
package user
import (
"cardverse/internal/middleware"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func RegisterRoutes(router *gin.RouterGroup, db *gorm.DB) {
repo := NewRepository(db)
svc := NewService(repo)
handler := NewHandler(svc)
// Route khusus user yang sedang login untuk kelola profil sendiri
me := router.Group("/users/me")
me.Use(middleware.AuthRequired())
{
me.GET("", handler.GetProfile)
me.PUT("", handler.UpdateProfile)
me.POST("/avatar", handler.UploadAvatar)
}
// Route khusus Admin/Superadmin untuk kelola semua data user
admin := router.Group("/users")
admin.Use(middleware.AuthRequired())
admin.Use(middleware.RequireRoles(RoleAdmin, RoleSuperAdmin))
{
admin.GET("", handler.GetAll)
admin.POST("", handler.Create)
admin.GET("/:id", handler.GetByID)
admin.PUT("/:id", handler.Update)
admin.PUT("/:id/suspend", handler.Suspend)
admin.PUT("/:id/unsuspend", handler.Unsuspend)
admin.DELETE("/:id", handler.Delete)
}
}

View File

@@ -0,0 +1,233 @@
package user
import (
"errors"
"cardverse/internal/pkg/utils"
)
var ErrEmailTaken = errors.New("email sudah terdaftar")
var ErrUserNotFound = errors.New("user tidak ditemukan")
var ErrUserSuspended = errors.New("akun anda sedang disuspensi")
var ErrForbiddenRoleCreation = errors.New("hanya superadmin yang dapat membuat atau mengubah akun dengan role admin atau superadmin")
type Service interface {
Create(creatorID *uint, req CreateUserRequest) (*User, error)
GetAll() ([]User, error)
GetAllPaginated(query ListUserQuery) ([]User, int64, error)
GetByID(id uint) (*User, error)
Update(modifierID uint, id uint, req UpdateUserRequest) (*User, error)
UpdateProfile(userID uint, req UpdateProfileRequest) (*User, error)
UpdateAvatar(userID uint, avatarURL string) (*User, error)
SuspendUser(modifierID uint, id uint) (*User, error)
UnsuspendUser(modifierID uint, id uint) (*User, error)
Delete(id uint) error
}
type service struct {
repo Repository
}
func NewService(repo Repository) Service {
return &service{repo: repo}
}
func (s *service) Create(creatorID *uint, req CreateUserRequest) (*User, error) {
existing, _ := s.repo.FindByEmail(req.Email)
if existing != nil {
return nil, ErrEmailTaken
}
role := req.Role
if role == "" {
role = RoleUser
}
// Batasan role: Hanya Superadmin yang boleh membuat akun dengan role admin atau superadmin
if role == RoleAdmin || role == RoleSuperAdmin {
if creatorID == nil {
return nil, ErrForbiddenRoleCreation
}
creator, err := s.repo.FindByID(*creatorID)
if err != nil || creator.Role != RoleSuperAdmin {
return nil, ErrForbiddenRoleCreation
}
}
hashedPassword, err := utils.HashPassword(req.Password)
if err != nil {
return nil, err
}
newUser := &User{
Name: req.Name,
Email: req.Email,
Password: hashedPassword,
Role: role,
Status: StatusActive,
IsEmailVerified: true,
CreatedBy: creatorID,
}
if err := s.repo.Create(newUser); err != nil {
return nil, err
}
return newUser, nil
}
func (s *service) GetAll() ([]User, error) {
return s.repo.FindAll()
}
func (s *service) GetAllPaginated(query ListUserQuery) ([]User, int64, error) {
page := query.Page
if page < 1 {
page = 1
}
limit := query.Limit
if limit < 1 {
limit = 20
}
return s.repo.FindAllPaginated(page, limit, query)
}
func (s *service) GetByID(id uint) (*User, error) {
u, err := s.repo.FindByID(id)
if err != nil {
return nil, ErrUserNotFound
}
return u, nil
}
func (s *service) Update(modifierID uint, id uint, req UpdateUserRequest) (*User, error) {
u, err := s.repo.FindByID(id)
if err != nil {
return nil, ErrUserNotFound
}
if req.Name != "" {
u.Name = req.Name
}
if req.Email != "" && req.Email != u.Email {
existing, _ := s.repo.FindByEmail(req.Email)
if existing != nil {
return nil, ErrEmailTaken
}
u.Email = req.Email
}
if req.AvatarURL != "" {
if u.AvatarURL != nil && *u.AvatarURL != "" && *u.AvatarURL != req.AvatarURL {
utils.DeleteLocalFile(*u.AvatarURL)
}
u.AvatarURL = &req.AvatarURL
}
if req.Role != "" && req.Role != u.Role {
// Batasan role: Hanya Superadmin yang boleh mengubah role ke admin atau superadmin
if req.Role == RoleAdmin || req.Role == RoleSuperAdmin {
creator, err := s.repo.FindByID(modifierID)
if err != nil || creator.Role != RoleSuperAdmin {
return nil, ErrForbiddenRoleCreation
}
}
u.Role = req.Role
}
if req.Status != "" {
u.Status = req.Status
}
u.UpdatedBy = &modifierID
if err := s.repo.Update(u); err != nil {
return nil, err
}
return u, nil
}
func (s *service) UpdateProfile(userID uint, req UpdateProfileRequest) (*User, error) {
u, err := s.repo.FindByID(userID)
if err != nil {
return nil, ErrUserNotFound
}
if req.Name != "" {
u.Name = req.Name
}
if req.AvatarURL != "" {
if u.AvatarURL != nil && *u.AvatarURL != "" && *u.AvatarURL != req.AvatarURL {
utils.DeleteLocalFile(*u.AvatarURL)
}
u.AvatarURL = &req.AvatarURL
}
u.UpdatedBy = &userID
if err := s.repo.Update(u); err != nil {
return nil, err
}
return u, nil
}
func (s *service) UpdateAvatar(userID uint, avatarURL string) (*User, error) {
u, err := s.repo.FindByID(userID)
if err != nil {
return nil, ErrUserNotFound
}
// Hapus file avatar lama jika ada
if u.AvatarURL != nil && *u.AvatarURL != "" && *u.AvatarURL != avatarURL {
utils.DeleteLocalFile(*u.AvatarURL)
}
u.AvatarURL = &avatarURL
u.UpdatedBy = &userID
if err := s.repo.Update(u); err != nil {
return nil, err
}
return u, nil
}
func (s *service) SuspendUser(modifierID uint, id uint) (*User, error) {
u, err := s.repo.FindByID(id)
if err != nil {
return nil, ErrUserNotFound
}
u.Status = StatusSuspended
u.UpdatedBy = &modifierID
if err := s.repo.Update(u); err != nil {
return nil, err
}
return u, nil
}
func (s *service) UnsuspendUser(modifierID uint, id uint) (*User, error) {
u, err := s.repo.FindByID(id)
if err != nil {
return nil, ErrUserNotFound
}
u.Status = StatusActive
u.UpdatedBy = &modifierID
if err := s.repo.Update(u); err != nil {
return nil, err
}
return u, nil
}
func (s *service) Delete(id uint) error {
_, err := s.repo.FindByID(id)
if err != nil {
return ErrUserNotFound
}
return s.repo.Delete(id)
}

View File

@@ -0,0 +1,264 @@
package user
import (
"errors"
"testing"
"cardverse/internal/pkg/utils"
)
func TestService_Create_Success(t *testing.T) {
repo := &mockRepository{
findByEmailFunc: func(email string) (*User, error) {
return nil, errors.New("record not found")
},
createFunc: func(u *User) error {
u.ID = 1
return nil
},
}
svc := NewService(repo)
req := CreateUserRequest{Name: "Budi", Email: "budi@mail.com", Password: "rahasia123"}
newUser, err := svc.Create(nil, req)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if newUser.Email != req.Email {
t.Errorf("expected email %s, got %s", req.Email, newUser.Email)
}
if newUser.Password == req.Password {
t.Error("stored password should be hashed, not plain text")
}
if !utils.CheckPassword(newUser.Password, req.Password) {
t.Error("hashed password does not match original password")
}
}
func TestService_Create_AdminRole_ForbiddenForRegularAdmin(t *testing.T) {
adminID := uint(5)
repo := &mockRepository{
findByEmailFunc: func(email string) (*User, error) {
return nil, errors.New("record not found")
},
findByIDFunc: func(id uint) (*User, error) {
return &User{ID: id, Role: RoleAdmin}, nil
},
}
svc := NewService(repo)
req := CreateUserRequest{Name: "New Admin", Email: "admin2@mail.com", Password: "rahasia123", Role: RoleAdmin}
_, err := svc.Create(&adminID, req)
if !errors.Is(err, ErrForbiddenRoleCreation) {
t.Fatalf("expected ErrForbiddenRoleCreation for regular admin creating another admin, got: %v", err)
}
}
func TestService_Create_AdminRole_AllowedForSuperAdmin(t *testing.T) {
superAdminID := uint(1)
repo := &mockRepository{
findByEmailFunc: func(email string) (*User, error) {
return nil, errors.New("record not found")
},
findByIDFunc: func(id uint) (*User, error) {
return &User{ID: id, Role: RoleSuperAdmin}, nil
},
createFunc: func(u *User) error {
u.ID = 10
return nil
},
}
svc := NewService(repo)
req := CreateUserRequest{Name: "New Admin", Email: "admin2@mail.com", Password: "rahasia123", Role: RoleAdmin}
newAdmin, err := svc.Create(&superAdminID, req)
if err != nil {
t.Fatalf("expected no error for superadmin creating an admin, got: %v", err)
}
if newAdmin.Role != RoleAdmin {
t.Errorf("expected role %s, got %s", RoleAdmin, newAdmin.Role)
}
}
func TestService_Create_EmailAlreadyTaken(t *testing.T) {
repo := &mockRepository{
findByEmailFunc: func(email string) (*User, error) {
return &User{ID: 99, Email: email}, nil
},
}
svc := NewService(repo)
_, err := svc.Create(nil, CreateUserRequest{Name: "Budi", Email: "budi@mail.com", Password: "rahasia123"})
if !errors.Is(err, ErrEmailTaken) {
t.Fatalf("expected ErrEmailTaken, got: %v", err)
}
}
func TestService_GetByID_Success(t *testing.T) {
repo := &mockRepository{
findByIDFunc: func(id uint) (*User, error) {
return &User{ID: id, Name: "Budi"}, nil
},
}
svc := NewService(repo)
u, err := svc.GetByID(1)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if u.ID != 1 {
t.Errorf("expected ID 1, got: %d", u.ID)
}
}
func TestService_GetByID_NotFound(t *testing.T) {
repo := &mockRepository{
findByIDFunc: func(id uint) (*User, error) {
return nil, errors.New("record not found")
},
}
svc := NewService(repo)
_, err := svc.GetByID(999)
if !errors.Is(err, ErrUserNotFound) {
t.Fatalf("expected ErrUserNotFound, got: %v", err)
}
}
func TestService_Update_Success(t *testing.T) {
repo := &mockRepository{
findByIDFunc: func(id uint) (*User, error) {
return &User{ID: id, Name: "Old Name"}, nil
},
updateFunc: func(u *User) error {
return nil
},
}
svc := NewService(repo)
updated, err := svc.Update(1, 1, UpdateUserRequest{Name: "New Name"})
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if updated.Name != "New Name" {
t.Errorf("expected Name 'New Name', got: '%s'", updated.Name)
}
}
func TestService_UpdateAvatar_Success(t *testing.T) {
repo := &mockRepository{
findByIDFunc: func(id uint) (*User, error) {
return &User{ID: id, Name: "User Test"}, nil
},
updateFunc: func(u *User) error {
return nil
},
}
svc := NewService(repo)
updated, err := svc.UpdateAvatar(1, "/images/avatars/avatar_1_123.png")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if updated.AvatarURL == nil || *updated.AvatarURL != "/images/avatars/avatar_1_123.png" {
t.Errorf("expected AvatarURL '/images/avatars/avatar_1_123.png', got: '%v'", updated.AvatarURL)
}
}
func TestService_Update_NotFound(t *testing.T) {
repo := &mockRepository{
findByIDFunc: func(id uint) (*User, error) {
return nil, errors.New("record not found")
},
}
svc := NewService(repo)
_, err := svc.Update(1, 1, UpdateUserRequest{Name: "Anything"})
if !errors.Is(err, ErrUserNotFound) {
t.Fatalf("expected ErrUserNotFound, got: %v", err)
}
}
func TestService_SuspendAndUnsuspend_Success(t *testing.T) {
repo := &mockRepository{
findByIDFunc: func(id uint) (*User, error) {
return &User{ID: id, Status: StatusActive}, nil
},
updateFunc: func(u *User) error {
return nil
},
}
svc := NewService(repo)
suspended, err := svc.SuspendUser(1, 2)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if suspended.Status != StatusSuspended {
t.Errorf("expected status 'suspended', got: '%s'", suspended.Status)
}
unsuspended, err := svc.UnsuspendUser(1, 2)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if unsuspended.Status != StatusActive {
t.Errorf("expected status 'active', got: '%s'", unsuspended.Status)
}
}
func TestService_Delete_Success(t *testing.T) {
deleteCalled := false
repo := &mockRepository{
findByIDFunc: func(id uint) (*User, error) {
return &User{ID: id}, nil
},
deleteFunc: func(id uint) error {
deleteCalled = true
return nil
},
}
svc := NewService(repo)
if err := svc.Delete(1); err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if !deleteCalled {
t.Error("repository.Delete should have been called")
}
}
func TestService_GetAllPaginated_DefaultNormalization(t *testing.T) {
var capturedPage, capturedLimit int
var capturedSearch string
repo := &mockRepository{
findAllPaginatedFunc: func(page, limit int, query ListUserQuery) ([]User, int64, error) {
capturedPage, capturedLimit, capturedSearch = page, limit, query.Search
return []User{{ID: 1}}, 1, nil
},
}
svc := NewService(repo)
_, total, err := svc.GetAllPaginated(ListUserQuery{Page: 0, Limit: 0, Search: "budi"})
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if total != 1 {
t.Errorf("expected total 1, got: %d", total)
}
if capturedPage != 1 {
t.Errorf("expected page normalized to 1, got: %d", capturedPage)
}
if capturedLimit != 20 {
t.Errorf("expected limit normalized to 20, got: %d", capturedLimit)
}
if capturedSearch != "budi" {
t.Errorf("expected search 'budi', got: '%s'", capturedSearch)
}
}