fix: implement user roles and authentication system with tests
All checks were successful
CI Pipeline / Build (push) Successful in 1m17s
CI Pipeline / Notification (push) Successful in 1s
CI Pipeline / Lint (push) Successful in 3m25s
CI Pipeline / Test (push) Successful in 4m6s
CI Pipeline / Security Scan (push) Successful in 6m13s

This commit is contained in:
ulflow_phattt2901 2025-06-05 20:00:50 +07:00
parent 9a8c40eee2
commit b572653d97
15 changed files with 272 additions and 278 deletions

View File

@ -1,32 +0,0 @@
# App Configuration
APP_NAME="ULFlow Starter Kit"
APP_VERSION="0.1.0"
APP_ENVIRONMENT="development"
APP_TIMEZONE="Asia/Ho_Chi_Minh"
# Logger Configuration
LOG_LEVEL="info" # debug, info, warn, error
# Server Configuration
SERVER_HOST="0.0.0.0"
SERVER_PORT=3000
SERVER_READ_TIMEOUT=15
SERVER_WRITE_TIMEOUT=15
SERVER_SHUTDOWN_TIMEOUT=30
# Database Configuration
DB_DRIVER="postgres"
DB_HOST="localhost"
DB_PORT=5432
DB_USERNAME="postgres"
DB_PASSWORD="your_password_here"
DB_NAME="ulflow"
DB_SSLMODE="disable"
# JWT Configuration
JWT_SECRET="your-32-byte-base64-encoded-secret-key-here"
JWT_ACCESS_TOKEN_EXPIRE=15 # in minutes
JWT_REFRESH_TOKEN_EXPIRE=10080 # in minutes (7 days)
JWT_ALGORITHM="HS256"
JWT_ISSUER="ulflow-starter-kit"
JWT_AUDIENCE="ulflow-web"

View File

@ -1,32 +0,0 @@
# Dockerfile.local
# Optimized for local development with hot reload
# Build stage
FROM golang:1.23-alpine AS builder
# Install necessary tools for development
RUN apk add --no-cache git make gcc libc-dev
# Install Air for hot reload
RUN go install github.com/cosmtrek/air@latest
# Set working directory
WORKDIR /app
# Copy go.mod and go.sum files
COPY go.mod go.sum* ./
# Download dependencies
RUN go mod download
# Copy the entire project
COPY . .
# Expose port
EXPOSE 3000
# Set environment variable for development
ENV APP_ENV=development
# Command to run the application with hot reload
CMD ["air", "-c", ".air.toml"]

View File

@ -70,7 +70,7 @@ dev:
# Run all tests
test:
@echo "Running tests..."
go test -v -cover ./...
go test -cover ./...
# Run linters and code quality tools
lint:

View File

@ -0,0 +1,63 @@
version: '3.8'
services:
# API Service
api:
build:
context: .
dockerfile: Dockerfile
container_name: ulflow-api-minimal
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- ./logs:/app/logs
- ./storage:/app/storage
env_file:
- .env
environment:
- DB_DRIVER=postgres
- DB_HOST=postgres
- DB_PORT=5432
- DB_USERNAME=${DB_USERNAME:-postgres}
- DB_PASSWORD=${DB_PASSWORD:-your_password_here}
- DB_NAME=${DB_NAME:-ulflow}
depends_on:
- postgres
networks:
- ulflow-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
# PostgreSQL Database
postgres:
image: postgres:15-alpine
container_name: ulflow-postgres-minimal
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USERNAME:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-your_password_here}
POSTGRES_DB: ${DB_NAME:-ulflow}
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- ulflow-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_NAME:-ulflow}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
volumes:
postgres-data:
networks:
ulflow-network:
driver: bridge

View File

@ -1,11 +1,11 @@
version: '3.8'
services:
# API Service
# API ServiceƯ
api:
build:
context: .
dockerfile: Dockerfile.local
dockerfile: Dockerfile
container_name: ulflow-api
restart: unless-stopped
ports:
@ -50,7 +50,7 @@ services:
- GITEA__database__USER=${DB_USER:-user}
- GITEA__database__PASSWD=${DB_PASSWORD:-password}
ports:
- "3000:3000"
- "3001:3000"
- "2222:22"
volumes:
- gitea-data:/data

1
go.mod
View File

@ -3,7 +3,6 @@ module starter-kit
go 1.23.6
require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/gin-gonic/gin v1.10.0
github.com/go-playground/validator/v10 v10.20.0
github.com/golang-jwt/jwt/v5 v5.2.2

3
go.sum
View File

@ -40,8 +40,6 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
@ -178,7 +176,6 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=

View File

@ -25,7 +25,7 @@ func (r *userRepository) Create(ctx context.Context, u *user.User) error {
func (r *userRepository) GetByID(ctx context.Context, id string) (*user.User, error) {
var u user.User
// First get the user
err := r.db.WithContext(ctx).Where("`users`.`id` = ? AND `users`.`deleted_at` IS NULL", id).First(&u).Error
err := r.db.WithContext(ctx).Where("users.id = $1 AND users.deleted_at IS NULL", id).First(&u).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
@ -36,7 +36,7 @@ func (r *userRepository) GetByID(ctx context.Context, id string) (*user.User, er
// Manually preload roles with the exact SQL format expected by tests
var roles []*role.Role
err = r.db.WithContext(ctx).Raw(
"SELECT * FROM `roles` JOIN `user_roles` ON `user_roles`.`role_id` = `roles`.`id` WHERE `user_roles`.`user_id` = ? AND `roles`.`deleted_at` IS NULL",
"SELECT * FROM roles JOIN user_roles ON user_roles.role_id = roles.id WHERE user_roles.user_id = $1",
id,
).Scan(&roles).Error
@ -50,7 +50,7 @@ func (r *userRepository) GetByID(ctx context.Context, id string) (*user.User, er
func (r *userRepository) GetByUsername(ctx context.Context, username string) (*user.User, error) {
var u user.User
err := r.db.WithContext(ctx).Preload("Roles").First(&u, "username = ?", username).Error
err := r.db.WithContext(ctx).Preload("Roles").First(&u, "username = $1", username).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
@ -59,7 +59,7 @@ func (r *userRepository) GetByUsername(ctx context.Context, username string) (*u
func (r *userRepository) GetByEmail(ctx context.Context, email string) (*user.User, error) {
var u user.User
err := r.db.WithContext(ctx).Preload("Roles").First(&u, "email = ?", email).Error
err := r.db.WithContext(ctx).Preload("Roles").First(&u, "email = $1", email).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
@ -71,28 +71,28 @@ func (r *userRepository) Update(ctx context.Context, u *user.User) error {
}
func (r *userRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&user.User{}, "id = ?", id).Error
return r.db.WithContext(ctx).Delete(&user.User{}, "id = $1", id).Error
}
func (r *userRepository) AddRole(ctx context.Context, userID string, roleID int) error {
func (r *userRepository) AddRole(ctx context.Context, userID string, roleID string) error {
return r.db.WithContext(ctx).Exec(
"INSERT INTO `user_roles` (`user_id`, `role_id`) VALUES (?, ?) ON CONFLICT DO NOTHING",
"INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT (user_id, role_id) DO NOTHING",
userID, roleID,
).Error
}
func (r *userRepository) RemoveRole(ctx context.Context, userID string, roleID int) error {
func (r *userRepository) RemoveRole(ctx context.Context, userID string, roleID string) error {
return r.db.WithContext(ctx).Exec(
"DELETE FROM user_roles WHERE user_id = ? AND role_id = ?",
"DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2",
userID, roleID,
).Error
}
func (r *userRepository) HasRole(ctx context.Context, userID string, roleID int) (bool, error) {
func (r *userRepository) HasRole(ctx context.Context, userID string, roleID string) (bool, error) {
var count int64
err := r.db.WithContext(ctx).Model(&user.User{}).
Joins("JOIN user_roles ON user_roles.user_id = users.id").
Where("users.id = ? AND user_roles.role_id = ?", userID, roleID).
Where("users.id = $1 AND user_roles.role_id = $2", userID, roleID).
Count(&count).Error
return count > 0, err
@ -101,6 +101,6 @@ func (r *userRepository) HasRole(ctx context.Context, userID string, roleID int)
func (r *userRepository) UpdateLastLogin(ctx context.Context, userID string) error {
now := gorm.Expr("NOW()")
return r.db.WithContext(ctx).Model(&user.User{}).
Where("id = ?", userID).
Where("id = $1", userID).
Update("last_login_at", now).Error
}

View File

@ -4,7 +4,7 @@ import "time"
// Role đại diện cho một vai trò trong hệ thống
type Role struct {
ID int `json:"id" gorm:"primaryKey"`
ID string `json:"id" gorm:"primaryKey;type:uuid;default:uuid_generate_v4()"`
Name string `json:"name" gorm:"size:50;uniqueIndex;not null"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`

View File

@ -25,13 +25,13 @@ type Repository interface {
Delete(ctx context.Context, id string) error
// AddRole thêm vai trò cho người dùng
AddRole(ctx context.Context, userID string, roleID int) error
AddRole(ctx context.Context, userID string, roleID string) error
// RemoveRole xóa vai trò của người dùng
RemoveRole(ctx context.Context, userID string, roleID int) error
RemoveRole(ctx context.Context, userID string, roleID string) error
// HasRole kiểm tra người dùng có vai trò không
HasRole(ctx context.Context, userID string, roleID int) (bool, error)
HasRole(ctx context.Context, userID string, roleID string) (bool, error)
// UpdateLastLogin cập nhật thời gian đăng nhập cuối cùng
UpdateLastLogin(ctx context.Context, userID string) error

View File

@ -38,7 +38,13 @@ func NewConnection(cfg *config.DatabaseConfig) (*gorm.DB, error) {
case "postgres":
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
cfg.Host, cfg.Port, cfg.Username, cfg.Password, cfg.Database, cfg.SSLMode)
dbInstance, dbErr = gorm.Open(postgres.Open(dsn), &gorm.Config{})
// Cấu hình GORM cho PostgreSQL với quote style phù hợp
dbInstance, dbErr = gorm.Open(postgres.New(postgres.Config{
DSN: dsn,
PreferSimpleProtocol: true,
}), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
case "mysql":
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",

View File

@ -53,17 +53,17 @@ func (m *MockUserRepo) UpdateLastLogin(ctx context.Context, id string) error {
return args.Error(0)
}
func (m *MockUserRepo) AddRole(ctx context.Context, userID string, roleID int) error {
func (m *MockUserRepo) AddRole(ctx context.Context, userID string, roleID string) error {
args := m.Called(ctx, userID, roleID)
return args.Error(0)
}
func (m *MockUserRepo) RemoveRole(ctx context.Context, userID string, roleID int) error {
func (m *MockUserRepo) RemoveRole(ctx context.Context, userID string, roleID string) error {
args := m.Called(ctx, userID, roleID)
return args.Error(0)
}
func (m *MockUserRepo) HasRole(ctx context.Context, userID string, roleID int) (bool, error) {
func (m *MockUserRepo) HasRole(ctx context.Context, userID string, roleID string) (bool, error) {
args := m.Called(ctx, userID, roleID)
return args.Bool(0), args.Error(1)
}
@ -142,7 +142,7 @@ func TestAuthService_Register(t *testing.T) {
// Mock GetByName - role exists
mr.On("GetByName", mock.Anything, role.User).
Return(&role.Role{ID: 1, Name: role.User}, nil)
Return(&role.Role{ID: "d619c5d7-7d3d-4a91-85af-7f974a2ce5c0", Name: role.User}, nil)
// Mock AddRole
mu.On("AddRole", mock.Anything, mock.Anything, mock.Anything).

View File

@ -4,218 +4,209 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"starter-kit/internal/adapter/persistence"
"starter-kit/internal/domain/role"
"starter-kit/internal/domain/user"
"starter-kit/internal/service"
"starter-kit/internal/transport/http/dto"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"github.com/stretchr/testify/mock"
)
// mock user repository với khả năng hook
type mockUserRepo struct {
user.Repository // nhúng interface để implement tự động
CreateFunc func(ctx context.Context, u *user.User) error
GetByIDFunc func(ctx context.Context, id string) (*user.User, error)
AddRoleFunc func(ctx context.Context, userID string, roleID int) error
// MockAuthService là mock của AuthService
type MockAuthService struct {
mock.Mock
}
func (m *mockUserRepo) Create(ctx context.Context, u *user.User) error {
if m.CreateFunc != nil {
return m.CreateFunc(ctx, u)
// Register mock hàm Register
func (m *MockAuthService) Register(ctx context.Context, req service.RegisterRequest) (*user.User, error) {
args := m.Called(ctx, req)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return nil
return args.Get(0).(*user.User), args.Error(1)
}
func (m *mockUserRepo) GetByID(ctx context.Context, id string) (*user.User, error) {
if m.GetByIDFunc != nil {
return m.GetByIDFunc(ctx, id)
// Login mock hàm Login
func (m *MockAuthService) Login(ctx context.Context, username, password string) (string, string, error) {
args := m.Called(ctx, username, password)
return args.String(0), args.String(1), args.Error(2)
}
// RefreshToken mock hàm RefreshToken
func (m *MockAuthService) RefreshToken(refreshToken string) (string, string, error) {
args := m.Called(refreshToken)
return args.String(0), args.String(1), args.Error(2)
}
// ValidateToken mock hàm ValidateToken
func (m *MockAuthService) ValidateToken(tokenString string) (*service.Claims, error) {
args := m.Called(tokenString)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return nil, nil
return args.Get(0).(*service.Claims), args.Error(1)
}
func (m *mockUserRepo) AddRole(ctx context.Context, userID string, roleID int) error {
if m.AddRoleFunc != nil {
return m.AddRoleFunc(ctx, userID, roleID)
}
return nil
}
func TestRegisterHandler(t *testing.T) {
// Thiết lập
func TestRegister(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
// UUID cố định cho bài test
testUserID := "123e4567-e89b-12d3-a456-426614174000"
t.Run("Test_Register_Duplicate_Email", func(t *testing.T) {
// Arrange
mockAuthSvc := new(MockAuthService)
handler := NewAuthHandler(mockAuthSvc)
// Tạo mock database
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
if err != nil {
t.Fatalf("Không thể tạo mock database: %v", err)
}
defer func() { _ = db.Close() }()
// Tạo request body
reqBody := dto.RegisterRequest{
Username: "testuser",
Email: "existing@example.com",
Password: "password123",
FullName: "Test User",
}
jsonBody, _ := json.Marshal(reqBody)
// Kết nối GORM
gormDB, err := gorm.Open(mysql.New(mysql.Config{
Conn: db,
SkipInitializeWithVersion: true,
}), &gorm.Config{})
if err != nil {
t.Fatalf("Không thể kết nối GORM: %v", err)
}
// Setup mock behavior - trả về lỗi email đã tồn tại
mockAuthSvc.On("Register", mock.Anything, service.RegisterRequest(reqBody)).
Return(nil, errors.New("email already exists"))
// Tạo repositories thật sẽ kết nối với mock DB
realUserRepo := persistence.NewUserRepository(gormDB)
roleRepo := persistence.NewRoleRepository(gormDB)
// Tạo mock repository với đầy đủ các phương thức cần thiết
mockedUserRepo := &mockUserRepo{
Repository: realUserRepo, // delegate các phương thức còn lại
CreateFunc: func(ctx context.Context, u *user.User) error {
// Chú ý: Trong thực tế, ID sẽ được tạo bởi DB (uuid_generate_v4())
// Nhưng vì đây là test, chúng ta cần giả lập việc DB thiết lập ID sau khi INSERT
// Gọi repository thật để thực thi SQL
err := realUserRepo.Create(ctx, u)
// Gán ID cố định sau khi tạo, giả lập việc DB tạo và trả về ID
u.ID = testUserID
return err
},
GetByIDFunc: func(ctx context.Context, id string) (*user.User, error) {
// Tạo user đủ thông tin với role đã preload
userRole := &role.Role{ID: 1, Name: "user", Description: "Basic user role"}
u := &user.User{
ID: testUserID,
Username: "testuser",
Email: "test@example.com",
FullName: "Test User",
AvatarURL: "",
IsActive: true,
Roles: []*role.Role{userRole}, // Gán role đã preload
}
return u, nil
},
AddRoleFunc: func(ctx context.Context, userID string, roleID int) error {
// Kiểm tra đảm bảo ID phù hợp
if userID != testUserID {
return fmt.Errorf("expected user ID %s but got %s", testUserID, userID)
}
// Khi chúng ta gọi AddRole của repo thật, nó sẽ thực thi câu lệnh SQL
return realUserRepo.AddRole(ctx, userID, roleID)
},
}
// Tạo service với mock userRepo
jwtSecret := "test-secret-key"
authSvc := service.NewAuthService(mockedUserRepo, roleRepo, jwtSecret, time.Duration(15)*time.Minute)
// Tạo handler
authHandler := NewAuthHandler(authSvc)
// Tạo router
r := gin.Default()
r.POST("/api/v1/auth/register", authHandler.Register)
// Dữ liệu đăng ký
registerData := dto.RegisterRequest{
Username: "testuser",
Email: "test@example.com",
Password: "password123",
FullName: "Test User",
}
// Chuyển đổi dữ liệu thành JSON
jsonData, err := json.Marshal(registerData)
if err != nil {
t.Fatalf("Lỗi chuyển đổi JSON: %v", err)
}
t.Run("Đăng ký tài khoản mới thành công", func(t *testing.T) {
// Setup các mong đợi SQL match chính xác với GORM theo logs và UserRepository implementation
// 1. Kiểm tra xem username đã tồn tại chưa (userRepo.GetByUsername)
mock.ExpectQuery("SELECT \\* FROM `users` WHERE username = \\? ORDER BY `users`\\.`id` LIMIT \\?").
WithArgs("testuser", 1).
WillReturnError(gorm.ErrRecordNotFound) // Username 'testuser' chưa tồn tại
// 2. Kiểm tra xem email đã tồn tại chưa (userRepo.GetByEmail)
mock.ExpectQuery("SELECT \\* FROM `users` WHERE email = \\? ORDER BY `users`\\.`id` LIMIT \\?").
WithArgs("test@example.com", 1).
WillReturnError(gorm.ErrRecordNotFound) // Email 'test@example.com' chưa tồn tại
// --- Sequence of operations after successful username/email checks and password hashing ---
// 3. Transaction for userRepo.Create (Implicit transaction by GORM)
mock.ExpectBegin()
// 4. Tạo user mới (userRepo.Create)
// Khi không đặt trước ID, GORM không đưa ID vào SQL, để DB tạo UUID tự động
mock.ExpectExec("^INSERT INTO `users` \\(`username`,`email`,`password_hash`,`full_name`,`avatar_url`,`is_active`,`last_login_at`,`created_at`,`updated_at`,`deleted_at`\\) VALUES \\(\\?,\\?,\\?,\\?,\\?,\\?,\\?,\\?,\\?,\\?\\)").
WithArgs(
"testuser", // username
"test@example.com", // email
sqlmock.AnyArg(), // password_hash
"Test User", // full_name
"", // avatar_url
true, // is_active
sqlmock.AnyArg(), // last_login_at
sqlmock.AnyArg(), // created_at
sqlmock.AnyArg(), // updated_at
sqlmock.AnyArg(), // deleted_at
).
WillReturnResult(sqlmock.NewResult(0, 1)) // UUID không có sequence ID, chỉ cần 1 row affected
mock.ExpectCommit()
// 5. Lấy role mặc định 'user' (roleRepo.GetByName)
mock.ExpectQuery("SELECT \\* FROM `roles` WHERE name = \\? ORDER BY `roles`\\.`id` LIMIT \\?").
WithArgs("user", 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "description", "created_at", "updated_at", "deleted_at"}).
AddRow(1, "user", "Basic user role", time.Now(), time.Now(), nil))
// 6. Thêm role cho user (userRepo.AddRole -> user_roles table)
// GORM's Create for user_roles có thể dùng 'INSERT ... ON CONFLICT'
mock.ExpectExec("INSERT INTO `user_roles` \\(`user_id`, `role_id`\\) VALUES \\(\\?\\, \\?\\)").
WithArgs(testUserID, 1). // user_id (UUID string), role_id (int)
WillReturnResult(sqlmock.NewResult(0, 1)) // Thêm thành công 1 row
// Chú ý: Vì chúng ta đã override mockUserRepo.GetByID và mockUserRepo.AddRole
// nên không cần mock SQL cho các query lấy thông tin user sau khi tạo
// mockUserRepo.GetByID sẽ trả về user đã có role được preload
// Tạo request
req, _ := http.NewRequest("POST", "/api/v1/auth/register", bytes.NewBuffer(jsonData))
// Setup HTTP request
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
// Thực thi request
r.ServeHTTP(w, req)
// Setup Gin context
c, _ := gin.CreateTestContext(w)
c.Request = req
// Kiểm tra kết quả
assert.Equal(t, http.StatusCreated, w.Code, "Status code phải là 201")
// Act
handler.Register(c)
// Parse JSON response
// Assert
// Kiểm tra response status code
assert.Equal(t, http.StatusConflict, w.Code)
// Kiểm tra response body
var response dto.ErrorResponse
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, "email already exists", response.Error)
mockAuthSvc.AssertExpectations(t)
})
t.Run("Test_Register_Duplicate_Username", func(t *testing.T) {
// Arrange
mockAuthSvc := new(MockAuthService)
handler := NewAuthHandler(mockAuthSvc)
// Tạo request body
reqBody := dto.RegisterRequest{
Username: "existinguser",
Email: "new@example.com",
Password: "password123",
FullName: "Test User",
}
jsonBody, _ := json.Marshal(reqBody)
// Setup mock behavior - trả về lỗi username đã tồn tại
mockAuthSvc.On("Register", mock.Anything, service.RegisterRequest(reqBody)).
Return(nil, errors.New("username already exists"))
// Setup HTTP request
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
// Setup Gin context
c, _ := gin.CreateTestContext(w)
c.Request = req
// Act
handler.Register(c)
// Assert
// Kiểm tra response status code
assert.Equal(t, http.StatusConflict, w.Code)
// Kiểm tra response body
var response dto.ErrorResponse
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, "username already exists", response.Error)
mockAuthSvc.AssertExpectations(t)
})
// Thêm một test case cho trường hợp đăng ký thành công để đảm bảo tính đầy đủ
t.Run("Test_Register_Success", func(t *testing.T) {
// Arrange
mockAuthSvc := new(MockAuthService)
handler := NewAuthHandler(mockAuthSvc)
// Tạo request body
reqBody := dto.RegisterRequest{
Username: "newuser",
Email: "new@example.com",
Password: "password123",
FullName: "New User",
}
jsonBody, _ := json.Marshal(reqBody)
// Tạo một user đại diện cho kết quả trả về khi đăng ký thành công
createdAt := time.Now()
mockUser := &user.User{
ID: "123e4567-e89b-12d3-a456-426614174000", // UUID string format
Username: reqBody.Username,
Email: reqBody.Email,
PasswordHash: "hashed_password",
FullName: reqBody.FullName,
IsActive: true,
CreatedAt: createdAt,
UpdatedAt: createdAt,
Roles: []*role.Role{
{
ID: "234e5678-e89b-12d3-a456-426614174001",
Name: "user",
},
},
}
// Setup mock behavior - trả về user đã tạo thành công
mockAuthSvc.On("Register", mock.Anything, service.RegisterRequest(reqBody)).
Return(mockUser, nil)
// Setup HTTP request
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
// Setup Gin context
c, _ := gin.CreateTestContext(w)
c.Request = req
// Act
handler.Register(c)
// Assert
// Kiểm tra response status code
assert.Equal(t, http.StatusCreated, w.Code)
// Kiểm tra response body
var response dto.UserResponse
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err, "Parse JSON không có lỗi")
assert.NoError(t, err)
assert.Equal(t, mockUser.ID, response.ID)
assert.Equal(t, mockUser.Username, response.Username)
assert.Equal(t, mockUser.Email, response.Email)
// Kiểm tra thông tin phản hồi
assert.Equal(t, registerData.Username, response.Username, "Username phải khớp")
assert.Equal(t, registerData.Email, response.Email, "Email phải khớp")
assert.Equal(t, registerData.FullName, response.FullName, "FullName phải khớp")
assert.NotEmpty(t, response.ID, "ID không được rỗng")
// Kiểm tra nếu có SQL expectations nào chưa được đáp ứng
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("Các expectations chưa được đáp ứng: %s", err)
}
mockAuthSvc.AssertExpectations(t)
})
}

View File

@ -1,5 +1,7 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
@ -7,8 +9,8 @@ CREATE TABLE roles (
);
-- Insert default roles
INSERT INTO roles (name, description) VALUES
('admin', 'Quản trị viên hệ thống'),
('manager', 'Quản lý'),
('user', 'Người dùng thông thường'),
('guest', 'Khách');
INSERT INTO roles (id, name, description) VALUES
(uuid_generate_v4(), 'admin', 'Quản trị viên hệ thống'),
(uuid_generate_v4(), 'manager', 'Quản lý'),
(uuid_generate_v4(), 'user', 'Người dùng thông thường'),
(uuid_generate_v4(), 'guest', 'Khách');

View File

@ -1,7 +1,7 @@
-- Tạo bảng mà không có ràng buộc
CREATE TABLE IF NOT EXISTS user_roles (
user_id UUID NOT NULL,
role_id INTEGER NOT NULL,
role_id UUID NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, role_id)
);