├── .gitignore ├── Makefile ├── internal ├── domain │ ├── user │ │ ├── service.go │ │ ├── entity.go │ │ └── repository.go │ ├── session │ │ └── store.go │ └── auth │ │ └── service.go ├── shared │ ├── validator.go │ ├── module.go │ ├── database.go │ ├── errs │ │ └── errs.go │ ├── redis.go │ ├── config.go │ ├── jwt.go │ └── testutil │ │ └── database.go ├── service │ ├── module.go │ ├── user │ │ ├── service.go │ │ └── mock │ │ │ ├── store.go │ │ │ └── repository.go │ └── auth │ │ ├── service.go │ │ └── service_test.go ├── adapter │ ├── http │ │ ├── user │ │ │ ├── dto.go │ │ │ └── routes.go │ │ ├── auth │ │ │ ├── dto.go │ │ │ └── routes.go │ │ ├── middleware │ │ │ └── auth.go │ │ ├── resp │ │ │ └── response.go │ │ └── module.go │ ├── persistence │ │ ├── model │ │ │ └── user.go │ │ └── mapper │ │ │ └── user.go │ └── repository │ │ ├── module.go │ │ ├── session_repository.go │ │ ├── user_repository.go │ │ └── user_repository_test.go └── app │ └── module.go ├── config.yaml.example ├── cmd └── app │ └── main.go ├── go.mod └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /config.yaml 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | run: 2 | -go run cmd/app/main.go 3 | dev: 4 | nodemon --signal SIGHUP --exec "make run" -e "go" -------------------------------------------------------------------------------- /internal/domain/user/service.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | type Service interface { 4 | Profile(id uint64) (*User, error) 5 | } 6 | -------------------------------------------------------------------------------- /internal/domain/session/store.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | type Store interface { 4 | Set(uid string, jti string) error 5 | Get(uid string) (jti string, ok bool) 6 | } 7 | -------------------------------------------------------------------------------- /internal/shared/validator.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import "github.com/go-playground/validator/v10" 4 | 5 | func NewValidator() *validator.Validate { return validator.New() } 6 | -------------------------------------------------------------------------------- /internal/shared/module.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import "go.uber.org/fx" 4 | 5 | var Module = fx.Options( 6 | fx.Provide(LoadConfig, NewDatabase, NewRedis, NewJWT, NewValidator), 7 | ) 8 | -------------------------------------------------------------------------------- /config.yaml.example: -------------------------------------------------------------------------------- 1 | database: 2 | dsn: "host=localhost user=postgres password=123456 dbname=xxxxx port=5432 sslmode=disable" 3 | 4 | redis: 5 | addr: "localhost:6379" 6 | ttl: 86400 7 | 8 | jwt: 9 | secret: "change_me_in_prod" 10 | -------------------------------------------------------------------------------- /internal/shared/database.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "gorm.io/driver/postgres" 5 | "gorm.io/gorm" 6 | ) 7 | 8 | func NewDatabase(cfg *Config) (*gorm.DB, error) { 9 | return gorm.Open(postgres.Open(cfg.Database.DSN), &gorm.Config{}) 10 | } 11 | -------------------------------------------------------------------------------- /internal/domain/auth/service.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import "context" 4 | 5 | type Service interface { 6 | Register(ctx context.Context, username, email, password string) error 7 | Login(ctx context.Context, username, password string) (token string, err error) 8 | } 9 | -------------------------------------------------------------------------------- /internal/domain/user/entity.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import "time" 4 | 5 | type User struct { 6 | Id uint64 7 | Username string 8 | Email string 9 | Password string 10 | Status bool 11 | CreatedAt time.Time 12 | UpdatedAt time.Time 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/module.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "go-template/internal/service/auth" 5 | "go-template/internal/service/user" 6 | "go.uber.org/fx" 7 | ) 8 | 9 | var Module = fx.Options( 10 | fx.Provide( 11 | auth.NewService, 12 | user.NewService, 13 | )) 14 | -------------------------------------------------------------------------------- /internal/shared/errs/errs.go: -------------------------------------------------------------------------------- 1 | package errs 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrDuplicateUsername = errors.New("username already exists") 7 | ErrDuplicateEmail = errors.New("email already exists") 8 | ErrInvalidCredential = errors.New("invalid credential") 9 | ) 10 | -------------------------------------------------------------------------------- /internal/domain/user/repository.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | type Repository interface { 4 | Save(*User) error 5 | FindById(id uint64) (*User, error) 6 | FindByUsername(username string) (*User, error) 7 | ExistsUsername(username string) (bool, error) 8 | ExistsEmail(email string) (bool, error) 9 | } 10 | -------------------------------------------------------------------------------- /internal/adapter/http/user/dto.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import "time" 4 | 5 | type userResp struct { 6 | Id uint64 `json:"id"` 7 | Username string `json:"username"` 8 | Email string `json:"email"` 9 | Status bool `json:"status"` 10 | CreatedAt time.Time `json:"created_at"` 11 | UpdatedAt time.Time `json:"updated_at"` 12 | } 13 | -------------------------------------------------------------------------------- /internal/app/module.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "go-template/internal/adapter/http" 5 | "go-template/internal/adapter/repository" 6 | "go-template/internal/service" 7 | "go-template/internal/shared" 8 | "go.uber.org/fx" 9 | ) 10 | 11 | var Module = fx.Options( 12 | shared.Module, 13 | repository.Module, 14 | service.Module, 15 | http.Module, 16 | ) 17 | -------------------------------------------------------------------------------- /internal/adapter/persistence/model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "time" 4 | 5 | type User struct { 6 | Id uint64 `gorm:"primaryKey;autoIncrement:true"` 7 | Username string `gorm:"unique"` 8 | Password string 9 | Email string `gorm:"unique"` 10 | Status bool `gorm:"default:true"` 11 | CreatedAt time.Time 12 | UpdatedAt time.Time 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/user/service.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "go-template/internal/domain/user" 5 | ) 6 | 7 | type service struct { 8 | repo user.Repository 9 | } 10 | 11 | func NewService(repo user.Repository) user.Service { 12 | return &service{repo: repo} 13 | } 14 | 15 | func (r *service) Profile(id uint64) (*user.User, error) { 16 | return r.repo.FindById(id) 17 | } 18 | -------------------------------------------------------------------------------- /internal/adapter/http/auth/dto.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | type signUpReq struct { 4 | Username string `json:"username" validate:"required,min=3,max=30,alphanum"` 5 | Password string `json:"password" validate:"required,min=8"` 6 | Email string `json:"email" validate:"required,email"` 7 | } 8 | 9 | type signInReq struct { 10 | Username string `json:"username" validate:"required"` 11 | Password string `json:"password" validate:"required"` 12 | } 13 | -------------------------------------------------------------------------------- /cmd/app/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | 10 | "go-template/internal/app" 11 | "go.uber.org/fx" 12 | ) 13 | 14 | func main() { 15 | shutdown := make(chan os.Signal, 1) 16 | signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM) 17 | 18 | fxApp := fx.New(app.Module) 19 | 20 | go func() { 21 | <-shutdown 22 | log.Println("🛑 Gracefully shutting down...") 23 | fxApp.Stop(context.Background()) 24 | }() 25 | 26 | fxApp.Run() 27 | } 28 | -------------------------------------------------------------------------------- /internal/shared/redis.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/redis/go-redis/v9" 8 | ) 9 | 10 | func NewRedis(cfg *Config) (*redis.Client, error) { 11 | rdb := redis.NewClient(&redis.Options{ 12 | Addr: cfg.Redis.Addr, 13 | // Password: cfg.Redis.Pass 14 | }) 15 | 16 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 17 | defer cancel() 18 | 19 | if err := rdb.Ping(ctx).Err(); err != nil { 20 | return nil, err 21 | } 22 | return rdb, nil 23 | } 24 | -------------------------------------------------------------------------------- /internal/adapter/persistence/mapper/user.go: -------------------------------------------------------------------------------- 1 | package mapper 2 | 3 | import ( 4 | "go-template/internal/adapter/persistence/model" 5 | "go-template/internal/domain/user" 6 | ) 7 | 8 | func UserToModel(r *user.User) *model.User { 9 | return &model.User{ 10 | Id: r.Id, 11 | Username: r.Username, 12 | Email: r.Email, 13 | } 14 | } 15 | 16 | func UserToDomain(r *model.User) *user.User { 17 | return &user.User{ 18 | Id: r.Id, 19 | Username: r.Username, 20 | Email: r.Email, 21 | Password: r.Password, 22 | Status: r.Status, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/adapter/http/middleware/auth.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v3" 5 | "go-template/internal/domain/session" 6 | "go-template/internal/shared" 7 | "strings" 8 | ) 9 | 10 | func Auth(store session.Store, jwt *shared.JWT) fiber.Handler { 11 | return func(c fiber.Ctx) error { 12 | h := c.Get("Authorization") 13 | if !strings.HasPrefix(h, "Bearer ") { 14 | return fiber.ErrUnauthorized 15 | } 16 | claims, err := jwt.ParseToken(h[7:]) 17 | if err != nil { 18 | return fiber.ErrUnauthorized 19 | } 20 | 21 | if j, ok := store.Get(claims.UserID); !ok || j != claims.ID { 22 | return fiber.ErrUnauthorized // token เก่าถูกแทน 23 | } 24 | c.Locals("uid", claims.UserID) 25 | return c.Next() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /internal/adapter/repository/module.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "go-template/internal/adapter/persistence/model" 6 | "go-template/internal/domain/session" 7 | "go-template/internal/domain/user" 8 | "go.uber.org/fx" 9 | "gorm.io/gorm" 10 | ) 11 | 12 | func RegisterMigration(lc fx.Lifecycle, db *gorm.DB) { 13 | lc.Append(fx.Hook{ 14 | OnStart: func(ctx context.Context) error { 15 | return db.AutoMigrate( 16 | &model.User{}, 17 | ) 18 | }, 19 | }) 20 | } 21 | 22 | var Module = fx.Options( 23 | fx.Provide( 24 | NewSession, 25 | fx.Annotate(NewSession, fx.As(new(session.Store))), 26 | NewUserRepository, 27 | fx.Annotate(NewUserRepository, fx.As(new(user.Repository))), 28 | ), 29 | fx.Invoke(RegisterMigration), 30 | ) 31 | -------------------------------------------------------------------------------- /internal/adapter/repository/session_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/redis/go-redis/v9" 7 | "go-template/internal/domain/session" 8 | "time" 9 | ) 10 | 11 | type Session struct { 12 | rdb *redis.Client 13 | ttl time.Duration 14 | } 15 | 16 | func (r *Session) key(uid string) string { 17 | return "session:" + uid 18 | } 19 | 20 | func (r *Session) Set(uid, jti string) error { 21 | ctx := context.Background() 22 | return r.rdb.Set(ctx, r.key(uid), jti, r.ttl).Err() 23 | } 24 | 25 | func (r *Session) Get(uid string) (string, bool) { 26 | ctx := context.Background() 27 | val, err := r.rdb.Get(ctx, r.key(uid)).Result() 28 | if errors.Is(err, redis.Nil) || err != nil { 29 | return "", false 30 | } 31 | return val, true 32 | } 33 | 34 | func NewSession(rdb *redis.Client) *Session { 35 | return &Session{rdb: rdb, ttl: 24 * time.Hour} 36 | } 37 | 38 | var _ session.Store = (*Session)(nil) 39 | -------------------------------------------------------------------------------- /internal/adapter/http/user/routes.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v3" 5 | "go-template/internal/adapter/http/middleware" 6 | "go-template/internal/adapter/http/resp" 7 | "go-template/internal/domain/session" 8 | "go-template/internal/domain/user" 9 | "go-template/internal/shared" 10 | "strconv" 11 | ) 12 | 13 | func RegisterRoutes(app *fiber.App, svc user.Service, store session.Store, jwt *shared.JWT) { 14 | 15 | g := app.Group("/users", middleware.Auth(store, jwt)) 16 | 17 | g.Get("/me", func(c fiber.Ctx) error { 18 | uidStr := c.Locals("uid").(string) 19 | uid, _ := strconv.ParseUint(uidStr, 10, 64) 20 | 21 | u, err := svc.Profile(uid) 22 | if err != nil { 23 | return resp.Error(c, err) // ใช้ helper JSON error 24 | } 25 | return resp.Success(c, userResp{ 26 | Id: u.Id, 27 | Username: u.Username, 28 | Email: u.Email, 29 | Status: false, 30 | CreatedAt: u.CreatedAt, 31 | UpdatedAt: u.UpdatedAt, 32 | }) 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /internal/shared/config.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/spf13/viper" 8 | ) 9 | 10 | type Config struct { 11 | Database struct { 12 | DSN string `mapstructure:"dsn"` 13 | } `mapstructure:"database"` 14 | 15 | Redis struct { 16 | Addr string `mapstructure:"addr"` 17 | TTL time.Duration `mapstructure:"ttl"` 18 | } `mapstructure:"redis"` 19 | 20 | JWT struct { 21 | Secret string `mapstructure:"secret"` 22 | } `mapstructure:"jwt"` 23 | } 24 | 25 | func LoadConfig() (*Config, error) { 26 | v := viper.New() 27 | 28 | v.SetConfigName("config") 29 | v.SetConfigType("yaml") 30 | v.AddConfigPath(".") 31 | v.AddConfigPath("./config") 32 | 33 | // ENV override ─ APP_* 34 | v.SetEnvPrefix("APP") 35 | v.AutomaticEnv() 36 | 37 | v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 38 | 39 | _ = v.ReadInConfig() 40 | 41 | var cfg Config 42 | if err := v.Unmarshal(&cfg); err != nil { 43 | return nil, err 44 | } 45 | return &cfg, nil 46 | } 47 | -------------------------------------------------------------------------------- /internal/shared/jwt.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/golang-jwt/jwt/v5" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | type JWT struct{ Secret []byte } 11 | 12 | func NewJWT(cfg *Config) *JWT { 13 | return &JWT{Secret: []byte(cfg.JWT.Secret)} 14 | } 15 | 16 | type Claims struct { 17 | UserID string `json:"uid"` 18 | jwt.RegisteredClaims 19 | } 20 | 21 | func (r *JWT) IssueToken(uid string) (token, jti string, err error) { 22 | jti = uuid.NewString() 23 | claims := Claims{ 24 | UserID: uid, 25 | RegisteredClaims: jwt.RegisteredClaims{ 26 | ID: jti, 27 | ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), 28 | }, 29 | } 30 | t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 31 | token, err = t.SignedString(r.Secret) 32 | return 33 | } 34 | 35 | func (r *JWT) ParseToken(tok string) (*Claims, error) { 36 | t, err := jwt.ParseWithClaims(tok, &Claims{}, func(*jwt.Token) (any, error) { 37 | return r.Secret, nil 38 | }) 39 | if err != nil { 40 | return nil, err 41 | } 42 | return t.Claims.(*Claims), nil 43 | } 44 | -------------------------------------------------------------------------------- /internal/adapter/http/resp/response.go: -------------------------------------------------------------------------------- 1 | package resp 2 | 3 | import ( 4 | "errors" 5 | "github.com/gofiber/fiber/v3" 6 | "go-template/internal/shared/errs" 7 | ) 8 | 9 | type successResp struct { 10 | Success bool `json:"success"` 11 | Data interface{} `json:"data,omitempty"` 12 | } 13 | 14 | type errorResp struct { 15 | Success bool `json:"success"` 16 | Message string `json:"message,omitempty"` 17 | } 18 | 19 | func Error(c fiber.Ctx, err error) error { 20 | var code int 21 | switch { 22 | case errors.Is(err, errs.ErrInvalidCredential): 23 | code = fiber.StatusUnauthorized // 401 24 | case errors.Is(err, errs.ErrDuplicateUsername): 25 | code = fiber.StatusConflict // 409 26 | case errors.Is(err, errs.ErrDuplicateEmail): 27 | code = fiber.StatusConflict // 409 28 | default: 29 | code = fiber.StatusInternalServerError // 500 30 | } 31 | 32 | return c.Status(code).JSON(errorResp{ 33 | Success: false, 34 | Message: err.Error(), 35 | }) 36 | } 37 | 38 | func Success(c fiber.Ctx, data any) error { 39 | return c.Status(fiber.StatusOK).JSON(successResp{ 40 | Success: true, 41 | Data: data, 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /internal/adapter/http/module.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "github.com/ditthkr/loggie" 6 | "github.com/gofiber/fiber/v3" 7 | "go-template/internal/adapter/http/auth" 8 | "go-template/internal/adapter/http/user" 9 | "go.uber.org/fx" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | func NewApp() *fiber.App { 14 | return fiber.New() 15 | } 16 | 17 | func RegisterHTTPLifecycle(life fx.Lifecycle, app *fiber.App, logger *zap.Logger) { 18 | 19 | app.Use(func(c fiber.Ctx) error { 20 | ctx, traceId := loggie.Injection(c.Context(), &loggie.ZapLogger{L: logger}) 21 | c.SetContext(ctx) 22 | c.Set("X-Trace-Id", traceId) 23 | return c.Next() 24 | }) 25 | life.Append(fx.Hook{ 26 | OnStart: func(ctx context.Context) error { 27 | go func() { 28 | _ = app.Listen(":8080") 29 | }() 30 | return nil 31 | }, 32 | OnStop: func(ctx context.Context) error { 33 | return app.Shutdown() 34 | }, 35 | }) 36 | } 37 | 38 | var Module = fx.Options( 39 | fx.Provide(NewApp, func() (*zap.Logger, error) { 40 | return zap.NewProduction(zap.AddCallerSkip(1)) 41 | }), 42 | fx.Invoke(RegisterHTTPLifecycle), 43 | fx.Invoke( 44 | auth.RegisterRoutes, 45 | user.RegisterRoutes, 46 | ), 47 | ) 48 | -------------------------------------------------------------------------------- /internal/shared/testutil/database.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "github.com/stretchr/testify/require" 5 | "go-template/internal/adapter/persistence/model" 6 | "go-template/internal/domain/user" 7 | "gorm.io/driver/sqlite" 8 | "gorm.io/gorm" 9 | "testing" 10 | ) 11 | 12 | func NewTestDB() *gorm.DB { 13 | db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) 14 | if err != nil { 15 | panic("failed to connect to test database: " + err.Error()) 16 | } 17 | 18 | // Auto-migrate user model for testing 19 | // Note: This creates a simplified users table for testing 20 | err = db.AutoMigrate(&model.User{}) 21 | 22 | if err != nil { 23 | panic("failed to migrate test database: " + err.Error()) 24 | } 25 | 26 | return db 27 | } 28 | 29 | func CreateTestUser(t *testing.T, db *gorm.DB, username, email string) *user.User { 30 | // Create a user record 31 | userModel := &model.User{ 32 | Username: username, 33 | Email: email, 34 | Password: "hashedpassword", 35 | } 36 | 37 | err := db.Create(userModel).Error 38 | require.NoError(t, err) 39 | 40 | // Convert to domain user 41 | return &user.User{ 42 | Id: userModel.Id, 43 | Username: userModel.Username, 44 | Email: userModel.Email, 45 | Password: userModel.Password, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /internal/adapter/http/auth/routes.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "github.com/ditthkr/loggie" 5 | "github.com/go-playground/validator/v10" 6 | "github.com/gofiber/fiber/v3" 7 | "go-template/internal/adapter/http/resp" 8 | "go-template/internal/domain/auth" 9 | ) 10 | 11 | func RegisterRoutes(app *fiber.App, svc auth.Service, v *validator.Validate) { 12 | 13 | g := app.Group("/auth") 14 | 15 | g.Post("/signup", func(c fiber.Ctx) error { 16 | var req signUpReq 17 | if err := c.Bind().Body(&req); err != nil { 18 | return resp.Error(c, err) 19 | } 20 | if err := v.Struct(&req); err != nil { 21 | return resp.Error(c, err) 22 | } 23 | 24 | ctx := c.Context() 25 | log := loggie.FromContext(ctx) 26 | log.Info("received /signup request") 27 | 28 | if err := svc.Register(ctx, req.Username, req.Email, req.Password); err != nil { 29 | return resp.Error(c, err) 30 | } 31 | return resp.Success(c, nil) 32 | }) 33 | 34 | g.Post("/signin", func(c fiber.Ctx) error { 35 | 36 | ctx := c.Context() 37 | ctx = loggie.WithCustomField(ctx, "user_id", c.Locals("uid")) 38 | log := loggie.FromContext(ctx) 39 | log.Info("received /signin request") 40 | 41 | var req signInReq 42 | if err := c.Bind().Body(&req); err != nil { 43 | return resp.Error(c, err) 44 | } 45 | if err := v.Struct(&req); err != nil { 46 | return resp.Error(c, err) 47 | } 48 | tok, err := svc.Login(ctx, req.Username, req.Password) 49 | if err != nil { 50 | return resp.Error(c, err) 51 | } 52 | return resp.Success(c, map[string]string{"token": tok}) 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /internal/adapter/repository/user_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "go-template/internal/adapter/persistence/mapper" 5 | "go-template/internal/adapter/persistence/model" 6 | "go-template/internal/domain/user" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | type UserRepository struct { 11 | db *gorm.DB 12 | } 13 | 14 | func (r *UserRepository) Save(u *user.User) error { 15 | m := model.User{ 16 | Id: u.Id, 17 | Username: u.Username, 18 | Email: u.Email, 19 | Password: u.Password, 20 | } 21 | if err := r.db.Create(&m).Error; err != nil { 22 | return err 23 | } 24 | u.Id = m.Id 25 | return nil 26 | } 27 | 28 | func (r *UserRepository) FindById(id uint64) (*user.User, error) { 29 | var m model.User 30 | if err := r.db.First(&m, "id = ?", id).Error; err != nil { 31 | return nil, err 32 | } 33 | return mapper.UserToDomain(&m), nil 34 | } 35 | 36 | func (r *UserRepository) FindByUsername(username string) (*user.User, error) { 37 | var m model.User 38 | if err := r.db.First(&m, "username = ?", username).Error; err != nil { 39 | return nil, err 40 | } 41 | return mapper.UserToDomain(&m), nil 42 | } 43 | 44 | func (r *UserRepository) ExistsUsername(username string) (bool, error) { 45 | var count int64 46 | if err := r.db.Model(&model.User{}). 47 | Where("username = ?", username). 48 | Count(&count).Error; err != nil { 49 | return false, err 50 | } 51 | return count > 0, nil 52 | } 53 | 54 | func (r *UserRepository) ExistsEmail(email string) (bool, error) { 55 | var count int64 56 | if err := r.db.Model(&model.User{}). 57 | Where("email = ?", email). 58 | Count(&count).Error; err != nil { 59 | return false, err 60 | } 61 | return count > 0, nil 62 | } 63 | 64 | func NewUserRepository(db *gorm.DB) *UserRepository { 65 | return &UserRepository{db: db} 66 | } 67 | 68 | var _ user.Repository = (*UserRepository)(nil) 69 | -------------------------------------------------------------------------------- /internal/service/auth/service.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "go-template/internal/domain/auth" 6 | "go-template/internal/domain/session" 7 | "go-template/internal/domain/user" 8 | "go-template/internal/shared" 9 | "go-template/internal/shared/errs" 10 | "golang.org/x/crypto/bcrypt" 11 | "strconv" 12 | "strings" 13 | ) 14 | 15 | type service struct { 16 | userRepo user.Repository 17 | sessionStore session.Store 18 | jwt *shared.JWT 19 | } 20 | 21 | func NewService(u user.Repository, st session.Store, j *shared.JWT) auth.Service { 22 | return &service{userRepo: u, sessionStore: st, jwt: j} 23 | } 24 | 25 | func (r *service) Register(ctx context.Context, username, email, password string) error { 26 | 27 | username = strings.TrimSpace(username) 28 | email = strings.ToLower(strings.TrimSpace(email)) 29 | 30 | if ok, _ := r.userRepo.ExistsUsername(username); ok { 31 | return errs.ErrDuplicateUsername 32 | } 33 | if ok, _ := r.userRepo.ExistsEmail(email); ok { 34 | return errs.ErrDuplicateEmail 35 | } 36 | 37 | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 38 | if err != nil { 39 | return err 40 | } 41 | return r.userRepo.Save(&user.User{ 42 | Username: username, Email: email, Password: string(hash), 43 | }) 44 | } 45 | 46 | func (r *service) Login(ctx context.Context, username, password string) (string, error) { 47 | u, err := r.userRepo.FindByUsername(username) 48 | if err != nil { 49 | return "", err 50 | } 51 | 52 | if bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)) != nil { 53 | return "", errs.ErrInvalidCredential 54 | } 55 | token, jti, err := r.jwt.IssueToken(strconv.FormatUint(u.Id, 10)) 56 | if err != nil { 57 | return "", err 58 | } 59 | err = r.sessionStore.Set(strconv.FormatUint(u.Id, 10), jti) 60 | if err != nil { 61 | return "", err 62 | } 63 | return token, nil 64 | } 65 | -------------------------------------------------------------------------------- /internal/service/user/mock/store.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: go-template/internal/domain/session (interfaces: Store) 3 | 4 | // Package mock is a generated GoMock package. 5 | package mock 6 | 7 | import ( 8 | reflect "reflect" 9 | 10 | gomock "github.com/golang/mock/gomock" 11 | ) 12 | 13 | // MockStore is a mock of Store interface. 14 | type MockStore struct { 15 | ctrl *gomock.Controller 16 | recorder *MockStoreMockRecorder 17 | } 18 | 19 | // MockStoreMockRecorder is the mock recorder for MockStore. 20 | type MockStoreMockRecorder struct { 21 | mock *MockStore 22 | } 23 | 24 | // NewMockStore creates a new mock instance. 25 | func NewMockStore(ctrl *gomock.Controller) *MockStore { 26 | mock := &MockStore{ctrl: ctrl} 27 | mock.recorder = &MockStoreMockRecorder{mock} 28 | return mock 29 | } 30 | 31 | // EXPECT returns an object that allows the caller to indicate expected use. 32 | func (m *MockStore) EXPECT() *MockStoreMockRecorder { 33 | return m.recorder 34 | } 35 | 36 | // Get mocks base method. 37 | func (m *MockStore) Get(arg0 string) (string, bool) { 38 | m.ctrl.T.Helper() 39 | ret := m.ctrl.Call(m, "Get", arg0) 40 | ret0, _ := ret[0].(string) 41 | ret1, _ := ret[1].(bool) 42 | return ret0, ret1 43 | } 44 | 45 | // Get indicates an expected call of Get. 46 | func (mr *MockStoreMockRecorder) Get(arg0 interface{}) *gomock.Call { 47 | mr.mock.ctrl.T.Helper() 48 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockStore)(nil).Get), arg0) 49 | } 50 | 51 | // Set mocks base method. 52 | func (m *MockStore) Set(arg0, arg1 string) error { 53 | m.ctrl.T.Helper() 54 | ret := m.ctrl.Call(m, "Set", arg0, arg1) 55 | ret0, _ := ret[0].(error) 56 | return ret0 57 | } 58 | 59 | // Set indicates an expected call of Set. 60 | func (mr *MockStoreMockRecorder) Set(arg0, arg1 interface{}) *gomock.Call { 61 | mr.mock.ctrl.T.Helper() 62 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockStore)(nil).Set), arg0, arg1) 63 | } 64 | -------------------------------------------------------------------------------- /internal/service/auth/service_test.go: -------------------------------------------------------------------------------- 1 | package auth_test 2 | 3 | import ( 4 | "context" 5 | "github.com/golang/mock/gomock" 6 | "go-template/internal/shared" 7 | "go-template/internal/shared/errs" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "go-template/internal/service/auth" 12 | "go-template/internal/service/user/mock" 13 | ) 14 | 15 | func TestAuthService_Register(t *testing.T) { 16 | t.Run("Register success", func(t *testing.T) { 17 | ctrl := gomock.NewController(t) 18 | defer ctrl.Finish() 19 | 20 | userRepo := mock.NewMockRepository(ctrl) 21 | sessionStore := mock.NewMockStore(ctrl) 22 | jwt := new(shared.JWT) 23 | service := auth.NewService(userRepo, sessionStore, jwt) 24 | 25 | userRepo.EXPECT().ExistsUsername("testuser").Return(false, nil) 26 | userRepo.EXPECT().ExistsEmail("test@example.com").Return(false, nil) 27 | userRepo.EXPECT().Save(gomock.Any()).Return(nil) 28 | 29 | err := service.Register(context.Background(), "testuser", "test@example.com", "Password123") 30 | assert.NoError(t, err) 31 | }) 32 | 33 | t.Run("Username already exists", func(t *testing.T) { 34 | ctrl := gomock.NewController(t) 35 | defer ctrl.Finish() 36 | 37 | userRepo := mock.NewMockRepository(ctrl) 38 | sessionStore := mock.NewMockStore(ctrl) 39 | jwt := new(shared.JWT) 40 | service := auth.NewService(userRepo, sessionStore, jwt) 41 | 42 | userRepo.EXPECT().ExistsUsername("testuser").Return(true, nil) 43 | 44 | err := service.Register(context.Background(), "testuser", "test@example.com", "Password123") 45 | 46 | assert.Error(t, err) 47 | assert.Equal(t, errs.ErrDuplicateUsername, err) 48 | assert.Contains(t, err.Error(), "username already exists") 49 | }) 50 | 51 | t.Run("Email already exists", func(t *testing.T) { 52 | 53 | ctrl := gomock.NewController(t) 54 | defer ctrl.Finish() 55 | 56 | userRepo := mock.NewMockRepository(ctrl) 57 | sessionStore := mock.NewMockStore(ctrl) 58 | jwt := new(shared.JWT) 59 | service := auth.NewService(userRepo, sessionStore, jwt) 60 | 61 | userRepo.EXPECT().ExistsUsername("testuser").Return(false, nil) 62 | userRepo.EXPECT().ExistsEmail("test@example.com").Return(true, nil) 63 | 64 | err := service.Register(context.Background(), "testuser", "test@example.com", "Password123") 65 | 66 | assert.Error(t, err) 67 | assert.Equal(t, errs.ErrDuplicateEmail, err) 68 | assert.Contains(t, err.Error(), "email already exists") 69 | }) 70 | } 71 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go-template 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.7 6 | 7 | require ( 8 | github.com/ditthkr/loggie v0.2.5 9 | github.com/go-playground/validator/v10 v10.26.0 10 | github.com/gofiber/fiber/v3 v3.0.0-beta.4 11 | github.com/golang-jwt/jwt/v5 v5.2.2 12 | github.com/golang/mock v1.6.0 13 | github.com/google/uuid v1.6.0 14 | github.com/redis/go-redis/v9 v9.7.3 15 | github.com/spf13/viper v1.20.1 16 | github.com/stretchr/testify v1.10.0 17 | go.uber.org/fx v1.23.0 18 | go.uber.org/zap v1.27.0 19 | golang.org/x/crypto v0.37.0 20 | gorm.io/driver/postgres v1.5.11 21 | gorm.io/driver/sqlite v1.5.7 22 | gorm.io/gorm v1.25.12 23 | ) 24 | 25 | require ( 26 | github.com/andybalholm/brotli v1.1.1 // indirect 27 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 28 | github.com/davecgh/go-spew v1.1.1 // indirect 29 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 30 | github.com/fsnotify/fsnotify v1.8.0 // indirect 31 | github.com/fxamacker/cbor/v2 v2.8.0 // indirect 32 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 33 | github.com/go-playground/locales v0.14.1 // indirect 34 | github.com/go-playground/universal-translator v0.18.1 // indirect 35 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 36 | github.com/gofiber/schema v1.3.0 // indirect 37 | github.com/gofiber/utils/v2 v2.0.0-beta.8 // indirect 38 | github.com/jackc/pgpassfile v1.0.0 // indirect 39 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 40 | github.com/jackc/pgx/v5 v5.7.4 // indirect 41 | github.com/jackc/puddle/v2 v2.2.2 // indirect 42 | github.com/jinzhu/inflection v1.0.0 // indirect 43 | github.com/jinzhu/now v1.1.5 // indirect 44 | github.com/klauspost/compress v1.18.0 // indirect 45 | github.com/leodido/go-urn v1.4.0 // indirect 46 | github.com/mattn/go-colorable v0.1.14 // indirect 47 | github.com/mattn/go-isatty v0.0.20 // indirect 48 | github.com/mattn/go-sqlite3 v1.14.22 // indirect 49 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 50 | github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect 51 | github.com/pmezard/go-difflib v1.0.0 // indirect 52 | github.com/sagikazarmark/locafero v0.7.0 // indirect 53 | github.com/sirupsen/logrus v1.9.3 // indirect 54 | github.com/sourcegraph/conc v0.3.0 // indirect 55 | github.com/spf13/afero v1.12.0 // indirect 56 | github.com/spf13/cast v1.7.1 // indirect 57 | github.com/spf13/pflag v1.0.6 // indirect 58 | github.com/subosito/gotenv v1.6.0 // indirect 59 | github.com/tinylib/msgp v1.2.5 // indirect 60 | github.com/valyala/bytebufferpool v1.0.0 // indirect 61 | github.com/valyala/fasthttp v1.60.0 // indirect 62 | github.com/x448/float16 v0.8.4 // indirect 63 | go.opentelemetry.io/otel v1.35.0 // indirect 64 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 65 | go.uber.org/dig v1.18.0 // indirect 66 | go.uber.org/multierr v1.10.0 // indirect 67 | golang.org/x/net v0.39.0 // indirect 68 | golang.org/x/sync v0.13.0 // indirect 69 | golang.org/x/sys v0.32.0 // indirect 70 | golang.org/x/text v0.24.0 // indirect 71 | gopkg.in/yaml.v3 v3.0.1 // indirect 72 | ) 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚀 Go Template - (Hexagonal + Clean Architecture) 2 | 3 | Welcome, developers! 👋 This **Go Template** will accelerate your Go application development with a robust and maintainable architecture. 4 | 5 | ## 💫 Project Overview 6 | 7 | This **Modular Monolith** combines Hexagonal (Ports/Adapters) and Clean Architecture principles to provide: 8 | 9 | - 🔄 **Clean separation** between UI (HTTP) and persistence layers (DB, Cache) 10 | - 🧠 **Domain-focused development** without infrastructure concerns 11 | - 🔌 **Flexible infrastructure** - change databases or frameworks without affecting core logic 12 | - 🧩 **Future scalability** - easily evolve into microservices when needed 13 | 14 | ## 🛠️ Technology Stack 15 | 16 | - **Web Framework**: [Fiber v3](https://github.com/gofiber/fiber) 17 | - **Dependency Injection**: [Uber Fx](https://github.com/uber-go/fx) 18 | - **Database**: [GORM](https://gorm.io) 19 | - **Cache / Session**: [go-redis v9](https://github.com/redis/go-redis) 20 | - **Configuration**: [Viper](https://github.com/spf13/viper) 21 | - **Authentication**: JWT (github.com/golang-jwt/jwt/v5) 22 | - **Validation**: go-playground/validator 23 | - **Testing**: Testify + mockgen 24 | 25 | ## 🗂️ Project Structure 26 | 27 | ``` 28 | cmd/ 29 | app/ # main.go (entry point) 30 | internal/ 31 | adapter/ 32 | http/ # API endpoints and middleware 33 | persistence/ # Data models and mappers 34 | repository/ # Port implementations 35 | app/ # Module composition (Fx options) 36 | domain/ 37 | auth/ # Authentication interfaces 38 | session/ # Session management interfaces 39 | user/ # Entities and repository interfaces 40 | service/ 41 | auth/ # Authentication use cases 42 | user/ # User management use cases 43 | shared/ # Cross-cutting concerns (config, db, redis, jwt) 44 | ``` 45 | 46 | ## 🚀 Getting Started 47 | 48 | ```bash 49 | # 1. Clone and update dependencies 50 | $ git clone https://github.com/ditthkr/go-template.git 51 | $ cd go-template 52 | $ go mod tidy 53 | 54 | # 2. Configure your application 55 | $ cp config.yaml.example config.yaml # Remember to update DSN, Redis, JWT secret 56 | 57 | # 3. Run development mode 58 | $ make dev # Loads ./config.yaml with ENV variable overrides 59 | ``` 60 | 61 | ## 🌐 Core APIs 62 | 63 | | Method | Path | Auth | Description | 64 | |--------|------------------|------|-------------| 65 | | POST | `/auth/register` | ✗ | Register with username and email | 66 | | POST | `/auth/login` | ✗ | Login and receive JWT token | 67 | | GET | `/users/me` | ✓ | Retrieve current user profile | 68 | 69 | Response format: 70 | ```json 71 | // Success 72 | {"success":true, "data":{...}} 73 | // Error 74 | {"success":false, "message":"Error description"} 75 | ``` 76 | 77 | ## 🧪 Testing 78 | 79 | The project uses `gomock` for repository mocking and `testify` for assertions, supporting both unit and integration testing: 80 | 81 | ```bash 82 | # Run all tests 83 | $ go test ./... 84 | 85 | # Test specific package 86 | $ go test ./internal/service/auth -v 87 | 88 | # Test specific function 89 | $ go test ./internal/service/auth -run TestAuthService_Register 90 | ``` 91 | 92 | ## 🙋‍♀️ Questions or Issues? 93 | 94 | Please feel free to: 95 | - Open an issue 96 | - Submit a pull request 97 | - Fork for customization 98 | 99 | --- 100 | ## 📝 MIT License -------------------------------------------------------------------------------- /internal/service/user/mock/repository.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: go-template/internal/domain/user (interfaces: Repository) 3 | 4 | // Package mock is a generated GoMock package. 5 | package mock 6 | 7 | import ( 8 | user "go-template/internal/domain/user" 9 | reflect "reflect" 10 | 11 | gomock "github.com/golang/mock/gomock" 12 | ) 13 | 14 | // MockRepository is a mock of Repository interface. 15 | type MockRepository struct { 16 | ctrl *gomock.Controller 17 | recorder *MockRepositoryMockRecorder 18 | } 19 | 20 | // MockRepositoryMockRecorder is the mock recorder for MockRepository. 21 | type MockRepositoryMockRecorder struct { 22 | mock *MockRepository 23 | } 24 | 25 | // NewMockRepository creates a new mock instance. 26 | func NewMockRepository(ctrl *gomock.Controller) *MockRepository { 27 | mock := &MockRepository{ctrl: ctrl} 28 | mock.recorder = &MockRepositoryMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use. 33 | func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // ExistsEmail mocks base method. 38 | func (m *MockRepository) ExistsEmail(arg0 string) (bool, error) { 39 | m.ctrl.T.Helper() 40 | ret := m.ctrl.Call(m, "ExistsEmail", arg0) 41 | ret0, _ := ret[0].(bool) 42 | ret1, _ := ret[1].(error) 43 | return ret0, ret1 44 | } 45 | 46 | // ExistsEmail indicates an expected call of ExistsEmail. 47 | func (mr *MockRepositoryMockRecorder) ExistsEmail(arg0 interface{}) *gomock.Call { 48 | mr.mock.ctrl.T.Helper() 49 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExistsEmail", reflect.TypeOf((*MockRepository)(nil).ExistsEmail), arg0) 50 | } 51 | 52 | // ExistsUsername mocks base method. 53 | func (m *MockRepository) ExistsUsername(arg0 string) (bool, error) { 54 | m.ctrl.T.Helper() 55 | ret := m.ctrl.Call(m, "ExistsUsername", arg0) 56 | ret0, _ := ret[0].(bool) 57 | ret1, _ := ret[1].(error) 58 | return ret0, ret1 59 | } 60 | 61 | // ExistsUsername indicates an expected call of ExistsUsername. 62 | func (mr *MockRepositoryMockRecorder) ExistsUsername(arg0 interface{}) *gomock.Call { 63 | mr.mock.ctrl.T.Helper() 64 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExistsUsername", reflect.TypeOf((*MockRepository)(nil).ExistsUsername), arg0) 65 | } 66 | 67 | // FindById mocks base method. 68 | func (m *MockRepository) FindById(arg0 uint64) (*user.User, error) { 69 | m.ctrl.T.Helper() 70 | ret := m.ctrl.Call(m, "FindById", arg0) 71 | ret0, _ := ret[0].(*user.User) 72 | ret1, _ := ret[1].(error) 73 | return ret0, ret1 74 | } 75 | 76 | // FindById indicates an expected call of FindById. 77 | func (mr *MockRepositoryMockRecorder) FindById(arg0 interface{}) *gomock.Call { 78 | mr.mock.ctrl.T.Helper() 79 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindById", reflect.TypeOf((*MockRepository)(nil).FindById), arg0) 80 | } 81 | 82 | // FindByUsername mocks base method. 83 | func (m *MockRepository) FindByUsername(arg0 string) (*user.User, error) { 84 | m.ctrl.T.Helper() 85 | ret := m.ctrl.Call(m, "FindByUsername", arg0) 86 | ret0, _ := ret[0].(*user.User) 87 | ret1, _ := ret[1].(error) 88 | return ret0, ret1 89 | } 90 | 91 | // FindByUsername indicates an expected call of FindByUsername. 92 | func (mr *MockRepositoryMockRecorder) FindByUsername(arg0 interface{}) *gomock.Call { 93 | mr.mock.ctrl.T.Helper() 94 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByUsername", reflect.TypeOf((*MockRepository)(nil).FindByUsername), arg0) 95 | } 96 | 97 | // Save mocks base method. 98 | func (m *MockRepository) Save(arg0 *user.User) error { 99 | m.ctrl.T.Helper() 100 | ret := m.ctrl.Call(m, "Save", arg0) 101 | ret0, _ := ret[0].(error) 102 | return ret0 103 | } 104 | 105 | // Save indicates an expected call of Save. 106 | func (mr *MockRepositoryMockRecorder) Save(arg0 interface{}) *gomock.Call { 107 | mr.mock.ctrl.T.Helper() 108 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockRepository)(nil).Save), arg0) 109 | } 110 | -------------------------------------------------------------------------------- /internal/adapter/repository/user_repository_test.go: -------------------------------------------------------------------------------- 1 | package repository_test 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "github.com/stretchr/testify/require" 6 | "go-template/internal/adapter/repository" 7 | "go-template/internal/domain/user" 8 | "go-template/internal/shared/testutil" 9 | "gorm.io/gorm" 10 | "testing" 11 | ) 12 | 13 | func TestUserRepository_Save(t *testing.T) { 14 | // Setup test database 15 | db := testutil.NewTestDB() 16 | repo := repository.NewUserRepository(db) 17 | 18 | t.Run("Save user successfully", func(t *testing.T) { 19 | // Create test user 20 | u := &user.User{ 21 | Username: "testuser", 22 | Email: "test@example.com", 23 | Password: "hashedpassword", 24 | } 25 | 26 | // Save user 27 | err := repo.Save(u) 28 | 29 | // Assert 30 | require.NoError(t, err) 31 | assert.NotZero(t, u.Id, "User ID should be set after save") 32 | 33 | // Verify user exists in database 34 | var count int64 35 | db.Model(&struct{ ID uint64 }{}).Table("users").Where("id = ?", u.Id).Count(&count) 36 | assert.Equal(t, int64(1), count, "User should exist in database") 37 | }) 38 | } 39 | 40 | func TestUserRepository_FindById(t *testing.T) { 41 | // Setup test database 42 | db := testutil.NewTestDB() 43 | repo := repository.NewUserRepository(db) 44 | 45 | // Create test user directly in DB 46 | testUser := testutil.CreateTestUser(t, db, "findbyid", "findbyid@example.com") 47 | 48 | t.Run("Find existing user by ID", func(t *testing.T) { 49 | // Find user 50 | found, err := repo.FindById(testUser.Id) 51 | 52 | // Assert 53 | require.NoError(t, err) 54 | assert.NotNil(t, found) 55 | assert.Equal(t, testUser.Id, found.Id) 56 | assert.Equal(t, "findbyid", found.Username) 57 | assert.Equal(t, "findbyid@example.com", found.Email) 58 | }) 59 | 60 | t.Run("Find non-existing user by ID", func(t *testing.T) { 61 | // Find non-existent user 62 | found, err := repo.FindById(999999) 63 | 64 | // Assert 65 | assert.Error(t, err) 66 | assert.Nil(t, found) 67 | assert.Equal(t, gorm.ErrRecordNotFound, err) 68 | }) 69 | } 70 | 71 | func TestUserRepository_FindByUsername(t *testing.T) { 72 | // Setup test database 73 | db := testutil.NewTestDB() 74 | repo := repository.NewUserRepository(db) 75 | 76 | // Create test user directly in DB 77 | testutil.CreateTestUser(t, db, "findbyusername", "findbyusername@example.com") 78 | 79 | t.Run("Find existing user by username", func(t *testing.T) { 80 | // Find user 81 | found, err := repo.FindByUsername("findbyusername") 82 | 83 | // Assert 84 | require.NoError(t, err) 85 | assert.NotNil(t, found) 86 | assert.Equal(t, "findbyusername", found.Username) 87 | assert.Equal(t, "findbyusername@example.com", found.Email) 88 | }) 89 | 90 | t.Run("Find non-existing user by username", func(t *testing.T) { 91 | // Find non-existent user 92 | found, err := repo.FindByUsername("nonexistent") 93 | 94 | // Assert 95 | assert.Error(t, err) 96 | assert.Nil(t, found) 97 | assert.Equal(t, gorm.ErrRecordNotFound, err) 98 | }) 99 | } 100 | 101 | func TestUserRepository_ExistsUsername(t *testing.T) { 102 | // Setup test database 103 | db := testutil.NewTestDB() 104 | repo := repository.NewUserRepository(db) 105 | 106 | // Create test user directly in DB 107 | testutil.CreateTestUser(t, db, "existsusername", "existsusername@example.com") 108 | 109 | t.Run("Check existing username", func(t *testing.T) { 110 | // Check if username exists 111 | exists, err := repo.ExistsUsername("existsusername") 112 | 113 | // Assert 114 | require.NoError(t, err) 115 | assert.True(t, exists, "Username should exist") 116 | }) 117 | 118 | t.Run("Check non-existing username", func(t *testing.T) { 119 | // Check if username exists 120 | exists, err := repo.ExistsUsername("nonexistent") 121 | 122 | // Assert 123 | require.NoError(t, err) 124 | assert.False(t, exists, "Username should not exist") 125 | }) 126 | } 127 | 128 | func TestUserRepository_ExistsEmail(t *testing.T) { 129 | // Setup test database 130 | db := testutil.NewTestDB() 131 | repo := repository.NewUserRepository(db) 132 | 133 | // Create test user directly in DB 134 | testutil.CreateTestUser(t, db, "existsemail", "existsemail@example.com") 135 | 136 | t.Run("Check existing email", func(t *testing.T) { 137 | // Check if email exists 138 | exists, err := repo.ExistsEmail("existsemail@example.com") 139 | 140 | // Assert 141 | require.NoError(t, err) 142 | assert.True(t, exists, "Email should exist") 143 | }) 144 | 145 | t.Run("Check non-existing email", func(t *testing.T) { 146 | // Check if email exists 147 | exists, err := repo.ExistsEmail("nonexistent@example.com") 148 | 149 | // Assert 150 | require.NoError(t, err) 151 | assert.False(t, exists, "Email should not exist") 152 | }) 153 | } 154 | --------------------------------------------------------------------------------