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,58 @@
package auth
type RegisterRequest 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"`
ConfirmPassword string `json:"confirm_password" binding:"required,eqfield=Password"`
}
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
type GoogleLoginRequest struct {
IDToken string `json:"id_token" binding:"required"`
}
type VerifyEmailRequest struct {
Token string `json:"token" binding:"required"`
}
type ForgotPasswordRequest struct {
Email string `json:"email" binding:"required,email"`
}
type ResetPasswordRequest struct {
Token string `json:"token" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
ConfirmNewPassword string `json:"confirm_new_password" binding:"required,eqfield=NewPassword"`
}
type RefreshTokenRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
type LogoutRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
type LoginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
User struct {
ID uint `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL *string `json:"avatar_url,omitempty"`
Role string `json:"role"`
Status string `json:"status"`
IsEmailVerified bool `json:"is_email_verified"`
} `json:"user"`
}
type TokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}

View File

@@ -0,0 +1,182 @@
package auth
import (
"errors"
"net/http"
"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) Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
return
}
u, token, err := h.service.Register(req)
if err != nil {
if errors.Is(err, ErrPasswordMismatch) {
response.Error(c, http.StatusBadRequest, err.Error(), nil)
return
}
if errors.Is(err, ErrEmailAlreadyExists) {
response.Error(c, http.StatusConflict, err.Error(), nil)
return
}
response.Error(c, http.StatusInternalServerError, "gagal mendaftar akun", err.Error())
return
}
response.Success(c, http.StatusCreated, "registrasi berhasil, silakan verifikasi email anda", gin.H{
"user_id": u.ID,
"email": u.Email,
"verification_token": token,
})
}
func (h *Handler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
return
}
result, err := h.service.Login(req)
if err != nil {
if errors.Is(err, ErrInvalidCredentials) {
response.Error(c, http.StatusUnauthorized, err.Error(), nil)
return
}
if errors.Is(err, ErrAccountSuspended) {
response.Error(c, http.StatusForbidden, err.Error(), nil)
return
}
response.Error(c, http.StatusInternalServerError, "gagal login", err.Error())
return
}
response.Success(c, http.StatusOK, "login berhasil", result)
}
func (h *Handler) GoogleLogin(c *gin.Context) {
var req GoogleLoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
return
}
result, err := h.service.GoogleLogin(req)
if err != nil {
if errors.Is(err, ErrGoogleVerificationFailed) {
response.Error(c, http.StatusUnauthorized, err.Error(), nil)
return
}
if errors.Is(err, ErrAccountSuspended) {
response.Error(c, http.StatusForbidden, err.Error(), nil)
return
}
response.Error(c, http.StatusInternalServerError, "gagal login via google", err.Error())
return
}
response.Success(c, http.StatusOK, "login via google berhasil", result)
}
func (h *Handler) RefreshToken(c *gin.Context) {
var req RefreshTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
return
}
result, err := h.service.RefreshToken(req)
if err != nil {
if errors.Is(err, ErrAccountSuspended) {
response.Error(c, http.StatusForbidden, err.Error(), nil)
return
}
response.Error(c, http.StatusUnauthorized, err.Error(), nil)
return
}
response.Success(c, http.StatusOK, "token berhasil diperbarui", result)
}
func (h *Handler) Logout(c *gin.Context) {
var req LogoutRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
return
}
if err := h.service.Logout(req.RefreshToken); err != nil {
response.Error(c, http.StatusInternalServerError, "gagal logout", err.Error())
return
}
response.Success(c, http.StatusOK, "logout berhasil", nil)
}
func (h *Handler) VerifyEmail(c *gin.Context) {
var req VerifyEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
return
}
if err := h.service.VerifyEmail(req.Token); err != nil {
response.Error(c, http.StatusBadRequest, err.Error(), nil)
return
}
response.Success(c, http.StatusOK, "email berhasil diverifikasi", nil)
}
func (h *Handler) ForgotPassword(c *gin.Context) {
var req ForgotPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
return
}
resetToken, err := h.service.ForgotPassword(req.Email)
if err != nil {
response.Error(c, http.StatusNotFound, err.Error(), nil)
return
}
response.Success(c, http.StatusOK, "instruksi reset password telah dikirim", gin.H{
"reset_token": resetToken,
})
}
func (h *Handler) ResetPassword(c *gin.Context) {
var req ResetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
return
}
if err := h.service.ResetPassword(req); err != nil {
if errors.Is(err, ErrPasswordMismatch) {
response.Error(c, http.StatusBadRequest, err.Error(), nil)
return
}
response.Error(c, http.StatusBadRequest, err.Error(), nil)
return
}
response.Success(c, http.StatusOK, "password berhasil diperbarui", nil)
}

View File

@@ -0,0 +1,15 @@
package auth
import "time"
type RefreshToken struct {
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
UserID uint `json:"user_id" gorm:"not null;index"`
Token string `json:"token" gorm:"type:varchar(255);uniqueIndex;not null"`
ExpiresAt time.Time `json:"expires_at" gorm:"not null"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
}
func (RefreshToken) TableName() string {
return "refresh_tokens"
}

View File

@@ -0,0 +1,59 @@
package auth
import "gorm.io/gorm"
type Repository interface {
CreateRefreshToken(token *RefreshToken) error
FindByToken(token string) (*RefreshToken, error)
DeleteByToken(token string) error
DeleteByUserID(userID uint) error
ReplaceUserRefreshToken(userID uint, newToken *RefreshToken) error
RotateRefreshToken(oldToken string, newToken *RefreshToken) error
}
type repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) Repository {
return &repository{db: db}
}
func (r *repository) CreateRefreshToken(token *RefreshToken) error {
return r.db.Create(token).Error
}
func (r *repository) FindByToken(token string) (*RefreshToken, error) {
var rt RefreshToken
err := r.db.Select("id", "user_id", "token", "expires_at", "created_at").Where("token = ?", token).First(&rt).Error
if err != nil {
return nil, err
}
return &rt, nil
}
func (r *repository) DeleteByToken(token string) error {
return r.db.Where("token = ?", token).Delete(&RefreshToken{}).Error
}
func (r *repository) DeleteByUserID(userID uint) error {
return r.db.Where("user_id = ?", userID).Delete(&RefreshToken{}).Error
}
func (r *repository) ReplaceUserRefreshToken(userID uint, newToken *RefreshToken) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", userID).Delete(&RefreshToken{}).Error; err != nil {
return err
}
return tx.Create(newToken).Error
})
}
func (r *repository) RotateRefreshToken(oldToken string, newToken *RefreshToken) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("token = ?", oldToken).Delete(&RefreshToken{}).Error; err != nil {
return err
}
return tx.Create(newToken).Error
})
}

View File

@@ -0,0 +1,28 @@
package auth
import (
"cardverse/internal/middleware"
"cardverse/internal/modules/user"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB) {
userRepo := user.NewRepository(db)
authRepo := NewRepository(db)
service := NewService(userRepo, authRepo)
handler := NewHandler(service)
authGroup := rg.Group("/auth")
{
authGroup.POST("/register", handler.Register)
authGroup.POST("/login", middleware.RateLimiter(1, 3), handler.Login)
authGroup.POST("/google", middleware.RateLimiter(1, 3), handler.GoogleLogin)
authGroup.POST("/refresh-token", handler.RefreshToken)
authGroup.POST("/logout", handler.Logout)
authGroup.POST("/verify-email", middleware.RateLimiter(1, 3), handler.VerifyEmail)
authGroup.POST("/forgot-password", middleware.RateLimiter(1, 3), handler.ForgotPassword)
authGroup.POST("/reset-password", handler.ResetPassword)
}
}

View File

@@ -0,0 +1,348 @@
package auth
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"time"
"cardverse/config"
"cardverse/internal/modules/user"
"cardverse/internal/pkg/utils"
)
var ErrInvalidCredentials = errors.New("email atau password salah")
var ErrEmailAlreadyExists = errors.New("email sudah terdaftar")
var ErrAccountSuspended = errors.New("akun anda sedang disuspensi")
var ErrInvalidToken = errors.New("token tidak valid atau sudah kedaluwarsa")
var ErrPasswordMismatch = errors.New("konfirmasi password tidak cocok")
var ErrGoogleVerificationFailed = errors.New("verifikasi google id token gagal")
var ErrGoogleAccountNoPassword = errors.New("akun ini mendaftar menggunakan Google, silakan login via Google atau buat password melalui fitur Lupa Password")
type Service interface {
Register(req RegisterRequest) (*user.User, string, error)
Login(req LoginRequest) (*LoginResponse, error)
GoogleLogin(req GoogleLoginRequest) (*LoginResponse, error)
RefreshToken(req RefreshTokenRequest) (*TokenResponse, error)
Logout(refreshToken string) error
VerifyEmail(token string) error
ForgotPassword(email string) (string, error)
ResetPassword(req ResetPasswordRequest) error
}
type service struct {
userRepo user.Repository
authRepo Repository
}
func NewService(userRepo user.Repository, authRepo Repository) Service {
return &service{
userRepo: userRepo,
authRepo: authRepo,
}
}
func (s *service) Register(req RegisterRequest) (*user.User, string, error) {
if req.Password != req.ConfirmPassword {
return nil, "", ErrPasswordMismatch
}
existing, _ := s.userRepo.FindByEmail(req.Email)
if existing != nil {
return nil, "", ErrEmailAlreadyExists
}
hashedPassword, err := utils.HashPassword(req.Password)
if err != nil {
return nil, "", err
}
verificationToken := generateRandomToken()
newUser := &user.User{
Name: req.Name,
Email: req.Email,
Password: hashedPassword,
Role: user.RoleUser,
Status: user.StatusUnverified,
IsEmailVerified: false,
VerificationToken: verificationToken,
}
if err := s.userRepo.Create(newUser); err != nil {
return nil, "", err
}
return newUser, verificationToken, nil
}
func (s *service) Login(req LoginRequest) (*LoginResponse, error) {
u, err := s.userRepo.FindByEmail(req.Email)
if err != nil {
return nil, ErrInvalidCredentials
}
if u.Password == "" {
return nil, ErrGoogleAccountNoPassword
}
if !utils.CheckPassword(u.Password, req.Password) {
return nil, ErrInvalidCredentials
}
if u.Status == user.StatusSuspended {
return nil, ErrAccountSuspended
}
return s.generateLoginResponse(u)
}
type googleTokenPayload struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified string `json:"email_verified"`
Name string `json:"name"`
Picture string `json:"picture"`
}
func (s *service) GoogleLogin(req GoogleLoginRequest) (*LoginResponse, error) {
googleUser, err := verifyGoogleIDToken(req.IDToken)
if err != nil || googleUser.Email == "" {
return nil, ErrGoogleVerificationFailed
}
u, _ := s.userRepo.FindByGoogleID(googleUser.Sub)
if u == nil {
u, _ = s.userRepo.FindByEmail(googleUser.Email)
}
if u != nil {
if u.Status == user.StatusSuspended {
return nil, ErrAccountSuspended
}
updated := false
if u.GoogleID == nil || *u.GoogleID == "" {
u.GoogleID = &googleUser.Sub
updated = true
}
if (u.AvatarURL == nil || *u.AvatarURL == "") && googleUser.Picture != "" {
u.AvatarURL = &googleUser.Picture
updated = true
}
if !u.IsEmailVerified {
u.IsEmailVerified = true
u.Status = user.StatusActive
updated = true
}
if updated {
_ = s.userRepo.Update(u)
}
} else {
var avatar *string
if googleUser.Picture != "" {
avatar = &googleUser.Picture
}
googleID := googleUser.Sub
newUser := &user.User{
Name: googleUser.Name,
Email: googleUser.Email,
GoogleID: &googleID,
AvatarURL: avatar,
Role: user.RoleUser,
Status: user.StatusActive,
IsEmailVerified: true,
}
if err := s.userRepo.Create(newUser); err != nil {
return nil, err
}
u = newUser
}
return s.generateLoginResponse(u)
}
func (s *service) generateLoginResponse(u *user.User) (*LoginResponse, error) {
accessToken, err := utils.GenerateAccessToken(u.ID, u.Email, u.Role)
if err != nil {
return nil, err
}
refreshToken, err := utils.GenerateRefreshToken(u.ID)
if err != nil {
return nil, err
}
expiresAt := time.Now().Add(time.Duration(config.Cfg.JWTRefreshExpiresHours) * time.Hour)
rtRecord := &RefreshToken{
UserID: u.ID,
Token: refreshToken,
ExpiresAt: expiresAt,
}
if err := s.authRepo.ReplaceUserRefreshToken(u.ID, rtRecord); err != nil {
return nil, err
}
resp := &LoginResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
}
resp.User.ID = u.ID
resp.User.Name = u.Name
resp.User.Email = u.Email
resp.User.AvatarURL = utils.FormatMediaURLPtr(u.AvatarURL)
resp.User.Role = u.Role
resp.User.Status = u.Status
resp.User.IsEmailVerified = u.IsEmailVerified
return resp, nil
}
func (s *service) RefreshToken(req RefreshTokenRequest) (*TokenResponse, error) {
claims, err := utils.ValidateRefreshToken(req.RefreshToken)
if err != nil {
return nil, ErrInvalidToken
}
savedToken, err := s.authRepo.FindByToken(req.RefreshToken)
if err != nil || savedToken == nil {
return nil, ErrInvalidToken
}
if time.Now().After(savedToken.ExpiresAt) {
_ = s.authRepo.DeleteByToken(req.RefreshToken)
return nil, ErrInvalidToken
}
u, err := s.userRepo.FindByID(claims.UserID)
if err != nil || u == nil {
return nil, ErrInvalidToken
}
if u.Status == user.StatusSuspended {
return nil, ErrAccountSuspended
}
newAccessToken, err := utils.GenerateAccessToken(u.ID, u.Email, u.Role)
if err != nil {
return nil, err
}
newRefreshToken, err := utils.GenerateRefreshToken(u.ID)
if err != nil {
return nil, err
}
expiresAt := time.Now().Add(time.Duration(config.Cfg.JWTRefreshExpiresHours) * time.Hour)
newRtRecord := &RefreshToken{
UserID: u.ID,
Token: newRefreshToken,
ExpiresAt: expiresAt,
}
if err := s.authRepo.RotateRefreshToken(req.RefreshToken, newRtRecord); err != nil {
return nil, err
}
return &TokenResponse{
AccessToken: newAccessToken,
RefreshToken: newRefreshToken,
}, nil
}
func (s *service) Logout(refreshToken string) error {
if refreshToken == "" {
return nil
}
return s.authRepo.DeleteByToken(refreshToken)
}
func (s *service) VerifyEmail(token string) error {
u, err := s.userRepo.FindByVerificationToken(token)
if err != nil || u == nil {
return ErrInvalidToken
}
u.IsEmailVerified = true
u.Status = user.StatusActive
u.VerificationToken = ""
return s.userRepo.Update(u)
}
func (s *service) ForgotPassword(email string) (string, error) {
u, err := s.userRepo.FindByEmail(email)
if err != nil || u == nil {
return "", errors.New("email tidak ditemukan")
}
resetToken := generateRandomToken()
expiresAt := time.Now().Add(1 * time.Hour)
u.ResetPasswordToken = resetToken
u.ResetPasswordExpiresAt = &expiresAt
if err := s.userRepo.Update(u); err != nil {
return "", err
}
return resetToken, nil
}
func (s *service) ResetPassword(req ResetPasswordRequest) error {
if req.NewPassword != req.ConfirmNewPassword {
return ErrPasswordMismatch
}
u, err := s.userRepo.FindByResetToken(req.Token)
if err != nil || u == nil {
return ErrInvalidToken
}
if u.ResetPasswordExpiresAt != nil && time.Now().After(*u.ResetPasswordExpiresAt) {
return ErrInvalidToken
}
hashedPassword, err := utils.HashPassword(req.NewPassword)
if err != nil {
return err
}
u.Password = hashedPassword
u.ResetPasswordToken = ""
u.ResetPasswordExpiresAt = nil
return s.userRepo.Update(u)
}
func verifyGoogleIDToken(idToken string) (*googleTokenPayload, error) {
resp, err := http.Get("https://oauth2.googleapis.com/tokeninfo?id_token=" + idToken)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("invalid google id token response status")
}
var payload googleTokenPayload
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, err
}
return &payload, nil
}
func generateRandomToken() string {
bytes := make([]byte, 16)
_, _ = rand.Read(bytes)
return hex.EncodeToString(bytes)
}

View File

@@ -0,0 +1,280 @@
package auth
import (
"errors"
"testing"
"time"
"cardverse/config"
"cardverse/internal/modules/user"
"cardverse/internal/pkg/utils"
)
func setupTestConfig() {
config.Cfg = &config.Config{
JWTSecret: "test-secret-key",
JWTExpiresHours: 1,
JWTRefreshSecret: "test-refresh-secret-key",
JWTRefreshExpiresHours: 168,
}
}
func TestService_Register_Success(t *testing.T) {
setupTestConfig()
userRepo := &mockUserRepository{
findByEmailFunc: func(email string) (*user.User, error) {
return nil, errors.New("record not found")
},
createFunc: func(u *user.User) error {
u.ID = 1
return nil
},
}
authRepo := &mockAuthRepository{}
svc := NewService(userRepo, authRepo)
req := RegisterRequest{
Name: "User Test",
Email: "register@mail.com",
Password: "password123",
ConfirmPassword: "password123",
}
u, token, err := svc.Register(req)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if token == "" {
t.Error("verification token should not be empty")
}
if u.Status != user.StatusUnverified {
t.Errorf("expected status 'unverified', got: %s", u.Status)
}
}
func TestService_Register_PasswordMismatch(t *testing.T) {
setupTestConfig()
userRepo := &mockUserRepository{}
authRepo := &mockAuthRepository{}
svc := NewService(userRepo, authRepo)
req := RegisterRequest{
Name: "User Test",
Email: "mismatch@mail.com",
Password: "password123",
ConfirmPassword: "differentpassword",
}
_, _, err := svc.Register(req)
if !errors.Is(err, ErrPasswordMismatch) {
t.Fatalf("expected ErrPasswordMismatch, got: %v", err)
}
}
func TestService_Register_EmailAlreadyExists(t *testing.T) {
setupTestConfig()
userRepo := &mockUserRepository{
findByEmailFunc: func(email string) (*user.User, error) {
return &user.User{ID: 1, Email: email}, nil
},
}
authRepo := &mockAuthRepository{}
svc := NewService(userRepo, authRepo)
req := RegisterRequest{
Name: "User Test",
Email: "existing@mail.com",
Password: "password123",
ConfirmPassword: "password123",
}
_, _, err := svc.Register(req)
if !errors.Is(err, ErrEmailAlreadyExists) {
t.Fatalf("expected ErrEmailAlreadyExists, got: %v", err)
}
}
func TestService_Login_Success(t *testing.T) {
setupTestConfig()
hashedPassword, _ := utils.HashPassword("rahasia123")
userRepo := &mockUserRepository{
findByEmailFunc: func(email string) (*user.User, error) {
return &user.User{ID: 1, Name: "Budi", Email: email, Password: hashedPassword, Role: user.RoleUser, Status: user.StatusActive}, nil
},
}
authRepo := &mockAuthRepository{
createRefreshTokenFunc: func(token *RefreshToken) error {
return nil
},
}
svc := NewService(userRepo, authRepo)
result, err := svc.Login(LoginRequest{Email: "budi@mail.com", Password: "rahasia123"})
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if result.AccessToken == "" {
t.Error("access_token should not be empty")
}
if result.RefreshToken == "" {
t.Error("refresh_token should not be empty")
}
if result.User.Email != "budi@mail.com" {
t.Errorf("expected email budi@mail.com, got %s", result.User.Email)
}
}
func TestService_Login_SuspendedAccount(t *testing.T) {
setupTestConfig()
hashedPassword, _ := utils.HashPassword("rahasia123")
userRepo := &mockUserRepository{
findByEmailFunc: func(email string) (*user.User, error) {
return &user.User{ID: 1, Name: "Budi", Email: email, Password: hashedPassword, Status: user.StatusSuspended}, nil
},
}
authRepo := &mockAuthRepository{}
svc := NewService(userRepo, authRepo)
_, err := svc.Login(LoginRequest{Email: "budi@mail.com", Password: "rahasia123"})
if !errors.Is(err, ErrAccountSuspended) {
t.Fatalf("expected ErrAccountSuspended, got: %v", err)
}
}
func TestService_Login_InvalidPassword(t *testing.T) {
setupTestConfig()
hashedPassword, _ := utils.HashPassword("rahasia123")
userRepo := &mockUserRepository{
findByEmailFunc: func(email string) (*user.User, error) {
return &user.User{ID: 1, Email: email, Password: hashedPassword}, nil
},
}
authRepo := &mockAuthRepository{}
svc := NewService(userRepo, authRepo)
_, err := svc.Login(LoginRequest{Email: "budi@mail.com", Password: "wrong_password"})
if !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("expected ErrInvalidCredentials, got: %v", err)
}
}
func TestService_RefreshToken_Success(t *testing.T) {
setupTestConfig()
validRefreshToken, _ := utils.GenerateRefreshToken(42)
userRepo := &mockUserRepository{
findByIDFunc: func(id uint) (*user.User, error) {
return &user.User{ID: 42, Email: "test@mail.com", Role: user.RoleUser, Status: user.StatusActive}, nil
},
}
authRepo := &mockAuthRepository{
findByTokenFunc: func(token string) (*RefreshToken, error) {
return &RefreshToken{
UserID: 42,
Token: token,
ExpiresAt: time.Now().Add(1 * time.Hour),
}, nil
},
}
svc := NewService(userRepo, authRepo)
res, err := svc.RefreshToken(RefreshTokenRequest{RefreshToken: validRefreshToken})
if err != nil {
t.Fatalf("RefreshToken expected no error, got: %v", err)
}
if res.AccessToken == "" || res.RefreshToken == "" {
t.Error("new access_token and refresh_token should not be empty")
}
}
func TestService_Logout_Success(t *testing.T) {
setupTestConfig()
deleted := false
authRepo := &mockAuthRepository{
deleteByTokenFunc: func(token string) error {
deleted = true
return nil
},
}
svc := NewService(&mockUserRepository{}, authRepo)
err := svc.Logout("token-to-delete")
if err != nil {
t.Fatalf("Logout expected no error, got: %v", err)
}
if !deleted {
t.Error("deleteByTokenFunc should have been called")
}
}
func TestService_VerifyEmail_Success(t *testing.T) {
setupTestConfig()
userRepo := &mockUserRepository{
findByVerificationTokenFunc: func(token string) (*user.User, error) {
return &user.User{ID: 1, Status: user.StatusUnverified, IsEmailVerified: false, VerificationToken: token}, nil
},
updateFunc: func(u *user.User) error {
return nil
},
}
authRepo := &mockAuthRepository{}
svc := NewService(userRepo, authRepo)
err := svc.VerifyEmail("valid-token")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
}
func TestService_ForgotPassword_AndResetPassword_Success(t *testing.T) {
setupTestConfig()
var storedToken string
userRepo := &mockUserRepository{
findByEmailFunc: func(email string) (*user.User, error) {
return &user.User{ID: 1, Email: email}, nil
},
findByResetTokenFunc: func(token string) (*user.User, error) {
expiry := time.Now().Add(1 * time.Hour)
return &user.User{ID: 1, ResetPasswordToken: token, ResetPasswordExpiresAt: &expiry}, nil
},
updateFunc: func(u *user.User) error {
storedToken = u.ResetPasswordToken
return nil
},
}
authRepo := &mockAuthRepository{}
svc := NewService(userRepo, authRepo)
resetToken, err := svc.ForgotPassword("user@mail.com")
if err != nil {
t.Fatalf("ForgotPassword expected no error, got: %v", err)
}
if resetToken == "" {
t.Fatal("resetToken should not be empty")
}
if storedToken != resetToken {
t.Errorf("expected storedToken to be %s, got %s", resetToken, storedToken)
}
err = svc.ResetPassword(ResetPasswordRequest{
Token: resetToken,
NewPassword: "newpassword123",
ConfirmNewPassword: "newpassword123",
})
if err != nil {
t.Fatalf("ResetPassword expected no error, got: %v", err)
}
}

View File

@@ -0,0 +1,137 @@
package auth
import "cardverse/internal/modules/user"
type mockUserRepository struct {
createFunc func(u *user.User) error
findAllFunc func() ([]user.User, error)
findAllPaginatedFunc func(page, limit int, query user.ListUserQuery) ([]user.User, int64, error)
findByIDFunc func(id uint) (*user.User, error)
findByEmailFunc func(email string) (*user.User, error)
findByGoogleIDFunc func(googleID string) (*user.User, error)
findByVerificationTokenFunc func(token string) (*user.User, error)
findByResetTokenFunc func(token string) (*user.User, error)
updateFunc func(u *user.User) error
deleteFunc func(id uint) error
}
func (m *mockUserRepository) Create(u *user.User) error {
if m.createFunc != nil {
return m.createFunc(u)
}
return nil
}
func (m *mockUserRepository) FindAll() ([]user.User, error) {
if m.findAllFunc != nil {
return m.findAllFunc()
}
return nil, nil
}
func (m *mockUserRepository) FindAllPaginated(page, limit int, query user.ListUserQuery) ([]user.User, int64, error) {
if m.findAllPaginatedFunc != nil {
return m.findAllPaginatedFunc(page, limit, query)
}
return nil, 0, nil
}
func (m *mockUserRepository) FindByID(id uint) (*user.User, error) {
if m.findByIDFunc != nil {
return m.findByIDFunc(id)
}
return nil, nil
}
func (m *mockUserRepository) FindByEmail(email string) (*user.User, error) {
if m.findByEmailFunc != nil {
return m.findByEmailFunc(email)
}
return nil, nil
}
func (m *mockUserRepository) FindByGoogleID(googleID string) (*user.User, error) {
if m.findByGoogleIDFunc != nil {
return m.findByGoogleIDFunc(googleID)
}
return nil, nil
}
func (m *mockUserRepository) FindByVerificationToken(token string) (*user.User, error) {
if m.findByVerificationTokenFunc != nil {
return m.findByVerificationTokenFunc(token)
}
return nil, nil
}
func (m *mockUserRepository) FindByResetToken(token string) (*user.User, error) {
if m.findByResetTokenFunc != nil {
return m.findByResetTokenFunc(token)
}
return nil, nil
}
func (m *mockUserRepository) Update(u *user.User) error {
if m.updateFunc != nil {
return m.updateFunc(u)
}
return nil
}
func (m *mockUserRepository) Delete(id uint) error {
if m.deleteFunc != nil {
return m.deleteFunc(id)
}
return nil
}
type mockAuthRepository struct {
createRefreshTokenFunc func(token *RefreshToken) error
findByTokenFunc func(token string) (*RefreshToken, error)
deleteByTokenFunc func(token string) error
deleteByUserIDFunc func(userID uint) error
replaceUserRefreshTokenFunc func(userID uint, newToken *RefreshToken) error
rotateRefreshTokenFunc func(oldToken string, newToken *RefreshToken) error
}
func (m *mockAuthRepository) CreateRefreshToken(token *RefreshToken) error {
if m.createRefreshTokenFunc != nil {
return m.createRefreshTokenFunc(token)
}
return nil
}
func (m *mockAuthRepository) FindByToken(token string) (*RefreshToken, error) {
if m.findByTokenFunc != nil {
return m.findByTokenFunc(token)
}
return nil, nil
}
func (m *mockAuthRepository) DeleteByToken(token string) error {
if m.deleteByTokenFunc != nil {
return m.deleteByTokenFunc(token)
}
return nil
}
func (m *mockAuthRepository) DeleteByUserID(userID uint) error {
if m.deleteByUserIDFunc != nil {
return m.deleteByUserIDFunc(userID)
}
return nil
}
func (m *mockAuthRepository) ReplaceUserRefreshToken(userID uint, newToken *RefreshToken) error {
if m.replaceUserRefreshTokenFunc != nil {
return m.replaceUserRefreshTokenFunc(userID, newToken)
}
return nil
}
func (m *mockAuthRepository) RotateRefreshToken(oldToken string, newToken *RefreshToken) error {
if m.rotateRefreshTokenFunc != nil {
return m.rotateRefreshTokenFunc(oldToken, newToken)
}
return nil
}

View File

@@ -0,0 +1,169 @@
package cardmaster
import (
"cardverse/internal/pkg/utils"
"time"
)
type ListCardMasterQuery 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"`
IDSet uint `form:"id_set" binding:"omitempty"`
Element string `form:"element"`
Rarity string `form:"rarity"`
Stage string `form:"stage"`
}
type CreateCardMasterRequest struct {
IDSet uint `json:"id_set" binding:"required"`
Name string `json:"name" binding:"required,min=1,max=150"`
Number string `json:"number" binding:"omitempty,max=50"`
Image string `json:"image" binding:"omitempty,max=255"`
Stage string `json:"stage" binding:"omitempty,max=50"`
Element string `json:"element" binding:"omitempty,max=50"`
ElementImage string `json:"element_image" binding:"omitempty,max=255"`
EvolvesFrom string `json:"evolves_from" binding:"omitempty,max=150"`
Illustrator string `json:"illustrator" binding:"omitempty,max=150"`
Regulation string `json:"regulation" binding:"omitempty,max=10"`
Rarity string `json:"rarity" binding:"omitempty,max=50"`
IDPriceChartingEng string `json:"id_price_charting_eng" binding:"omitempty,max=50"`
LinkPriceChartingEng string `json:"link_price_charting_eng" binding:"omitempty"`
IDPriceChartingJpn string `json:"id_price_charting_jpn" binding:"omitempty,max=50"`
LinkPriceChartingJpn string `json:"link_price_charting_jpn" binding:"omitempty"`
HP *HP `json:"hp" binding:"omitempty"`
Attacks []Attack `json:"attacks" binding:"omitempty"`
Abilities []Ability `json:"abilities" binding:"omitempty"`
Battle *Battle `json:"battle" binding:"omitempty"`
EvolutionLine []EvolutionStage `json:"evolution_line" binding:"omitempty"`
PokedexInfo *PokedexInfo `json:"pokedex_info" binding:"omitempty"`
Effects []Effect `json:"effects" binding:"omitempty"`
}
type UpdateCardMasterRequest struct {
IDSet uint `json:"id_set" binding:"omitempty"`
Name string `json:"name" binding:"omitempty,min=1,max=150"`
Number string `json:"number" binding:"omitempty,max=50"`
Image string `json:"image" binding:"omitempty,max=255"`
Stage string `json:"stage" binding:"omitempty,max=50"`
Element string `json:"element" binding:"omitempty,max=50"`
EvolvesFrom string `json:"evolves_from" binding:"omitempty,max=150"`
Illustrator string `json:"illustrator" binding:"omitempty,max=150"`
Regulation string `json:"regulation" binding:"omitempty,max=10"`
Rarity string `json:"rarity" binding:"omitempty,max=50"`
IDPriceChartingEng string `json:"id_price_charting_eng" binding:"omitempty,max=50"`
LinkPriceChartingEng string `json:"link_price_charting_eng" binding:"omitempty"`
IDPriceChartingJpn string `json:"id_price_charting_jpn" binding:"omitempty,max=50"`
LinkPriceChartingJpn string `json:"link_price_charting_jpn" binding:"omitempty"`
HP *HP `json:"hp" binding:"omitempty"`
Attacks []Attack `json:"attacks" binding:"omitempty"`
Abilities []Ability `json:"abilities" binding:"omitempty"`
Battle *Battle `json:"battle" binding:"omitempty"`
EvolutionLine []EvolutionStage `json:"evolution_line" binding:"omitempty"`
PokedexInfo *PokedexInfo `json:"pokedex_info" binding:"omitempty"`
Effects []Effect `json:"effects" binding:"omitempty"`
}
type ElementResponse struct {
Name string `json:"name"`
Image string `json:"image"`
}
func GetElementsResponse() []ElementResponse {
elements := []string{"Daun", "Api", "Air", "Listrik", "Psikis", "Petarung", "Kegelapan", "Logam", "Naga", "Peri", "Bening"}
result := make([]ElementResponse, 0, len(elements))
for _, name := range elements {
if path, exists := ElementImageMap[name]; exists {
result = append(result, ElementResponse{
Name: name,
Image: utils.FormatMediaURL(path),
})
}
}
return result
}
type CardMasterResponse struct {
ID uint `json:"id"`
IDSet uint `json:"id_set"`
Name string `json:"name"`
Number *string `json:"number"`
Image *string `json:"image"`
Stage *string `json:"stage"`
Element *string `json:"element"`
ElementImage *string `json:"element_image"`
EvolvesFrom *string `json:"evolves_from"`
Illustrator *string `json:"illustrator"`
Regulation *string `json:"regulation"`
Rarity *string `json:"rarity"`
IDPriceChartingEng *string `json:"id_price_charting_eng"`
LinkPriceChartingEng *string `json:"link_price_charting_eng"`
IDPriceChartingJpn *string `json:"id_price_charting_jpn"`
LinkPriceChartingJpn *string `json:"link_price_charting_jpn"`
HP JSONB `json:"hp"`
Attacks JSONB `json:"attacks"`
Abilities JSONB `json:"abilities"`
Battle JSONB `json:"battle"`
EvolutionLine JSONB `json:"evolution_line"`
PokedexInfo JSONB `json:"pokedex_info"`
Effects JSONB `json:"effects"`
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 ToCardMasterResponse(c CardMaster) CardMasterResponse {
return CardMasterResponse{
ID: c.ID,
IDSet: c.IDSet,
Name: c.Name,
Number: strPtr(c.Number),
Image: strPtrMedia(c.Image),
Stage: strPtr(c.Stage),
Element: strPtr(c.Element),
ElementImage: strPtrMedia(c.ElementImage),
EvolvesFrom: strPtr(c.EvolvesFrom),
Illustrator: strPtr(c.Illustrator),
Regulation: strPtr(c.Regulation),
Rarity: strPtr(c.Rarity),
IDPriceChartingEng: strPtr(c.IDPriceChartingEng),
LinkPriceChartingEng: strPtr(c.LinkPriceChartingEng),
IDPriceChartingJpn: strPtr(c.IDPriceChartingJpn),
LinkPriceChartingJpn: strPtr(c.LinkPriceChartingJpn),
HP: JSONB(utils.FormatJSONBMediaURLs(c.HP)),
Attacks: JSONB(utils.FormatJSONBMediaURLs(c.Attacks)),
Abilities: JSONB(utils.FormatJSONBMediaURLs(c.Abilities)),
Battle: JSONB(utils.FormatJSONBMediaURLs(c.Battle)),
EvolutionLine: JSONB(utils.FormatJSONBMediaURLs(c.EvolutionLine)),
PokedexInfo: JSONB(utils.FormatJSONBMediaURLs(c.PokedexInfo)),
Effects: JSONB(utils.FormatJSONBMediaURLs(c.Effects)),
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
CreatedBy: c.CreatedBy,
UpdatedBy: c.UpdatedBy,
}
}
func ToCardMasterResponseList(list []CardMaster) []CardMasterResponse {
result := make([]CardMasterResponse, 0, len(list))
for _, item := range list {
result = append(result, ToCardMasterResponse(item))
}
return result
}
func strPtr(s string) *string {
if s == "" {
return nil
}
return &s
}
func strPtrMedia(s string) *string {
if s == "" {
return nil
}
formatted := utils.FormatMediaURL(s)
return &formatted
}

View File

@@ -0,0 +1,314 @@
package cardmaster
import (
"encoding/json"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"cardverse/internal/modules/seriessetmaster"
"cardverse/internal/pkg/response"
"cardverse/internal/pkg/utils"
"cardverse/internal/pkg/validator"
"github.com/chai2010/webp"
"github.com/gin-gonic/gin"
)
type Handler struct {
service Service
seriesSetRepo seriessetmaster.Repository
}
func NewHandler(service Service, seriesSetRepo seriessetmaster.Repository) *Handler {
return &Handler{
service: service,
seriesSetRepo: seriesSetRepo,
}
}
func (h *Handler) GetAll(c *gin.Context) {
var query ListCardMasterQuery
if err := c.ShouldBindQuery(&query); err != nil {
response.Error(c, http.StatusBadRequest, "query parameter tidak valid", validator.TranslateError(err))
return
}
list, total, err := h.service.GetAllPaginated(query)
if err != nil {
response.Error(c, http.StatusInternalServerError, "gagal mengambil data kartu", err.Error())
return
}
pagination := response.NewPagination(query.Page, query.Limit, total)
response.SuccessWithPagination(c, http.StatusOK, "berhasil mengambil data kartu", ToCardMasterResponseList(list), pagination)
}
func (h *Handler) GetElements(c *gin.Context) {
elements := h.service.GetElements()
response.Success(c, http.StatusOK, "berhasil mengambil data elemen", elements)
}
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
}
cardData, 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 kartu", ToCardMasterResponse(*cardData))
}
func (h *Handler) Create(c *gin.Context) {
var req CreateCardMasterRequest
contentType := c.GetHeader("Content-Type")
if strings.HasPrefix(contentType, "multipart/form-data") {
if jsonData := c.PostForm("data"); jsonData != "" {
if err := json.Unmarshal([]byte(jsonData), &req); err != nil {
response.Error(c, http.StatusBadRequest, "format data JSON pada multipart tidak valid", nil)
return
}
} else {
if err := c.ShouldBind(&req); err != nil {
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
return
}
}
fileHeader, err := c.FormFile("image")
if err == nil && fileHeader != nil {
if fileHeader.Size > 10*1024*1024 {
response.Error(c, http.StatusBadRequest, "ukuran file gambar maksimal 10MB", nil)
return
}
if req.IDSet == 0 {
response.Error(c, http.StatusBadRequest, "id_set wajib diisi untuk upload gambar", nil)
return
}
setInfo, err := h.seriesSetRepo.FindByID(req.IDSet)
if err != nil || setInfo == nil {
response.Error(c, http.StatusBadRequest, "set dengan id_set tersebut tidak ditemukan", nil)
return
}
seriesSlug := utils.Slugify(setInfo.SeriesName)
setCode := setInfo.SetCode
uploadDir := filepath.Join("./public/images", seriesSlug, setCode, "images")
if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil {
response.Error(c, http.StatusInternalServerError, "gagal membuat direktori gambar kartu", err.Error())
return
}
srcFile, err := fileHeader.Open()
if err != nil {
response.Error(c, http.StatusInternalServerError, "gagal membuka file gambar", err.Error())
return
}
defer srcFile.Close()
img, _, err := image.Decode(srcFile)
if err != nil {
response.Error(c, http.StatusBadRequest, "file bukan merupakan gambar yang valid", err.Error())
return
}
filename := formatCardImageFilename(setCode, req.Number)
dstPath := filepath.Join(uploadDir, filename)
out, err := os.Create(dstPath)
if err != nil {
response.Error(c, http.StatusInternalServerError, "gagal membuat file WebP kartu", err.Error())
return
}
defer out.Close()
if err := webp.Encode(out, img, &webp.Options{Lossless: false, Quality: 90}); err != nil {
response.Error(c, http.StatusInternalServerError, "gagal mengonversi gambar ke WebP", err.Error())
return
}
req.Image = fmt.Sprintf("/images/%s/%s/images/%s", seriesSlug, setCode, filename)
}
} else {
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
return
}
}
createdCard, err := h.service.Create(req)
if err != nil {
response.Error(c, http.StatusBadRequest, err.Error(), nil)
return
}
response.Success(c, http.StatusCreated, "kartu berhasil dibuat", ToCardMasterResponse(*createdCard))
}
func (h *Handler) Update(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
}
var req UpdateCardMasterRequest
contentType := c.GetHeader("Content-Type")
if strings.HasPrefix(contentType, "multipart/form-data") {
if jsonData := c.PostForm("data"); jsonData != "" {
if err := json.Unmarshal([]byte(jsonData), &req); err != nil {
response.Error(c, http.StatusBadRequest, "format data JSON pada multipart tidak valid", nil)
return
}
} else {
if err := c.ShouldBind(&req); err != nil {
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
return
}
}
fileHeader, err := c.FormFile("image")
if err == nil && fileHeader != nil {
if fileHeader.Size > 10*1024*1024 {
response.Error(c, http.StatusBadRequest, "ukuran file gambar maksimal 10MB", nil)
return
}
existingCard, err := h.service.GetByID(id)
if err != nil || existingCard == nil {
response.Error(c, http.StatusNotFound, "kartu tidak ditemukan", nil)
return
}
targetIDSet := req.IDSet
if targetIDSet == 0 {
targetIDSet = existingCard.IDSet
}
targetNumber := req.Number
if targetNumber == "" {
targetNumber = existingCard.Number
}
setInfo, err := h.seriesSetRepo.FindByID(targetIDSet)
if err != nil || setInfo == nil {
response.Error(c, http.StatusBadRequest, "set dengan id_set tersebut tidak ditemukan", nil)
return
}
seriesSlug := utils.Slugify(setInfo.SeriesName)
setCode := setInfo.SetCode
uploadDir := filepath.Join("./public/images", seriesSlug, setCode, "images")
if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil {
response.Error(c, http.StatusInternalServerError, "gagal membuat direktori gambar kartu", err.Error())
return
}
srcFile, err := fileHeader.Open()
if err != nil {
response.Error(c, http.StatusInternalServerError, "gagal membuka file gambar", err.Error())
return
}
defer srcFile.Close()
img, _, err := image.Decode(srcFile)
if err != nil {
response.Error(c, http.StatusBadRequest, "file bukan merupakan gambar yang valid", err.Error())
return
}
filename := formatCardImageFilename(setCode, targetNumber)
dstPath := filepath.Join(uploadDir, filename)
out, err := os.Create(dstPath)
if err != nil {
response.Error(c, http.StatusInternalServerError, "gagal membuat file WebP kartu", err.Error())
return
}
defer out.Close()
if err := webp.Encode(out, img, &webp.Options{Lossless: false, Quality: 90}); err != nil {
response.Error(c, http.StatusInternalServerError, "gagal mengonversi gambar ke WebP", err.Error())
return
}
req.Image = fmt.Sprintf("/images/%s/%s/images/%s", seriesSlug, setCode, filename)
}
} else {
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
return
}
}
updatedCard, err := h.service.Update(id, req)
if err != nil {
response.Error(c, http.StatusNotFound, err.Error(), nil)
return
}
response.Success(c, http.StatusOK, "kartu berhasil diupdate", ToCardMasterResponse(*updatedCard))
}
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, "kartu 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 formatCardImageFilename(setCode, number string) string {
cleanNum := strings.TrimSpace(number)
if cleanNum != "" {
cleanNum = strings.ReplaceAll(cleanNum, "/", "_")
cleanNum = strings.ReplaceAll(cleanNum, "\\", "_")
if !strings.HasSuffix(strings.ToLower(cleanNum), ".webp") {
cleanNum = cleanNum + ".webp"
}
return cleanNum
}
return fmt.Sprintf("%s-1_%d.webp", setCode, time.Now().UnixNano())
}

View File

@@ -0,0 +1,161 @@
package cardmaster
import (
"database/sql/driver"
"encoding/json"
"errors"
"time"
)
// JSONB merepresentasikan kolom JSONB di PostgreSQL
type JSONB json.RawMessage
func (j JSONB) Value() (driver.Value, error) {
if len(j) == 0 {
return nil, nil
}
return string(j), nil
}
func (j *JSONB) Scan(value interface{}) error {
if value == nil {
*j = nil
return nil
}
switch v := value.(type) {
case []byte:
*j = append((*j)[0:0], v...)
case string:
*j = JSONB(v)
default:
return errors.New("type assertion ke []byte atau string gagal")
}
return nil
}
func (j JSONB) MarshalJSON() ([]byte, error) {
if len(j) == 0 {
return []byte("null"), nil
}
return json.RawMessage(j).MarshalJSON()
}
func (j *JSONB) UnmarshalJSON(data []byte) error {
if j == nil {
return errors.New("JSONB: UnmarshalJSON pada nil pointer")
}
*j = append((*j)[0:0], data...)
return nil
}
// CardMaster merepresentasikan tabel "card_masters" di database
type CardMaster struct {
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
IDSet uint `json:"id_set" gorm:"column:id_set;not null;index"`
Name string `json:"name" gorm:"type:varchar(150);not null;index"`
Number string `json:"number" gorm:"type:varchar(50)"`
Image string `json:"image" gorm:"type:varchar(255)"`
Stage string `json:"stage,omitempty" gorm:"type:varchar(50)"`
Element string `json:"element,omitempty" gorm:"type:varchar(50);index"`
ElementImage string `json:"element_image,omitempty" gorm:"type:varchar(255)"`
EvolvesFrom string `json:"evolves_from,omitempty" gorm:"type:varchar(150)"`
Illustrator string `json:"illustrator,omitempty" gorm:"type:varchar(150)"`
Regulation string `json:"regulation,omitempty" gorm:"type:varchar(10)"`
Rarity string `json:"rarity,omitempty" gorm:"type:varchar(50);index"`
IDPriceChartingEng string `json:"id_price_charting_eng,omitempty" gorm:"type:varchar(50)"`
LinkPriceChartingEng string `json:"link_price_charting_eng,omitempty" gorm:"type:text"`
IDPriceChartingJpn string `json:"id_price_charting_jpn,omitempty" gorm:"type:varchar(50)"`
LinkPriceChartingJpn string `json:"link_price_charting_jpn,omitempty" gorm:"type:text"`
HP JSONB `json:"hp,omitempty" gorm:"type:jsonb"`
Attacks JSONB `json:"attacks,omitempty" gorm:"type:jsonb"`
Abilities JSONB `json:"abilities,omitempty" gorm:"type:jsonb"`
Battle JSONB `json:"battle,omitempty" gorm:"type:jsonb"`
EvolutionLine JSONB `json:"evolution_line,omitempty" gorm:"type:jsonb"`
PokedexInfo JSONB `json:"pokedex_info,omitempty" gorm:"type:jsonb"`
Effects JSONB `json:"effects,omitempty" gorm:"type:jsonb"`
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 (CardMaster) TableName() string {
return "card_masters"
}
// Struct di bawah ini digunakan untuk parsing data JSON ke tipe Go yang terstruktur
type HP struct {
Value string `json:"value"`
Element string `json:"element"`
ElementImage string `json:"element_image,omitempty"`
}
type AttackCost struct {
Element string `json:"element"`
Image string `json:"image,omitempty"`
}
type Attack struct {
Cost []AttackCost `json:"cost"`
Name string `json:"name"`
Damage string `json:"damage,omitempty"`
Description string `json:"description,omitempty"`
}
type BattleStat struct {
Element string `json:"element,omitempty"`
ElementImage string `json:"element_image,omitempty"`
Value string `json:"value,omitempty"`
}
type Battle struct {
Weakness []BattleStat `json:"weakness"`
Resistance []BattleStat `json:"resistance"`
Retreat []BattleStat `json:"retreat"`
}
type EvolutionStage struct {
Stage string `json:"stage"`
Name string `json:"name"`
}
type PokedexInfo struct {
Number string `json:"number"`
Height string `json:"height"`
Weight string `json:"weight"`
}
type Ability struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
type Effect struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
// ElementImageMap memetakan nama element (Bahasa Indonesia) ke path gambar di /images/elements/
var ElementImageMap = map[string]string{
"Daun": "/images/elements/Grass.png",
"Api": "/images/elements/Fire.png",
"Air": "/images/elements/Water.png",
"Listrik": "/images/elements/Lightning.png",
"Psikis": "/images/elements/Psychic.png",
"Petarung": "/images/elements/Fighting.png",
"Kegelapan": "/images/elements/Darkness.png",
"Logam": "/images/elements/Metal.png",
"Naga": "/images/elements/Dragon.png",
"Peri": "/images/elements/Fairy.png",
"Bening": "/images/elements/Colorless.png",
}
// GetElementImage mengembalikan path gambar element berdasarkan nama element
func GetElementImage(element string) string {
if path, exists := ElementImageMap[element]; exists {
return path
}
return ""
}

View File

@@ -0,0 +1,158 @@
package cardmaster
import (
"gorm.io/gorm"
)
type Repository interface {
FindAllPaginated(page, limit int, query ListCardMasterQuery) ([]CardMaster, int64, error)
FindByID(id uint) (*CardMaster, error)
Create(c *CardMaster) error
Update(c *CardMaster, oldIDSet uint) error
Delete(id uint, idSet uint) error
}
type repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) Repository {
return &repository{db: db}
}
func (r *repository) FindAllPaginated(page, limit int, queryParams ListCardMasterQuery) ([]CardMaster, int64, error) {
var list []CardMaster
var total int64
query := r.db.Model(&CardMaster{}).
Select(
"id", "id_set", "name", "number", "image", "stage", "element", "element_image",
"evolves_from", "illustrator", "regulation", "rarity",
"id_price_charting_eng", "link_price_charting_eng", "id_price_charting_jpn", "link_price_charting_jpn",
"hp", "attacks", "abilities", "battle", "evolution_line", "pokedex_info", "effects",
"created_at", "updated_at", "created_by", "updated_by",
)
if queryParams.IDSet > 0 {
query = query.Where("id_set = ?", queryParams.IDSet)
}
if queryParams.Element != "" {
query = query.Where("element ILIKE ?", queryParams.Element)
}
if queryParams.Rarity != "" {
query = query.Where("rarity ILIKE ?", queryParams.Rarity)
}
if queryParams.Stage != "" {
query = query.Where("stage ILIKE ?", queryParams.Stage)
}
if queryParams.Search != "" {
pattern := "%" + queryParams.Search + "%"
query = query.Where("name ILIKE ? OR number ILIKE ? OR illustrator ILIKE ?", pattern, pattern, pattern)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * limit
err := query.Order("id ASC").Limit(limit).Offset(offset).Find(&list).Error
if err != nil {
return nil, 0, err
}
return list, total, nil
}
func (r *repository) FindByID(id uint) (*CardMaster, error) {
var c CardMaster
err := r.db.Select(
"id", "id_set", "name", "number", "image", "stage", "element", "element_image",
"evolves_from", "illustrator", "regulation", "rarity",
"id_price_charting_eng", "link_price_charting_eng", "id_price_charting_jpn", "link_price_charting_jpn",
"hp", "attacks", "abilities", "battle", "evolution_line", "pokedex_info", "effects",
"created_at", "updated_at", "created_by", "updated_by",
).First(&c, id).Error
if err != nil {
return nil, err
}
return &c, nil
}
func (r *repository) Create(c *CardMaster) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(c).Error; err != nil {
return err
}
if c.IDSet > 0 {
recalculateSetAndSeriesStatsTx(tx, c.IDSet)
}
return nil
})
}
func (r *repository) Update(c *CardMaster, oldIDSet uint) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(c).Error; err != nil {
return err
}
if c.IDSet > 0 {
recalculateSetAndSeriesStatsTx(tx, c.IDSet)
}
if oldIDSet > 0 && oldIDSet != c.IDSet {
recalculateSetAndSeriesStatsTx(tx, oldIDSet)
}
return nil
})
}
func (r *repository) Delete(id uint, idSet uint) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Delete(&CardMaster{}, id).Error; err != nil {
return err
}
if idSet > 0 {
recalculateSetAndSeriesStatsTx(tx, idSet)
}
return nil
})
}
func recalculateSetAndSeriesStatsTx(tx *gorm.DB, setID uint) {
if setID == 0 {
return
}
type seriesSetRef struct {
ID uint
ParentID *uint
}
var set seriesSetRef
if err := tx.Table("series_set_masters").Select("id", "parent_id").Where("id = ?", setID).First(&set).Error; err != nil {
return
}
var totalCards int64
_ = tx.Table("card_masters").Where("id_set = ?", setID).Count(&totalCards).Error
_ = tx.Table("series_set_masters").Where("id = ?", setID).Update("total_cards", int(totalCards)).Error
if set.ParentID != nil {
var expansionCount int64
_ = tx.Table("series_set_masters").Where("parent_id = ?", *set.ParentID).Count(&expansionCount).Error
var cardCount int64
_ = tx.Table("card_masters").
Where("id_set IN (SELECT id FROM series_set_masters WHERE parent_id = ?)", *set.ParentID).
Count(&cardCount).Error
_ = tx.Table("series_set_masters").
Where("id = ? AND parent_id IS NULL", *set.ParentID).
Updates(map[string]interface{}{
"expansion_count": int(expansionCount),
"card_count": int(cardCount),
}).Error
}
}

View File

@@ -0,0 +1,44 @@
package cardmaster
type mockRepository struct {
findAllPaginatedFunc func(page, limit int, query ListCardMasterQuery) ([]CardMaster, int64, error)
findByIDFunc func(id uint) (*CardMaster, error)
createFunc func(c *CardMaster) error
updateFunc func(c *CardMaster, oldIDSet uint) error
deleteFunc func(id uint, idSet uint) error
}
func (m *mockRepository) FindAllPaginated(page, limit int, query ListCardMasterQuery) ([]CardMaster, int64, error) {
if m.findAllPaginatedFunc != nil {
return m.findAllPaginatedFunc(page, limit, query)
}
return nil, 0, nil
}
func (m *mockRepository) FindByID(id uint) (*CardMaster, error) {
if m.findByIDFunc != nil {
return m.findByIDFunc(id)
}
return nil, nil
}
func (m *mockRepository) Create(c *CardMaster) error {
if m.createFunc != nil {
return m.createFunc(c)
}
return nil
}
func (m *mockRepository) Update(c *CardMaster, oldIDSet uint) error {
if m.updateFunc != nil {
return m.updateFunc(c, oldIDSet)
}
return nil
}
func (m *mockRepository) Delete(id uint, idSet uint) error {
if m.deleteFunc != nil {
return m.deleteFunc(id, idSet)
}
return nil
}

View File

@@ -0,0 +1,27 @@
package cardmaster
import (
"cardverse/internal/middleware"
"cardverse/internal/modules/seriessetmaster"
"cardverse/internal/modules/user"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func RegisterRoutes(router *gin.RouterGroup, db *gorm.DB) {
repo := NewRepository(db)
svc := NewService(repo)
seriesSetRepo := seriessetmaster.NewRepository(db)
handler := NewHandler(svc, seriesSetRepo)
cards := router.Group("/cards")
{
cards.GET("", handler.GetAll)
cards.GET("/elements", handler.GetElements)
cards.GET("/:id", handler.GetByID)
cards.POST("", middleware.AuthRequired(), middleware.RequireRoles(user.RoleAdmin, user.RoleSuperAdmin), handler.Create)
cards.PUT("/:id", middleware.AuthRequired(), middleware.RequireRoles(user.RoleAdmin, user.RoleSuperAdmin), handler.Update)
cards.DELETE("/:id", middleware.AuthRequired(), middleware.RequireRoles(user.RoleAdmin, user.RoleSuperAdmin), handler.Delete)
}
}

View File

@@ -0,0 +1,291 @@
package cardmaster
import (
"encoding/json"
"errors"
"cardverse/internal/pkg/utils"
)
var ErrCardNotFound = errors.New("kartu tidak ditemukan")
type Service interface {
GetAllPaginated(query ListCardMasterQuery) ([]CardMaster, int64, error)
GetByID(id uint) (*CardMaster, error)
GetElements() []ElementResponse
Create(req CreateCardMasterRequest) (*CardMaster, error)
Update(id uint, req UpdateCardMasterRequest) (*CardMaster, error)
Delete(id uint) error
}
type service struct {
repo Repository
}
func NewService(repo Repository) Service {
return &service{
repo: repo,
}
}
func (s *service) GetAllPaginated(query ListCardMasterQuery) ([]CardMaster, 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) (*CardMaster, error) {
c, err := s.repo.FindByID(id)
if err != nil {
return nil, ErrCardNotFound
}
return c, nil
}
func (s *service) GetElements() []ElementResponse {
return GetElementsResponse()
}
func (s *service) Create(req CreateCardMasterRequest) (*CardMaster, error) {
elementImage := req.ElementImage
if elementImage == "" && req.Element != "" {
elementImage = GetElementImage(req.Element)
}
c := &CardMaster{
IDSet: req.IDSet,
Name: req.Name,
Number: req.Number,
Image: req.Image,
Stage: req.Stage,
Element: req.Element,
ElementImage: elementImage,
EvolvesFrom: req.EvolvesFrom,
Illustrator: req.Illustrator,
Regulation: req.Regulation,
Rarity: req.Rarity,
IDPriceChartingEng: req.IDPriceChartingEng,
LinkPriceChartingEng: req.LinkPriceChartingEng,
IDPriceChartingJpn: req.IDPriceChartingJpn,
LinkPriceChartingJpn: req.LinkPriceChartingJpn,
}
if req.HP != nil {
req.HP.ElementImage = GetElementImage(req.HP.Element)
bytes, err := json.Marshal(req.HP)
if err == nil {
c.HP = JSONB(bytes)
}
}
if req.Attacks != nil {
for i := range req.Attacks {
for j := range req.Attacks[i].Cost {
req.Attacks[i].Cost[j].Image = GetElementImage(req.Attacks[i].Cost[j].Element)
}
}
bytes, err := json.Marshal(req.Attacks)
if err == nil {
c.Attacks = JSONB(bytes)
}
}
if req.Abilities != nil {
bytes, err := json.Marshal(req.Abilities)
if err == nil {
c.Abilities = JSONB(bytes)
}
}
if req.Battle != nil {
for i := range req.Battle.Weakness {
req.Battle.Weakness[i].ElementImage = GetElementImage(req.Battle.Weakness[i].Element)
}
for i := range req.Battle.Resistance {
req.Battle.Resistance[i].ElementImage = GetElementImage(req.Battle.Resistance[i].Element)
}
for i := range req.Battle.Retreat {
req.Battle.Retreat[i].ElementImage = GetElementImage(req.Battle.Retreat[i].Element)
}
bytes, err := json.Marshal(req.Battle)
if err == nil {
c.Battle = JSONB(bytes)
}
}
if req.EvolutionLine != nil {
bytes, err := json.Marshal(req.EvolutionLine)
if err == nil {
c.EvolutionLine = JSONB(bytes)
}
}
if req.PokedexInfo != nil {
bytes, err := json.Marshal(req.PokedexInfo)
if err == nil {
c.PokedexInfo = JSONB(bytes)
}
}
if req.Effects != nil {
bytes, err := json.Marshal(req.Effects)
if err == nil {
c.Effects = JSONB(bytes)
}
}
if err := s.repo.Create(c); err != nil {
return nil, err
}
return c, nil
}
func (s *service) Update(id uint, req UpdateCardMasterRequest) (*CardMaster, error) {
c, err := s.repo.FindByID(id)
if err != nil {
return nil, ErrCardNotFound
}
var oldIDSet uint
if req.IDSet > 0 && req.IDSet != c.IDSet {
oldIDSet = c.IDSet
c.IDSet = req.IDSet
}
if req.Name != "" {
c.Name = req.Name
}
if req.Number != "" {
c.Number = req.Number
}
if req.Image != "" {
if c.Image != "" && c.Image != req.Image {
utils.DeleteLocalFile(c.Image)
}
c.Image = req.Image
}
if req.Stage != "" {
c.Stage = req.Stage
}
if req.Element != "" {
c.Element = req.Element
c.ElementImage = GetElementImage(req.Element)
}
if req.EvolvesFrom != "" {
c.EvolvesFrom = req.EvolvesFrom
}
if req.Illustrator != "" {
c.Illustrator = req.Illustrator
}
if req.Regulation != "" {
c.Regulation = req.Regulation
}
if req.Rarity != "" {
c.Rarity = req.Rarity
}
if req.IDPriceChartingEng != "" {
c.IDPriceChartingEng = req.IDPriceChartingEng
}
if req.LinkPriceChartingEng != "" {
c.LinkPriceChartingEng = req.LinkPriceChartingEng
}
if req.IDPriceChartingJpn != "" {
c.IDPriceChartingJpn = req.IDPriceChartingJpn
}
if req.LinkPriceChartingJpn != "" {
c.LinkPriceChartingJpn = req.LinkPriceChartingJpn
}
if req.HP != nil {
req.HP.ElementImage = GetElementImage(req.HP.Element)
bytes, err := json.Marshal(req.HP)
if err == nil {
c.HP = JSONB(bytes)
}
}
if req.Attacks != nil {
for i := range req.Attacks {
for j := range req.Attacks[i].Cost {
req.Attacks[i].Cost[j].Image = GetElementImage(req.Attacks[i].Cost[j].Element)
}
}
bytes, err := json.Marshal(req.Attacks)
if err == nil {
c.Attacks = JSONB(bytes)
}
}
if req.Abilities != nil {
bytes, err := json.Marshal(req.Abilities)
if err == nil {
c.Abilities = JSONB(bytes)
}
}
if req.Battle != nil {
for i := range req.Battle.Weakness {
req.Battle.Weakness[i].ElementImage = GetElementImage(req.Battle.Weakness[i].Element)
}
for i := range req.Battle.Resistance {
req.Battle.Resistance[i].ElementImage = GetElementImage(req.Battle.Resistance[i].Element)
}
for i := range req.Battle.Retreat {
req.Battle.Retreat[i].ElementImage = GetElementImage(req.Battle.Retreat[i].Element)
}
bytes, err := json.Marshal(req.Battle)
if err == nil {
c.Battle = JSONB(bytes)
}
}
if req.EvolutionLine != nil {
bytes, err := json.Marshal(req.EvolutionLine)
if err == nil {
c.EvolutionLine = JSONB(bytes)
}
}
if req.PokedexInfo != nil {
bytes, err := json.Marshal(req.PokedexInfo)
if err == nil {
c.PokedexInfo = JSONB(bytes)
}
}
if req.Effects != nil {
bytes, err := json.Marshal(req.Effects)
if err == nil {
c.Effects = JSONB(bytes)
}
}
if err := s.repo.Update(c, oldIDSet); err != nil {
return nil, err
}
return c, nil
}
func (s *service) Delete(id uint) error {
c, err := s.repo.FindByID(id)
if err != nil {
return ErrCardNotFound
}
idSet := c.IDSet
if c.Image != "" {
utils.DeleteLocalFile(c.Image)
}
return s.repo.Delete(id, idSet)
}

View File

@@ -0,0 +1,179 @@
package cardmaster
import (
"encoding/json"
"errors"
"testing"
)
func TestService_GetAllPaginated_Success(t *testing.T) {
repo := &mockRepository{
findAllPaginatedFunc: func(page, limit int, query ListCardMasterQuery) ([]CardMaster, int64, error) {
return []CardMaster{
{ID: 1, Name: "Pikachu"},
{ID: 2, Name: "Charizard"},
}, 2, nil
},
}
svc := NewService(repo)
list, total, err := svc.GetAllPaginated(ListCardMasterQuery{Page: 1, Limit: 20})
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if total != 2 {
t.Errorf("expected total 2, got: %d", total)
}
if len(list) != 2 {
t.Errorf("expected list length 2, got: %d", len(list))
}
}
func TestService_GetAllPaginated_DefaultNormalization(t *testing.T) {
var capturedPage, capturedLimit int
repo := &mockRepository{
findAllPaginatedFunc: func(page, limit int, query ListCardMasterQuery) ([]CardMaster, int64, error) {
capturedPage, capturedLimit = page, limit
return []CardMaster{{ID: 1, Name: "Bulbasaur"}}, 1, nil
},
}
svc := NewService(repo)
_, total, err := svc.GetAllPaginated(ListCardMasterQuery{Page: 0, Limit: 0})
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)
}
}
func TestService_GetByID_Success(t *testing.T) {
repo := &mockRepository{
findByIDFunc: func(id uint) (*CardMaster, error) {
return &CardMaster{ID: id, Name: "Bulbasaur"}, nil
},
}
svc := NewService(repo)
c, err := svc.GetByID(1)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if c.ID != 1 {
t.Errorf("expected ID 1, got: %d", c.ID)
}
if c.Name != "Bulbasaur" {
t.Errorf("expected Name 'Bulbasaur', got: '%s'", c.Name)
}
}
func TestService_GetByID_NotFound(t *testing.T) {
repo := &mockRepository{
findByIDFunc: func(id uint) (*CardMaster, error) {
return nil, errors.New("record not found")
},
}
svc := NewService(repo)
_, err := svc.GetByID(999)
if !errors.Is(err, ErrCardNotFound) {
t.Fatalf("expected ErrCardNotFound, got: %v", err)
}
}
func TestService_Update_Success(t *testing.T) {
repo := &mockRepository{
findByIDFunc: func(id uint) (*CardMaster, error) {
return &CardMaster{ID: id, Name: "Old Name", Element: "Daun"}, nil
},
updateFunc: func(c *CardMaster, oldIDSet uint) error {
return nil
},
}
svc := NewService(repo)
req := UpdateCardMasterRequest{
Name: "Bulbasaur Mega",
Element: "Daun",
}
updated, err := svc.Update(1, req)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if updated.Name != "Bulbasaur Mega" {
t.Errorf("expected Name 'Bulbasaur Mega', got: '%s'", updated.Name)
}
if updated.ElementImage != "/images/elements/Grass.png" {
t.Errorf("expected ElementImage '/images/elements/Grass.png', got: '%s'", updated.ElementImage)
}
}
func TestService_Update_NotFound(t *testing.T) {
repo := &mockRepository{
findByIDFunc: func(id uint) (*CardMaster, error) {
return nil, errors.New("record not found")
},
}
svc := NewService(repo)
_, err := svc.Update(999, UpdateCardMasterRequest{Name: "New Name"})
if !errors.Is(err, ErrCardNotFound) {
t.Fatalf("expected ErrCardNotFound, got: %v", err)
}
}
func TestService_Update_EnrichElementImage(t *testing.T) {
repo := &mockRepository{
findByIDFunc: func(id uint) (*CardMaster, error) {
return &CardMaster{ID: id, Name: "Charmander"}, nil
},
updateFunc: func(c *CardMaster, oldIDSet uint) error {
return nil
},
}
svc := NewService(repo)
req := UpdateCardMasterRequest{
HP: &HP{Value: "70", Element: "Api"},
Attacks: []Attack{
{
Name: "Flame Burst",
Cost: []AttackCost{
{Element: "Api"},
},
Damage: "30",
},
},
}
updated, err := svc.Update(1, req)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
var parsedHP HP
if err := json.Unmarshal(updated.HP, &parsedHP); err != nil {
t.Fatalf("failed to unmarshal HP: %v", err)
}
if parsedHP.ElementImage != "/images/elements/Fire.png" {
t.Errorf("expected HP ElementImage '/images/elements/Fire.png', got: '%s'", parsedHP.ElementImage)
}
var parsedAttacks []Attack
if err := json.Unmarshal(updated.Attacks, &parsedAttacks); err != nil {
t.Fatalf("failed to unmarshal Attacks: %v", err)
}
if len(parsedAttacks) != 1 || parsedAttacks[0].Cost[0].Image != "/images/elements/Fire.png" {
t.Errorf("expected Attack Cost Image '/images/elements/Fire.png', got: '%s'", parsedAttacks[0].Cost[0].Image)
}
}

View File

@@ -0,0 +1,97 @@
package seriessetmaster
import (
"time"
"cardverse/internal/pkg/utils"
)
type ListSeriesQuery struct {
Search string `form:"search"`
}
type ListSetQuery struct {
Search string `form:"search"`
ParentID uint `form:"parent_id"`
}
type CreateSeriesRequest struct {
Name string `json:"name" binding:"required,min=2,max=150"`
Image string `json:"image" binding:"omitempty,max=255"`
}
type CreateSetRequest struct {
ParentID uint `json:"parent_id" binding:"required"`
SetCode string `json:"set_code" binding:"required,max=50"`
SetName string `json:"set_name" binding:"required,max=150"`
Image string `json:"image" binding:"omitempty,max=255"`
Logo string `json:"logo" binding:"omitempty,max=255"`
ReleaseDate *time.Time `json:"release_date" binding:"omitempty"`
}
type UpdateSeriesRequest struct {
Name string `json:"name" binding:"omitempty,min=2,max=150"`
Image string `json:"image" binding:"omitempty,max=255"`
}
type UpdateSetRequest struct {
ParentID *uint `json:"parent_id" binding:"omitempty"`
SetCode string `json:"set_code" binding:"omitempty,max=50"`
SetName string `json:"set_name" binding:"omitempty,max=150"`
Image string `json:"image" binding:"omitempty,max=255"`
Logo string `json:"logo" binding:"omitempty,max=255"`
ReleaseDate *time.Time `json:"release_date" binding:"omitempty"`
}
type SeriesSetMasterResponse struct {
ID uint `json:"id"`
ParentID *uint `json:"parent_id,omitempty"`
SetCode string `json:"set_code,omitempty"`
SetName string `json:"set_name,omitempty"`
SeriesName string `json:"series_name"`
Image string `json:"image,omitempty"`
Logo string `json:"logo,omitempty"`
ReleaseDate *time.Time `json:"release_date,omitempty"`
TotalCards int `json:"total_cards"`
ExpansionCount int `json:"expansion_count"`
CardCount int `json:"card_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy *uint `json:"created_by,omitempty"`
UpdatedBy *uint `json:"updated_by,omitempty"`
Children []SeriesSetMasterResponse `json:"children,omitempty"`
}
func ToSeriesSetMasterResponse(m SeriesSetMaster) SeriesSetMasterResponse {
var children []SeriesSetMasterResponse
if len(m.Children) > 0 {
children = ToSeriesSetMasterResponseList(m.Children)
}
return SeriesSetMasterResponse{
ID: m.ID,
ParentID: m.ParentID,
SetCode: m.SetCode,
SetName: m.SetName,
SeriesName: m.SeriesName,
Image: utils.FormatMediaURL(m.Image),
Logo: utils.FormatMediaURL(m.Logo),
ReleaseDate: m.ReleaseDate,
TotalCards: m.TotalCards,
ExpansionCount: m.ExpansionCount,
CardCount: m.CardCount,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
CreatedBy: m.CreatedBy,
UpdatedBy: m.UpdatedBy,
Children: children,
}
}
func ToSeriesSetMasterResponseList(list []SeriesSetMaster) []SeriesSetMasterResponse {
res := make([]SeriesSetMasterResponse, len(list))
for i, m := range list {
res[i] = ToSeriesSetMasterResponse(m)
}
return res
}

View File

@@ -0,0 +1,550 @@
package seriessetmaster
import (
"bytes"
"encoding/base64"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
"image/png"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"cardverse/internal/pkg/response"
"cardverse/internal/pkg/utils"
"cardverse/internal/pkg/validator"
"github.com/chai2010/webp"
"github.com/gin-gonic/gin"
)
type Handler struct {
service Service
}
func NewHandler(service Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) GetAllSeries(c *gin.Context) {
var query ListSeriesQuery
if err := c.ShouldBindQuery(&query); err != nil {
response.Error(c, http.StatusBadRequest, "query parameter tidak valid", validator.TranslateError(err))
return
}
list, err := h.service.GetAllSeries(query)
if err != nil {
response.Error(c, http.StatusInternalServerError, "gagal mengambil data series", err.Error())
return
}
response.Success(c, http.StatusOK, "berhasil mengambil data series", ToSeriesSetMasterResponseList(list))
}
func (h *Handler) GetAllSets(c *gin.Context) {
var query ListSetQuery
if err := c.ShouldBindQuery(&query); err != nil {
response.Error(c, http.StatusBadRequest, "query parameter tidak valid", validator.TranslateError(err))
return
}
list, err := h.service.GetAllSets(query)
if err != nil {
response.Error(c, http.StatusInternalServerError, "gagal mengambil data set", err.Error())
return
}
response.Success(c, http.StatusOK, "berhasil mengambil data set", ToSeriesSetMasterResponseList(list))
}
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
}
m, 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", ToSeriesSetMasterResponse(*m))
}
func (h *Handler) GetSetByCode(c *gin.Context) {
code := c.Param("code")
if code == "" {
response.Error(c, http.StatusBadRequest, "set_code tidak boleh kosong", nil)
return
}
m, err := h.service.GetSetByCode(code)
if err != nil {
response.Error(c, http.StatusNotFound, err.Error(), nil)
return
}
response.Success(c, http.StatusOK, "berhasil mengambil data set", ToSeriesSetMasterResponse(*m))
}
func (h *Handler) CreateSeries(c *gin.Context) {
var req CreateSeriesRequest
contentType := c.Request.Header.Get("Content-Type")
if strings.Contains(contentType, "multipart/form-data") {
req.Name = c.PostForm("name")
if req.Name == "" {
response.Error(c, http.StatusBadRequest, "name wajib diisi", nil)
return
}
fileHeader, err := c.FormFile("image")
if err == nil && fileHeader != nil {
if fileHeader.Size > 5*1024*1024 {
response.Error(c, http.StatusBadRequest, "ukuran file gambar maksimal 5MB", nil)
return
}
srcFile, err := fileHeader.Open()
if err == nil {
defer srcFile.Close()
img, _, err := image.Decode(srcFile)
if err == nil {
slug := utils.UnderscoreString(req.Name)
uploadDir := "./public/images/series"
_ = os.MkdirAll(uploadDir, os.ModePerm)
filename := fmt.Sprintf("%s.webp", slug)
filepathDst := filepath.Join(uploadDir, filename)
out, err := os.Create(filepathDst)
if err == nil {
defer out.Close()
if err := webp.Encode(out, img, &webp.Options{Lossless: false, Quality: 90}); err == nil {
req.Image = fmt.Sprintf("/images/series/%s", filename)
}
}
}
}
}
} else {
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
return
}
}
created, err := h.service.CreateSeries(req)
if err != nil {
response.Error(c, http.StatusBadRequest, err.Error(), nil)
return
}
response.Success(c, http.StatusCreated, "series berhasil dibuat", ToSeriesSetMasterResponse(*created))
}
func (h *Handler) CreateSet(c *gin.Context) {
var req CreateSetRequest
contentType := c.Request.Header.Get("Content-Type")
if strings.Contains(contentType, "multipart/form-data") {
if parentIDStr := c.PostForm("parent_id"); parentIDStr != "" {
if pID, err := strconv.ParseUint(parentIDStr, 10, 64); err == nil {
req.ParentID = uint(pID)
}
}
req.SetCode = c.PostForm("set_code")
req.SetName = c.PostForm("set_name")
if req.ParentID == 0 || req.SetCode == "" || req.SetName == "" {
response.Error(c, http.StatusBadRequest, "parent_id, set_code, dan set_name wajib diisi", nil)
return
}
parentSeries, err := h.service.GetByID(req.ParentID)
if err != nil || parentSeries == nil || parentSeries.ParentID != nil {
response.Error(c, http.StatusBadRequest, "parent_id tidak ditemukan atau bukan merupakan series", nil)
return
}
seriesSlug := utils.Slugify(parentSeries.SeriesName)
uploadDir := fmt.Sprintf("./public/images/%s/%s", seriesSlug, req.SetCode)
// Handle file 'image'
fileHeader, err := c.FormFile("image")
if err == nil && fileHeader != nil {
if fileHeader.Size <= 5*1024*1024 {
srcFile, err := fileHeader.Open()
if err == nil {
defer srcFile.Close()
img, _, err := image.Decode(srcFile)
if err == nil {
_ = os.MkdirAll(uploadDir, os.ModePerm)
imgSlug := utils.UnderscoreString(req.SetName)
filename := fmt.Sprintf("%s.webp", imgSlug)
filepathDst := filepath.Join(uploadDir, filename)
out, err := os.Create(filepathDst)
if err == nil {
defer out.Close()
if err := webp.Encode(out, img, &webp.Options{Lossless: false, Quality: 90}); err == nil {
req.Image = fmt.Sprintf("/images/%s/%s/%s", seriesSlug, req.SetCode, filename)
}
}
}
}
}
}
// Handle file 'logo'
logoHeader, err := c.FormFile("logo")
if err == nil && logoHeader != nil {
if logoHeader.Size <= 5*1024*1024 {
_ = os.MkdirAll(uploadDir, os.ModePerm)
origFilename := logoHeader.Filename
ext := filepath.Ext(origFilename)
baseName := strings.TrimSuffix(origFilename, ext)
if baseName == "" {
baseName = req.SetCode
}
var logoFilename string
if (len(req.SetCode) > 5 || len(baseName) > 5) && (strings.Contains(req.SetCode, "-P") || strings.Contains(baseName, "-P")) {
logoFilename = "PROMO.svg"
} else {
logoFilename = fmt.Sprintf("%s.svg", baseName)
}
filepathDstLogo := filepath.Join(uploadDir, logoFilename)
srcLogo, err := logoHeader.Open()
if err == nil {
defer srcLogo.Close()
logoBytes, err := io.ReadAll(srcLogo)
if err == nil {
checkLen := len(logoBytes)
if checkLen > 512 {
checkLen = 512
}
isSVG := strings.EqualFold(ext, ".svg") || strings.Contains(strings.ToLower(string(logoBytes[:checkLen])), "<svg")
if isSVG {
if err := os.WriteFile(filepathDstLogo, logoBytes, 0644); err == nil {
req.Logo = fmt.Sprintf("/images/%s/%s/%s", seriesSlug, req.SetCode, logoFilename)
}
} else {
img, _, err := image.Decode(bytes.NewReader(logoBytes))
if err == nil && img != nil {
var buf bytes.Buffer
if err := png.Encode(&buf, img); err == nil {
b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
svgContent := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="%d" height="%d" viewBox="0 0 %d %d"><image width="%d" height="%d" xlink:href="data:image/png;base64,%s"/></svg>`, w, h, w, h, w, h, b64)
if err := os.WriteFile(filepathDstLogo, []byte(svgContent), 0644); err == nil {
req.Logo = fmt.Sprintf("/images/%s/%s/%s", seriesSlug, req.SetCode, logoFilename)
}
}
} else {
if err := c.SaveUploadedFile(logoHeader, filepathDstLogo); err == nil {
req.Logo = fmt.Sprintf("/images/%s/%s/%s", seriesSlug, req.SetCode, logoFilename)
}
}
}
}
}
}
}
} else {
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
return
}
}
created, err := h.service.CreateSet(req)
if err != nil {
response.Error(c, http.StatusBadRequest, err.Error(), nil)
return
}
response.Success(c, http.StatusCreated, "set berhasil dibuat", ToSeriesSetMasterResponse(*created))
}
func (h *Handler) UpdateSeries(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
}
var req UpdateSeriesRequest
contentType := c.Request.Header.Get("Content-Type")
if strings.Contains(contentType, "multipart/form-data") {
req.Name = c.PostForm("name")
fileHeader, err := c.FormFile("image")
if err == nil && fileHeader != nil {
if fileHeader.Size > 5*1024*1024 {
response.Error(c, http.StatusBadRequest, "ukuran file gambar maksimal 5MB", nil)
return
}
srcFile, err := fileHeader.Open()
if err != nil {
response.Error(c, http.StatusInternalServerError, "gagal membaca file gambar series", err.Error())
return
}
defer srcFile.Close()
img, _, err := image.Decode(srcFile)
if err != nil {
response.Error(c, http.StatusBadRequest, "file yang diunggah bukan format gambar yang valid", err.Error())
return
}
seriesName := req.Name
if seriesName == "" {
m, err := h.service.GetByID(id)
if err == nil && m != nil {
seriesName = m.SeriesName
}
}
slug := utils.UnderscoreString(seriesName)
uploadDir := "./public/images/series"
if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil {
response.Error(c, http.StatusInternalServerError, "gagal membuat direktori penyimpanan series", err.Error())
return
}
filename := fmt.Sprintf("%s.webp", slug)
filepathDst := filepath.Join(uploadDir, filename)
out, err := os.Create(filepathDst)
if err != nil {
response.Error(c, http.StatusInternalServerError, "gagal membuat file gambar series", err.Error())
return
}
defer out.Close()
if err := webp.Encode(out, img, &webp.Options{Lossless: false, Quality: 90}); err != nil {
response.Error(c, http.StatusInternalServerError, "gagal mengompresi gambar ke format WebP", err.Error())
return
}
req.Image = fmt.Sprintf("/images/series/%s", filename)
}
} else {
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
return
}
}
updated, err := h.service.UpdateSeries(id, req)
if err != nil {
response.Error(c, http.StatusBadRequest, err.Error(), nil)
return
}
response.Success(c, http.StatusOK, "series berhasil diupdate", ToSeriesSetMasterResponse(*updated))
}
func (h *Handler) UpdateSet(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
}
mOld, err := h.service.GetByID(id)
if err != nil || mOld == nil {
response.Error(c, http.StatusNotFound, "data set tidak ditemukan", nil)
return
}
var req UpdateSetRequest
contentType := c.Request.Header.Get("Content-Type")
if strings.Contains(contentType, "multipart/form-data") {
req.SetCode = c.PostForm("set_code")
req.SetName = c.PostForm("set_name")
if parentIDStr := c.PostForm("parent_id"); parentIDStr != "" {
if pID, err := strconv.ParseUint(parentIDStr, 10, 64); err == nil {
uintPID := uint(pID)
req.ParentID = &uintPID
}
}
setCode := req.SetCode
if setCode == "" {
setCode = mOld.SetCode
}
setName := req.SetName
if setName == "" {
setName = mOld.SetName
}
seriesName := mOld.SeriesName
if req.ParentID != nil {
parentSeries, err := h.service.GetByID(*req.ParentID)
if err == nil && parentSeries != nil && parentSeries.ParentID == nil {
seriesName = parentSeries.SeriesName
}
}
seriesSlug := utils.Slugify(seriesName)
uploadDir := fmt.Sprintf("./public/images/%s/%s", seriesSlug, setCode)
// 1. Handle file 'image' (WebP format, nama file = utils.UnderscoreString(setName) + ".webp")
fileHeader, err := c.FormFile("image")
if err == nil && fileHeader != nil {
if fileHeader.Size > 5*1024*1024 {
response.Error(c, http.StatusBadRequest, "ukuran file gambar maksimal 5MB", nil)
return
}
srcFile, err := fileHeader.Open()
if err == nil {
defer srcFile.Close()
img, _, err := image.Decode(srcFile)
if err == nil {
_ = os.MkdirAll(uploadDir, os.ModePerm)
imgSlug := utils.UnderscoreString(setName)
filename := fmt.Sprintf("%s.webp", imgSlug)
filepathDst := filepath.Join(uploadDir, filename)
out, err := os.Create(filepathDst)
if err == nil {
defer out.Close()
if err := webp.Encode(out, img, &webp.Options{Lossless: false, Quality: 90}); err == nil {
req.Image = fmt.Sprintf("/images/%s/%s/%s", seriesSlug, setCode, filename)
}
}
}
}
}
// 2. Handle file 'logo' (Format SVG: file SVG disimpan langsung, JPG/PNG/GIF/WebP dikonversi ke SVG XML)
logoHeader, err := c.FormFile("logo")
if err == nil && logoHeader != nil {
if logoHeader.Size > 5*1024*1024 {
response.Error(c, http.StatusBadRequest, "ukuran file logo maksimal 5MB", nil)
return
}
_ = os.MkdirAll(uploadDir, os.ModePerm)
origFilename := logoHeader.Filename
ext := filepath.Ext(origFilename)
baseName := strings.TrimSuffix(origFilename, ext)
if baseName == "" {
baseName = setCode
}
var logoFilename string
if (len(setCode) > 5 || len(baseName) > 5) && (strings.Contains(setCode, "-P") || strings.Contains(baseName, "-P")) {
logoFilename = "PROMO.svg"
} else {
logoFilename = fmt.Sprintf("%s.svg", baseName)
}
filepathDstLogo := filepath.Join(uploadDir, logoFilename)
srcLogo, err := logoHeader.Open()
if err == nil {
defer srcLogo.Close()
logoBytes, err := io.ReadAll(srcLogo)
if err == nil {
checkLen := len(logoBytes)
if checkLen > 512 {
checkLen = 512
}
isSVG := strings.EqualFold(ext, ".svg") || strings.Contains(strings.ToLower(string(logoBytes[:checkLen])), "<svg")
if isSVG {
if err := os.WriteFile(filepathDstLogo, logoBytes, 0644); err == nil {
req.Logo = fmt.Sprintf("/images/%s/%s/%s", seriesSlug, setCode, logoFilename)
}
} else {
img, _, err := image.Decode(bytes.NewReader(logoBytes))
if err == nil && img != nil {
var buf bytes.Buffer
if err := png.Encode(&buf, img); err == nil {
b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
svgContent := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="%d" height="%d" viewBox="0 0 %d %d"><image width="%d" height="%d" xlink:href="data:image/png;base64,%s"/></svg>`, w, h, w, h, w, h, b64)
if err := os.WriteFile(filepathDstLogo, []byte(svgContent), 0644); err == nil {
req.Logo = fmt.Sprintf("/images/%s/%s/%s", seriesSlug, setCode, logoFilename)
}
}
} else {
if err := c.SaveUploadedFile(logoHeader, filepathDstLogo); err == nil {
req.Logo = fmt.Sprintf("/images/%s/%s/%s", seriesSlug, setCode, logoFilename)
}
}
}
}
}
}
} else {
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
return
}
}
updated, err := h.service.UpdateSet(id, req)
if err != nil {
response.Error(c, http.StatusBadRequest, err.Error(), nil)
return
}
response.Success(c, http.StatusOK, "set berhasil diupdate", ToSeriesSetMasterResponse(*updated))
}
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, "berhasil menghapus data series/set", 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
}

View File

@@ -0,0 +1,60 @@
package seriessetmaster
import "time"
// SeriesSetMaster merepresentasikan tabel utama "series_set_masters" di database (digunakan untuk AutoMigrate GORM)
type SeriesSetMaster struct {
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
ParentID *uint `json:"parent_id" gorm:"index;default:null"`
SetCode string `json:"set_code,omitempty" gorm:"type:varchar(50);index"`
SetName string `json:"set_name,omitempty" gorm:"type:varchar(150)"`
SeriesName string `json:"series_name" gorm:"type:varchar(150);not null;index"`
Image string `json:"image" gorm:"type:varchar(255)"`
Logo string `json:"logo,omitempty" gorm:"type:varchar(255)"`
ReleaseDate *time.Time `json:"release_date,omitempty" gorm:"type:date"`
TotalCards int `json:"total_cards" gorm:"default:0"`
ExpansionCount int `json:"expansion_count" gorm:"default:0"`
CardCount int `json:"card_count" gorm:"default:0"`
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"`
Parent *SeriesSetMaster `json:"parent,omitempty" gorm:"foreignKey:ParentID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:RESTRICT;"`
Children []SeriesSetMaster `json:"children,omitempty" gorm:"foreignKey:ParentID;references:ID"`
}
func (SeriesSetMaster) TableName() string {
return "series_set_masters"
}
// Series merepresentasikan data khusus Series untuk kelola data/response
type Series struct {
ID uint `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
ExpansionCount int `json:"expansion_count"`
CardCount int `json:"card_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy *uint `json:"created_by"`
UpdatedBy *uint `json:"updated_by"`
Sets []Set `json:"sets,omitempty"`
}
// Set merepresentasikan data khusus Set untuk kelola data/response
type Set struct {
ID uint `json:"id"`
ParentID uint `json:"parent_id"`
SetCode string `json:"set_code"`
SetName string `json:"set_name"`
SeriesName string `json:"series_name"`
Image string `json:"image"`
Logo string `json:"logo,omitempty"`
ReleaseDate *time.Time `json:"release_date,omitempty"`
TotalCards int `json:"total_cards"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy *uint `json:"created_by"`
UpdatedBy *uint `json:"updated_by"`
Series *Series `json:"series,omitempty"`
}

View File

@@ -0,0 +1,228 @@
package seriessetmaster
import (
"gorm.io/gorm"
)
type Repository interface {
FindAllSeries(search string) ([]SeriesSetMaster, error)
FindAllSets(search string, parentID uint) ([]SeriesSetMaster, error)
FindByID(id uint) (*SeriesSetMaster, error)
FindBySetCode(setCode string) (*SeriesSetMaster, error)
FindCardImagesBySetID(setID uint) ([]string, error)
Create(m *SeriesSetMaster) error
CreateSet(m *SeriesSetMaster) error
Update(m *SeriesSetMaster) error
UpdateSet(m *SeriesSetMaster, oldParentID *uint) error
DeleteCascade(m *SeriesSetMaster) error
RecalculateSeriesStats(seriesID uint) error
RecalculateSetStats(setID uint) error
}
type repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) Repository {
return &repository{db: db}
}
func (r *repository) FindAllSeries(search string) ([]SeriesSetMaster, error) {
var list []SeriesSetMaster
query := r.db.Model(&SeriesSetMaster{}).
Select("id", "parent_id", "series_name", "image", "expansion_count", "card_count", "created_at", "updated_at", "created_by", "updated_by").
Where("parent_id IS NULL")
if search != "" {
pattern := "%" + search + "%"
query = query.Where("series_name ILIKE ?", pattern)
}
err := query.Order("id ASC").Preload("Children").Find(&list).Error
if err != nil {
return nil, err
}
return list, nil
}
func (r *repository) FindAllSets(search string, parentID uint) ([]SeriesSetMaster, error) {
var list []SeriesSetMaster
query := r.db.Model(&SeriesSetMaster{}).
Select("id", "parent_id", "set_code", "set_name", "series_name", "image", "logo", "release_date", "total_cards", "created_at", "updated_at", "created_by", "updated_by").
Where("parent_id IS NOT NULL")
if parentID > 0 {
query = query.Where("parent_id = ?", parentID)
}
if search != "" {
pattern := "%" + search + "%"
query = query.Where("set_name ILIKE ? OR set_code ILIKE ? OR series_name ILIKE ?", pattern, pattern, pattern)
}
err := query.Order("id ASC").Find(&list).Error
if err != nil {
return nil, err
}
return list, nil
}
func (r *repository) FindByID(id uint) (*SeriesSetMaster, error) {
var m SeriesSetMaster
err := r.db.Select("id", "parent_id", "set_code", "set_name", "series_name", "image", "logo", "release_date", "total_cards", "expansion_count", "card_count", "created_at", "updated_at", "created_by", "updated_by").
Preload("Children").
First(&m, id).Error
if err != nil {
return nil, err
}
return &m, nil
}
func (r *repository) FindBySetCode(setCode string) (*SeriesSetMaster, error) {
var m SeriesSetMaster
err := r.db.Select("id", "parent_id", "set_code", "set_name", "series_name", "image", "logo", "release_date", "total_cards", "expansion_count", "card_count", "created_at", "updated_at", "created_by", "updated_by").
Where("set_code = ? AND parent_id IS NOT NULL", setCode).
First(&m).Error
if err != nil {
return nil, err
}
return &m, nil
}
func (r *repository) FindCardImagesBySetID(setID uint) ([]string, error) {
var images []string
err := r.db.Table("card_masters").Select("image").Where("id_set = ? AND image != ''", setID).Pluck("image", &images).Error
return images, err
}
func (r *repository) Create(m *SeriesSetMaster) error {
return r.db.Create(m).Error
}
func (r *repository) CreateSet(m *SeriesSetMaster) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(m).Error; err != nil {
return err
}
if m.ParentID != nil {
return r.RecalculateSeriesStatsTx(tx, *m.ParentID)
}
return nil
})
}
func (r *repository) Update(m *SeriesSetMaster) error {
return r.db.Save(m).Error
}
func (r *repository) UpdateSet(m *SeriesSetMaster, oldParentID *uint) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(m).Error; err != nil {
return err
}
if m.ParentID != nil {
if err := r.RecalculateSeriesStatsTx(tx, *m.ParentID); err != nil {
return err
}
}
if oldParentID != nil && (m.ParentID == nil || *oldParentID != *m.ParentID) {
if err := r.RecalculateSeriesStatsTx(tx, *oldParentID); err != nil {
return err
}
}
return nil
})
}
func (r *repository) DeleteCascade(m *SeriesSetMaster) error {
return r.db.Transaction(func(tx *gorm.DB) error {
parentID := m.ParentID
if m.ParentID == nil {
if err := tx.Table("card_masters").Where("id_set IN (SELECT id FROM series_set_masters WHERE parent_id = ?)", m.ID).Delete(nil).Error; err != nil {
return err
}
if err := tx.Where("parent_id = ?", m.ID).Delete(&SeriesSetMaster{}).Error; err != nil {
return err
}
if err := tx.Delete(&SeriesSetMaster{}, m.ID).Error; err != nil {
return err
}
} else {
if err := tx.Table("card_masters").Where("id_set = ?", m.ID).Delete(nil).Error; err != nil {
return err
}
if err := tx.Delete(&SeriesSetMaster{}, m.ID).Error; err != nil {
return err
}
if parentID != nil {
if err := r.RecalculateSeriesStatsTx(tx, *parentID); err != nil {
return err
}
}
}
return nil
})
}
func (r *repository) RecalculateSeriesStats(seriesID uint) error {
return r.RecalculateSeriesStatsTx(r.db, seriesID)
}
func (r *repository) RecalculateSeriesStatsTx(tx *gorm.DB, seriesID uint) error {
if seriesID == 0 {
return nil
}
var expansionCount int64
if err := tx.Model(&SeriesSetMaster{}).Where("parent_id = ?", seriesID).Count(&expansionCount).Error; err != nil {
return err
}
var cardCount int64
if err := tx.Table("card_masters").
Where("id_set IN (SELECT id FROM series_set_masters WHERE parent_id = ?)", seriesID).
Count(&cardCount).Error; err != nil {
return err
}
return tx.Model(&SeriesSetMaster{}).
Where("id = ? AND parent_id IS NULL", seriesID).
Updates(map[string]interface{}{
"expansion_count": int(expansionCount),
"card_count": int(cardCount),
}).Error
}
func (r *repository) RecalculateSetStats(setID uint) error {
return r.RecalculateSetStatsTx(r.db, setID)
}
func (r *repository) RecalculateSetStatsTx(tx *gorm.DB, setID uint) error {
if setID == 0 {
return nil
}
var set SeriesSetMaster
if err := tx.Select("id", "parent_id").First(&set, setID).Error; err != nil {
return err
}
var totalCards int64
if err := tx.Table("card_masters").Where("id_set = ?", setID).Count(&totalCards).Error; err != nil {
return err
}
if err := tx.Model(&SeriesSetMaster{}).Where("id = ?", setID).Update("total_cards", int(totalCards)).Error; err != nil {
return err
}
if set.ParentID != nil {
return r.RecalculateSeriesStatsTx(tx, *set.ParentID)
}
return nil
}

View File

@@ -0,0 +1,100 @@
package seriessetmaster
type mockRepository struct {
findAllSeriesFunc func(search string) ([]SeriesSetMaster, error)
findAllSetsFunc func(search string, parentID uint) ([]SeriesSetMaster, error)
findByIDFunc func(id uint) (*SeriesSetMaster, error)
findBySetCodeFunc func(setCode string) (*SeriesSetMaster, error)
findCardImagesBySetIDFunc func(setID uint) ([]string, error)
createFunc func(m *SeriesSetMaster) error
createSetFunc func(m *SeriesSetMaster) error
updateFunc func(m *SeriesSetMaster) error
updateSetFunc func(m *SeriesSetMaster, oldParentID *uint) error
deleteCascadeFunc func(m *SeriesSetMaster) error
recalculateSeriesStatsFunc func(seriesID uint) error
recalculateSetStatsFunc func(setID uint) error
}
func (m *mockRepository) FindAllSeries(search string) ([]SeriesSetMaster, error) {
if m.findAllSeriesFunc != nil {
return m.findAllSeriesFunc(search)
}
return nil, nil
}
func (m *mockRepository) FindAllSets(search string, parentID uint) ([]SeriesSetMaster, error) {
if m.findAllSetsFunc != nil {
return m.findAllSetsFunc(search, parentID)
}
return nil, nil
}
func (m *mockRepository) FindByID(id uint) (*SeriesSetMaster, error) {
if m.findByIDFunc != nil {
return m.findByIDFunc(id)
}
return nil, nil
}
func (m *mockRepository) FindBySetCode(setCode string) (*SeriesSetMaster, error) {
if m.findBySetCodeFunc != nil {
return m.findBySetCodeFunc(setCode)
}
return nil, nil
}
func (m *mockRepository) FindCardImagesBySetID(setID uint) ([]string, error) {
if m.findCardImagesBySetIDFunc != nil {
return m.findCardImagesBySetIDFunc(setID)
}
return nil, nil
}
func (m *mockRepository) Create(item *SeriesSetMaster) error {
if m.createFunc != nil {
return m.createFunc(item)
}
return nil
}
func (m *mockRepository) CreateSet(item *SeriesSetMaster) error {
if m.createSetFunc != nil {
return m.createSetFunc(item)
}
return m.Create(item)
}
func (m *mockRepository) Update(item *SeriesSetMaster) error {
if m.updateFunc != nil {
return m.updateFunc(item)
}
return nil
}
func (m *mockRepository) UpdateSet(item *SeriesSetMaster, oldParentID *uint) error {
if m.updateSetFunc != nil {
return m.updateSetFunc(item, oldParentID)
}
return m.Update(item)
}
func (m *mockRepository) DeleteCascade(item *SeriesSetMaster) error {
if m.deleteCascadeFunc != nil {
return m.deleteCascadeFunc(item)
}
return nil
}
func (m *mockRepository) RecalculateSeriesStats(seriesID uint) error {
if m.recalculateSeriesStatsFunc != nil {
return m.recalculateSeriesStatsFunc(seriesID)
}
return nil
}
func (m *mockRepository) RecalculateSetStats(setID uint) error {
if m.recalculateSetStatsFunc != nil {
return m.recalculateSetStatsFunc(setID)
}
return nil
}

View File

@@ -0,0 +1,36 @@
package seriessetmaster
import (
"cardverse/internal/middleware"
"cardverse/internal/modules/user"
"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)
series := router.Group("/series")
{
series.GET("", handler.GetAllSeries)
series.POST("", middleware.AuthRequired(), middleware.RequireRoles(user.RoleAdmin, user.RoleSuperAdmin), handler.CreateSeries)
series.PUT("/:id", middleware.AuthRequired(), middleware.RequireRoles(user.RoleAdmin, user.RoleSuperAdmin), handler.UpdateSeries)
}
sets := router.Group("/sets")
{
sets.GET("", handler.GetAllSets)
sets.GET("/code/:code", handler.GetSetByCode)
sets.POST("", middleware.AuthRequired(), middleware.RequireRoles(user.RoleAdmin, user.RoleSuperAdmin), handler.CreateSet)
sets.PUT("/:id", middleware.AuthRequired(), middleware.RequireRoles(user.RoleAdmin, user.RoleSuperAdmin), handler.UpdateSet)
}
master := router.Group("/series-set-masters")
{
master.GET("/:id", handler.GetByID)
master.DELETE("/:id", middleware.AuthRequired(), middleware.RequireRoles(user.RoleAdmin, user.RoleSuperAdmin), handler.Delete)
}
}

View File

@@ -0,0 +1,206 @@
package seriessetmaster
import (
"errors"
"cardverse/internal/pkg/utils"
)
var ErrSeriesSetNotFound = errors.New("data series atau set tidak ditemukan")
type Service interface {
GetAllSeries(query ListSeriesQuery) ([]SeriesSetMaster, error)
GetAllSets(query ListSetQuery) ([]SeriesSetMaster, error)
GetByID(id uint) (*SeriesSetMaster, error)
GetSetByCode(code string) (*SeriesSetMaster, error)
CreateSeries(req CreateSeriesRequest) (*SeriesSetMaster, error)
CreateSet(req CreateSetRequest) (*SeriesSetMaster, error)
UpdateSeries(id uint, req UpdateSeriesRequest) (*SeriesSetMaster, error)
UpdateSet(id uint, req UpdateSetRequest) (*SeriesSetMaster, error)
Delete(id uint) error
}
type service struct {
repo Repository
}
func NewService(repo Repository) Service {
return &service{repo: repo}
}
func (s *service) GetAllSeries(query ListSeriesQuery) ([]SeriesSetMaster, error) {
return s.repo.FindAllSeries(query.Search)
}
func (s *service) GetAllSets(query ListSetQuery) ([]SeriesSetMaster, error) {
return s.repo.FindAllSets(query.Search, query.ParentID)
}
func (s *service) GetByID(id uint) (*SeriesSetMaster, error) {
m, err := s.repo.FindByID(id)
if err != nil {
return nil, ErrSeriesSetNotFound
}
return m, nil
}
func (s *service) GetSetByCode(code string) (*SeriesSetMaster, error) {
m, err := s.repo.FindBySetCode(code)
if err != nil {
return nil, ErrSeriesSetNotFound
}
return m, nil
}
func (s *service) CreateSeries(req CreateSeriesRequest) (*SeriesSetMaster, error) {
m := &SeriesSetMaster{
SeriesName: req.Name,
Image: req.Image,
ParentID: nil,
}
if err := s.repo.Create(m); err != nil {
return nil, err
}
return m, nil
}
func (s *service) CreateSet(req CreateSetRequest) (*SeriesSetMaster, error) {
parent, err := s.repo.FindByID(req.ParentID)
if err != nil || parent == nil || parent.ParentID != nil {
return nil, errors.New("parent_id tidak ditemukan atau bukan merupakan series")
}
m := &SeriesSetMaster{
ParentID: &req.ParentID,
SetCode: req.SetCode,
SetName: req.SetName,
SeriesName: parent.SeriesName,
Image: req.Image,
Logo: req.Logo,
ReleaseDate: req.ReleaseDate,
}
if err := s.repo.CreateSet(m); err != nil {
return nil, err
}
return m, nil
}
func (s *service) UpdateSeries(id uint, req UpdateSeriesRequest) (*SeriesSetMaster, error) {
m, err := s.repo.FindByID(id)
if err != nil {
return nil, ErrSeriesSetNotFound
}
if m.ParentID != nil {
return nil, errors.New("record ini adalah set, bukan series")
}
if req.Name != "" {
m.SeriesName = req.Name
}
if req.Image != "" {
if m.Image != "" && m.Image != req.Image {
utils.DeleteLocalFile(m.Image)
}
m.Image = req.Image
}
if err := s.repo.Update(m); err != nil {
return nil, err
}
return m, nil
}
func (s *service) UpdateSet(id uint, req UpdateSetRequest) (*SeriesSetMaster, error) {
m, err := s.repo.FindByID(id)
if err != nil {
return nil, ErrSeriesSetNotFound
}
if m.ParentID == nil {
return nil, errors.New("record ini adalah series, bukan set")
}
var oldParentID *uint
if req.ParentID != nil && (m.ParentID == nil || *req.ParentID != *m.ParentID) {
oldParentID = m.ParentID
parent, err := s.repo.FindByID(*req.ParentID)
if err != nil || parent == nil || parent.ParentID != nil {
return nil, errors.New("parent_id tidak ditemukan atau bukan merupakan series")
}
m.ParentID = req.ParentID
m.SeriesName = parent.SeriesName
}
if req.SetCode != "" {
m.SetCode = req.SetCode
}
if req.SetName != "" {
m.SetName = req.SetName
}
if req.Image != "" {
if m.Image != "" && m.Image != req.Image {
utils.DeleteLocalFile(m.Image)
}
m.Image = req.Image
}
if req.Logo != "" {
if m.Logo != "" && m.Logo != req.Logo {
utils.DeleteLocalFile(m.Logo)
}
m.Logo = req.Logo
}
if req.ReleaseDate != nil {
m.ReleaseDate = req.ReleaseDate
}
if err := s.repo.UpdateSet(m, oldParentID); err != nil {
return nil, err
}
return m, nil
}
func (s *service) Delete(id uint) error {
m, err := s.repo.FindByID(id)
if err != nil {
return ErrSeriesSetNotFound
}
if m.ParentID == nil {
childSets, _ := s.repo.FindAllSets("", m.ID)
for _, set := range childSets {
cardImages, _ := s.repo.FindCardImagesBySetID(set.ID)
for _, imgPath := range cardImages {
utils.DeleteLocalFile(imgPath)
}
if set.Image != "" {
utils.DeleteLocalFile(set.Image)
}
if set.Logo != "" {
utils.DeleteLocalFile(set.Logo)
}
}
if m.Image != "" {
utils.DeleteLocalFile(m.Image)
}
} else {
cardImages, _ := s.repo.FindCardImagesBySetID(m.ID)
for _, imgPath := range cardImages {
utils.DeleteLocalFile(imgPath)
}
if m.Image != "" {
utils.DeleteLocalFile(m.Image)
}
if m.Logo != "" {
utils.DeleteLocalFile(m.Logo)
}
}
return s.repo.DeleteCascade(m)
}

View File

@@ -0,0 +1,226 @@
package seriessetmaster
import (
"errors"
"testing"
"time"
)
func TestService_GetAllSeries_Success(t *testing.T) {
repo := &mockRepository{
findAllSeriesFunc: func(search string) ([]SeriesSetMaster, error) {
return []SeriesSetMaster{
{ID: 1, SeriesName: "Evolusi Mega"},
{ID: 2, SeriesName: "Scarlet & Violet"},
}, nil
},
}
svc := NewService(repo)
list, err := svc.GetAllSeries(ListSeriesQuery{Search: ""})
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if len(list) != 2 {
t.Fatalf("expected 2 items, got: %d", len(list))
}
if list[0].SeriesName != "Evolusi Mega" {
t.Errorf("expected 'Evolusi Mega', got: %s", list[0].SeriesName)
}
}
func TestService_GetAllSets_Success(t *testing.T) {
parentID := uint(1)
repo := &mockRepository{
findAllSetsFunc: func(search string, pID uint) ([]SeriesSetMaster, error) {
return []SeriesSetMaster{
{ID: 10, ParentID: &parentID, SetCode: "MA1", SetName: "Evolusi Mega"},
}, nil
},
}
svc := NewService(repo)
list, err := svc.GetAllSets(ListSetQuery{Search: "MA1", ParentID: 1})
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if len(list) != 1 {
t.Fatalf("expected 1 item, got: %d", len(list))
}
if list[0].SetCode != "MA1" {
t.Errorf("expected 'MA1', got: %s", list[0].SetCode)
}
}
func TestService_GetByID_Success(t *testing.T) {
repo := &mockRepository{
findByIDFunc: func(id uint) (*SeriesSetMaster, error) {
return &SeriesSetMaster{ID: id, SeriesName: "Pedang & Perisai"}, nil
},
}
svc := NewService(repo)
result, err := svc.GetByID(1)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if result.ID != 1 {
t.Errorf("expected ID 1, got: %d", result.ID)
}
if result.SeriesName != "Pedang & Perisai" {
t.Errorf("expected 'Pedang & Perisai', got: %s", result.SeriesName)
}
}
func TestService_GetSetByCode_Success(t *testing.T) {
parentID := uint(1)
repo := &mockRepository{
findBySetCodeFunc: func(code string) (*SeriesSetMaster, error) {
return &SeriesSetMaster{ID: 10, ParentID: &parentID, SetCode: code, SetName: "Evolusi Mega M-P"}, nil
},
}
svc := NewService(repo)
result, err := svc.GetSetByCode("M-P")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if result.SetCode != "M-P" {
t.Errorf("expected 'M-P', got: %s", result.SetCode)
}
}
func TestService_GetByID_NotFound(t *testing.T) {
repo := &mockRepository{
findByIDFunc: func(id uint) (*SeriesSetMaster, error) {
return nil, errors.New("record not found")
},
}
svc := NewService(repo)
_, err := svc.GetByID(999)
if !errors.Is(err, ErrSeriesSetNotFound) {
t.Fatalf("expected ErrSeriesSetNotFound, got: %v", err)
}
}
func TestService_UpdateSeries_Success(t *testing.T) {
repo := &mockRepository{
findByIDFunc: func(id uint) (*SeriesSetMaster, error) {
return &SeriesSetMaster{ID: id, ParentID: nil, SeriesName: "Old Name"}, nil
},
updateFunc: func(m *SeriesSetMaster) error {
return nil
},
}
svc := NewService(repo)
req := UpdateSeriesRequest{
Name: "New Series Name",
Image: "/images/series/new.png",
}
updated, err := svc.UpdateSeries(1, req)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if updated.SeriesName != "New Series Name" {
t.Errorf("expected 'New Series Name', got: %s", updated.SeriesName)
}
if updated.Image != "/images/series/new.png" {
t.Errorf("expected Image '/images/series/new.png', got: %s", updated.Image)
}
}
func TestService_UpdateSeries_NotFound(t *testing.T) {
repo := &mockRepository{
findByIDFunc: func(id uint) (*SeriesSetMaster, error) {
return nil, errors.New("record not found")
},
}
svc := NewService(repo)
_, err := svc.UpdateSeries(999, UpdateSeriesRequest{Name: "Test"})
if !errors.Is(err, ErrSeriesSetNotFound) {
t.Fatalf("expected ErrSeriesSetNotFound, got: %v", err)
}
}
func TestService_UpdateSeries_InvalidRecordType(t *testing.T) {
parentID := uint(1)
repo := &mockRepository{
findByIDFunc: func(id uint) (*SeriesSetMaster, error) {
return &SeriesSetMaster{ID: id, ParentID: &parentID, SetCode: "MA1"}, nil
},
}
svc := NewService(repo)
_, err := svc.UpdateSeries(1, UpdateSeriesRequest{Name: "Test"})
if err == nil {
t.Fatal("expected error when trying to update a Set using UpdateSeries, got nil")
}
}
func TestService_UpdateSet_Success(t *testing.T) {
parentID := uint(1)
repo := &mockRepository{
findByIDFunc: func(id uint) (*SeriesSetMaster, error) {
if id == 1 {
return &SeriesSetMaster{ID: 1, ParentID: nil, SeriesName: "Evolusi Mega"}, nil
}
return &SeriesSetMaster{ID: id, ParentID: &parentID, SetCode: "OLD", SetName: "Old Set", SeriesName: "Evolusi Mega"}, nil
},
updateFunc: func(m *SeriesSetMaster) error {
return nil
},
}
svc := NewService(repo)
now := time.Now()
newParentID := uint(1)
req := UpdateSetRequest{
ParentID: &newParentID,
SetCode: "NEW1",
SetName: "New Set Name",
ReleaseDate: &now,
}
updated, err := svc.UpdateSet(10, req)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if updated.SetCode != "NEW1" {
t.Errorf("expected 'NEW1', got: %s", updated.SetCode)
}
if updated.SetName != "New Set Name" {
t.Errorf("expected 'New Set Name', got: %s", updated.SetName)
}
}
func TestService_UpdateSet_NotFound(t *testing.T) {
repo := &mockRepository{
findByIDFunc: func(id uint) (*SeriesSetMaster, error) {
return nil, errors.New("record not found")
},
}
svc := NewService(repo)
_, err := svc.UpdateSet(999, UpdateSetRequest{SetName: "Test"})
if !errors.Is(err, ErrSeriesSetNotFound) {
t.Fatalf("expected ErrSeriesSetNotFound, got: %v", err)
}
}
func TestService_UpdateSet_InvalidRecordType(t *testing.T) {
repo := &mockRepository{
findByIDFunc: func(id uint) (*SeriesSetMaster, error) {
return &SeriesSetMaster{ID: id, ParentID: nil, SeriesName: "Evolusi Mega"}, nil
},
}
svc := NewService(repo)
_, err := svc.UpdateSet(1, UpdateSetRequest{SetName: "Test"})
if err == nil {
t.Fatal("expected error when trying to update a Series using UpdateSet, got nil")
}
}

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)
}
}