fix: implement user roles and authentication system with tests
All checks were successful
All checks were successful
This commit is contained in:
parent
9a8c40eee2
commit
b572653d97
32
.env.example
32
.env.example
@ -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"
|
||||
@ -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"]
|
||||
2
Makefile
2
Makefile
@ -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:
|
||||
|
||||
63
docker-compose-minimal.yml
Normal file
63
docker-compose-minimal.yml
Normal 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
|
||||
@ -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
1
go.mod
@ -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
3
go.sum
@ -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=
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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)
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user