diff --git a/cmd/main.go b/cmd/main.go index 73302f7..211d5a5 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -20,14 +20,11 @@ import ( "cruder/internal/middleware" "cruder/internal/repository" "cruder/internal/service" - "fmt" "log/slog" "os" - "strings" "github.com/gin-gonic/gin" "github.com/joho/godotenv" - "github.com/spf13/viper" ) func main() { @@ -48,7 +45,7 @@ func main() { os.Exit(1) } - cfg, err := loadConfig() + cfg, err := config.LoadConfig() if err != nil { logger.Error("failed to load config", slog.Any("err", err)) os.Exit(1) @@ -79,27 +76,8 @@ func main() { r.Use(apiKeyMiddleware.Handler()) _ = r.SetTrustedProxies(nil) - handler.New(r, controllers.Users) + handler.New(r, controllers.Users, controllers.Health) if err := r.Run(); err != nil { logger.Error("failed to run server", slog.Any("err", err)) } } - -func loadConfig() (*config.Config, error) { - viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - viper.AutomaticEnv() - viper.AddConfigPath("config") - viper.SetConfigName("config") - viper.SetConfigType("yaml") - viper.AddConfigPath(".") - - if err := viper.ReadInConfig(); err != nil { - return nil, fmt.Errorf("error reading config file: %w", err) - } - - var cfg config.Config - if err := viper.Unmarshal(&cfg); err != nil { - return nil, fmt.Errorf("unable to decode config into struct: %w", err) - } - return &cfg, nil -} diff --git a/docs/docs.go b/docs/docs.go index 9e52716..6282bc1 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -15,7 +15,7 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { - "/users": { + "/users/": { "get": { "security": [ { diff --git a/docs/swagger.json b/docs/swagger.json index 11d6457..937f6dc 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -8,7 +8,7 @@ }, "basePath": "/api/v1", "paths": { - "/users": { + "/users/": { "get": { "security": [ { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 7c4bc48..292e58d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -25,7 +25,7 @@ info: title: Users API version: "1.0" paths: - /users: + /users/: get: produces: - application/json diff --git a/internal/config/config.go b/internal/config/config.go index 35a4a8f..7573305 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,7 +2,11 @@ package config import ( "errors" + "fmt" "os" + "strings" + + "github.com/spf13/viper" ) type Config struct { @@ -29,3 +33,22 @@ func (c *Config) GetDSN() (string, error) { return dsn, nil } + +func LoadConfig() (*Config, error) { + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + viper.AutomaticEnv() + viper.AddConfigPath("config") + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath(".") + + if err := viper.ReadInConfig(); err != nil { + return nil, fmt.Errorf("error reading config file: %w", err) + } + + var cfg Config + if err := viper.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("unable to decode config into struct: %w", err) + } + return &cfg, nil +} diff --git a/internal/controller/controllers.go b/internal/controller/controllers.go index 2ea7d0d..aaf5e09 100644 --- a/internal/controller/controllers.go +++ b/internal/controller/controllers.go @@ -3,11 +3,13 @@ package controller import "cruder/internal/service" type Controller struct { - Users *UserController + Users *UserController + Health *HealthController } func NewController(services *service.Service) *Controller { return &Controller{ - Users: NewUserController(services.Users), + Users: NewUserController(services.Users), + Health: NewHealthController(), } } diff --git a/internal/controller/health.go b/internal/controller/health.go new file mode 100644 index 0000000..de66234 --- /dev/null +++ b/internal/controller/health.go @@ -0,0 +1,13 @@ +package controller + +import "github.com/gin-gonic/gin" + +type HealthController struct{} + +func NewHealthController() *HealthController { + return &HealthController{} +} + +func (c *HealthController) HealthCheck(ctx *gin.Context) { + ctx.JSON(200, gin.H{"status": "ok"}) +} diff --git a/internal/controller/health_test.go b/internal/controller/health_test.go new file mode 100644 index 0000000..79414ce --- /dev/null +++ b/internal/controller/health_test.go @@ -0,0 +1,29 @@ +package controller + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func setupHealthRouter(c *HealthController) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.Default() + r.GET("/healthz", c.HealthCheck) + return r +} + +func TestHealthCheck_Success(t *testing.T) { + controller := NewHealthController() + router := setupHealthRouter(controller) + + req, err := http.NewRequest("GET", "/healthz", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, w.Code) +} diff --git a/internal/controller/users.go b/internal/controller/users.go index e39c680..a43c52e 100644 --- a/internal/controller/users.go +++ b/internal/controller/users.go @@ -5,7 +5,6 @@ import ( "strconv" "cruder/internal/model" - "cruder/internal/repository" "cruder/internal/service" "github.com/gin-gonic/gin" @@ -26,7 +25,7 @@ func NewUserController(service service.UserService) *UserController { // @Success 200 {array} model.User // @Failure 500 {object} model.ErrorResponse "internal server error" // @Security ApiKeyAuth -// @Router /users [get] +// @Router /users/ [get] func (c *UserController) GetAllUsers(ctx *gin.Context) { users, err := c.service.GetAll() if handleError(ctx, err) { @@ -96,7 +95,7 @@ func (c *UserController) GetUserByID(ctx *gin.Context) { // @Failure 409 {object} model.ErrorResponse "user with username/email already exists" // @Failure 500 {object} model.ErrorResponse "internal server error" // @Security ApiKeyAuth -// @Router /users [post] +// @Router /users/ [post] func (c *UserController) CreateUser(ctx *gin.Context) { var user model.User @@ -191,11 +190,11 @@ func handleError(ctx *gin.Context, err error) bool { } var errToStatus = map[error]int{ - repository.ErrUserNotFound: http.StatusNotFound, - repository.ErrUserAlreadyExists: http.StatusConflict, - repository.ErrUsernameAlreadyExists: http.StatusConflict, - repository.ErrEmailAlreadyExists: http.StatusConflict, - service.ErrInvalidEmail: http.StatusBadRequest, - service.ErrInvalidUsername: http.StatusBadRequest, - service.ErrInvalidFullName: http.StatusBadRequest, + service.ErrUserNotFound: http.StatusNotFound, + service.ErrUserAlreadyExists: http.StatusConflict, + service.ErrUsernameAlreadyExists: http.StatusConflict, + service.ErrEmailAlreadyExists: http.StatusConflict, + service.ErrInvalidEmail: http.StatusBadRequest, + service.ErrInvalidUsername: http.StatusBadRequest, + service.ErrInvalidFullName: http.StatusBadRequest, } diff --git a/internal/controller/users_test.go b/internal/controller/users_test.go index 1cdf251..33c7e48 100644 --- a/internal/controller/users_test.go +++ b/internal/controller/users_test.go @@ -4,7 +4,6 @@ import ( "bytes" mock_service "cruder/internal/mocks/service" "cruder/internal/model" - "cruder/internal/repository" "cruder/internal/service" "encoding/json" "net/http" @@ -16,7 +15,7 @@ import ( "go.uber.org/mock/gomock" ) -func setupRouter(c *UserController) *gin.Engine { +func setupUserRouter(c *UserController) *gin.Engine { gin.SetMode(gin.TestMode) r := gin.Default() r.GET("/users", c.GetAllUsers) @@ -37,7 +36,7 @@ func TestGetAllUsers_Success(t *testing.T) { mockSvc.EXPECT().GetAll().Return(users, nil) controller := NewUserController(mockSvc) - router := setupRouter(controller) + router := setupUserRouter(controller) // When: GET /users is called req, _ := http.NewRequest("GET", "/users", nil) @@ -61,7 +60,7 @@ func TestGetUserByUsername_Success(t *testing.T) { mockSvc.EXPECT().GetByUsername("john_doe").Return(expected, nil) controller := NewUserController(mockSvc) - router := setupRouter(controller) + router := setupUserRouter(controller) // When: GET /users/username/john_doe is called req, _ := http.NewRequest("GET", "/users/username/john_doe", nil) @@ -81,10 +80,10 @@ func TestGetUserByUsername_NotFound(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockSvc := mock_service.NewMockUserService(ctrl) - mockSvc.EXPECT().GetByUsername("missing").Return(nil, repository.ErrUserNotFound) + mockSvc.EXPECT().GetByUsername("missing").Return(nil, service.ErrUserNotFound) controller := NewUserController(mockSvc) - router := setupRouter(controller) + router := setupUserRouter(controller) // When: GET /users/username/missing is called req, _ := http.NewRequest("GET", "/users/username/missing", nil) @@ -107,7 +106,7 @@ func TestGetUserByID_Success(t *testing.T) { mockSvc.EXPECT().GetByID(int64(1)).Return(expected, nil) controller := NewUserController(mockSvc) - router := setupRouter(controller) + router := setupUserRouter(controller) // When: GET /users/id/1 is called req, _ := http.NewRequest("GET", "/users/id/1", nil) @@ -129,7 +128,7 @@ func TestGetUserByID_InvalidID(t *testing.T) { mockSvc := mock_service.NewMockUserService(ctrl) controller := NewUserController(mockSvc) - router := setupRouter(controller) + router := setupUserRouter(controller) // When: GET /users/id/abc is called req, _ := http.NewRequest("GET", "/users/id/abc", nil) @@ -153,7 +152,7 @@ func TestCreateUser_Success(t *testing.T) { mockSvc.EXPECT().Create(input).Return(created, nil) controller := NewUserController(mockSvc) - router := setupRouter(controller) + router := setupUserRouter(controller) body, _ := json.Marshal(input) @@ -177,7 +176,7 @@ func TestCreateUser_InvalidBody(t *testing.T) { mockSvc := mock_service.NewMockUserService(ctrl) controller := NewUserController(mockSvc) - router := setupRouter(controller) + router := setupUserRouter(controller) // When: POST /users with invalid JSON req, _ := http.NewRequest("POST", "/users", bytes.NewBuffer([]byte("{bad json"))) @@ -197,10 +196,10 @@ func TestDeleteUser_NotFound(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockSvc := mock_service.NewMockUserService(ctrl) - mockSvc.EXPECT().Delete(int64(99)).Return(repository.ErrUserNotFound) + mockSvc.EXPECT().Delete(int64(99)).Return(service.ErrUserNotFound) controller := NewUserController(mockSvc) - router := setupRouter(controller) + router := setupUserRouter(controller) // When: DELETE /users/99 is called req, _ := http.NewRequest("DELETE", "/users/99", nil) @@ -221,7 +220,7 @@ func TestUpdateUser_IDMismatch(t *testing.T) { mockSvc := mock_service.NewMockUserService(ctrl) controller := NewUserController(mockSvc) - router := setupRouter(controller) + router := setupUserRouter(controller) user := model.User{ID: 2, Username: "john", Email: "john@doe.ee", FullName: "John Doe"} body, _ := json.Marshal(user) @@ -249,7 +248,7 @@ func TestUpdateUser_Success(t *testing.T) { mockSvc.EXPECT().Update(input).Return(updated, nil) controller := NewUserController(mockSvc) - router := setupRouter(controller) + router := setupUserRouter(controller) body, _ := json.Marshal(input) @@ -272,7 +271,7 @@ func TestHandleError_UserNotFound(t *testing.T) { c, _ := gin.CreateTestContext(w) // When: calling handleError with ErrUserNotFound - result := handleError(c, repository.ErrUserNotFound) + result := handleError(c, service.ErrUserNotFound) // Then: should return true and response with 404 + correct error message assert.True(t, result) @@ -289,7 +288,7 @@ func TestHandleError_UserAlreadyExists(t *testing.T) { c, _ := gin.CreateTestContext(w) // When: calling handleError with ErrUserAlreadyExists - result := handleError(c, repository.ErrUserAlreadyExists) + result := handleError(c, service.ErrUserAlreadyExists) // Then: should return true and response with 409 + correct error message assert.True(t, result) @@ -306,7 +305,7 @@ func TestHandleError_UsernameAlreadyExists(t *testing.T) { c, _ := gin.CreateTestContext(w) // When: calling handleError with ErrUsernameAlreadyExists - result := handleError(c, repository.ErrUsernameAlreadyExists) + result := handleError(c, service.ErrUsernameAlreadyExists) // Then: should return true and response with 409 + correct error message assert.True(t, result) @@ -323,7 +322,7 @@ func TestHandleError_EmailAlreadyExists(t *testing.T) { c, _ := gin.CreateTestContext(w) // When: calling handleError with ErrEmailAlreadyExists - result := handleError(c, repository.ErrEmailAlreadyExists) + result := handleError(c, service.ErrEmailAlreadyExists) // Then: should return true and response with 409 + correct error message assert.True(t, result) diff --git a/internal/handler/router.go b/internal/handler/router.go index eda1695..b0a6c0b 100644 --- a/internal/handler/router.go +++ b/internal/handler/router.go @@ -10,10 +10,8 @@ import ( ginSwagger "github.com/swaggo/gin-swagger" ) -func New(router *gin.Engine, userController *controller.UserController) *gin.Engine { - router.GET("/healthz", func(c *gin.Context) { - c.JSON(200, gin.H{"status": "ok"}) - }) +func New(router *gin.Engine, userController *controller.UserController, healthController *controller.HealthController) *gin.Engine { + router.GET("/healthz", healthController.HealthCheck) v1 := router.Group("/api/v1") { userGroup := v1.Group("/users") diff --git a/internal/middleware/apikey.go b/internal/middleware/apikey.go index 14e021f..d33e3bc 100644 --- a/internal/middleware/apikey.go +++ b/internal/middleware/apikey.go @@ -1,6 +1,10 @@ package middleware -import "github.com/gin-gonic/gin" +import ( + "slices" + + "github.com/gin-gonic/gin" +) type ApiKeyMiddleware struct { apiKey string @@ -13,11 +17,9 @@ func NewApiKeyMiddleware(apiKey string, ignored []string) *ApiKeyMiddleware { func (am *ApiKeyMiddleware) Handler() gin.HandlerFunc { return func(c *gin.Context) { - for _, path := range am.ignored { - if c.FullPath() == path { - c.Next() - return - } + if slices.Contains(am.ignored, c.FullPath()) { + c.Next() + return } providedKey := c.GetHeader("X-Api-Key") diff --git a/internal/repository/users.go b/internal/repository/users.go index 97e0e57..fd310f3 100644 --- a/internal/repository/users.go +++ b/internal/repository/users.go @@ -5,6 +5,7 @@ import ( "cruder/internal/model" "database/sql" "errors" + "fmt" "github.com/lib/pq" ) @@ -57,7 +58,7 @@ func (r *userRepository) GetByUsername(username string) (*model.User, error) { if err := r.db.QueryRowContext(context.Background(), `SELECT id, username, email, full_name FROM users WHERE username = $1`, username). Scan(&u.ID, &u.Username, &u.Email, &u.FullName); err != nil { if err == sql.ErrNoRows { - return nil, ErrUserNotFound + return nil, ErrRowNotFound } return nil, err } @@ -69,7 +70,7 @@ func (r *userRepository) GetByID(id int64) (*model.User, error) { if err := r.db.QueryRowContext(context.Background(), `SELECT id, username, email, full_name FROM users WHERE id = $1`, id). Scan(&u.ID, &u.Username, &u.Email, &u.FullName); err != nil { if err == sql.ErrNoRows { - return nil, ErrUserNotFound + return nil, ErrRowNotFound } return nil, err } @@ -80,14 +81,7 @@ func (r *userRepository) Create(user *model.User) (*model.User, error) { if err := r.db.QueryRowContext(context.Background(), `INSERT INTO users (username, email, full_name) VALUES ($1, $2, $3) RETURNING id, username, email, full_name`, user.Username, user.Email, user.FullName). Scan(&user.ID, &user.Username, &user.Email, &user.FullName); err != nil { if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" { - switch pqErr.Constraint { - case "users_username_key": - return nil, ErrUsernameAlreadyExists - case "users_email_key": - return nil, ErrEmailAlreadyExists - default: - return nil, ErrUserAlreadyExists - } + return nil, handleUniqueConstraintError(pqErr.Constraint) } return nil, err } @@ -99,13 +93,13 @@ func (r *userRepository) Delete(id int64) error { if err := r.db.QueryRowContext(context.Background(), `DELETE FROM users WHERE id = $1 RETURNING id`, id). Scan(&idCheck); err != nil { if err == sql.ErrNoRows { - return ErrUserNotFound + return ErrRowNotFound } else { return err } } if idCheck == 0 { - return ErrUserNotFound + return ErrRowNotFound } return nil } @@ -114,15 +108,32 @@ func (r *userRepository) Update(user *model.User) (*model.User, error) { if err := r.db.QueryRowContext(context.Background(), `UPDATE users SET username = $1, email = $2, full_name = $3 WHERE id = $4 RETURNING id, username, email, full_name`, user.Username, user.Email, user.FullName, user.ID). Scan(&user.ID, &user.Username, &user.Email, &user.FullName); err != nil { if err == sql.ErrNoRows { - return nil, ErrUserNotFound - } else { - return nil, err + return nil, ErrRowNotFound + } else if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" { + return nil, handleUniqueConstraintError(pqErr.Constraint) } + return nil, err } return user, nil } -var ErrUserNotFound = errors.New("user not found") -var ErrUserAlreadyExists = errors.New("user already exists") -var ErrUsernameAlreadyExists = errors.New("username already exists") -var ErrEmailAlreadyExists = errors.New("email already exists") +func handleUniqueConstraintError(constraint string) error { + switch constraint { + case "users_username_key": + return &UniqueConstraintError{Field: "username"} + case "users_email_key": + return &UniqueConstraintError{Field: "email"} + default: + return &UniqueConstraintError{} + } +} + +var ErrRowNotFound = errors.New("user not found") + +type UniqueConstraintError struct { + Field string +} + +func (e *UniqueConstraintError) Error() string { + return fmt.Sprintf("%s unique constraint violation", e.Field) +} diff --git a/internal/repository/users_test.go b/internal/repository/users_test.go index bf55ba7..708a07c 100644 --- a/internal/repository/users_test.go +++ b/internal/repository/users_test.go @@ -70,7 +70,7 @@ func TestGetByUsername_NotFound(t *testing.T) { user, err := repo.GetByUsername("missing") // Then: ErrUserNotFound should be returned and user should be nil - assert.ErrorIs(t, err, ErrUserNotFound) + assert.ErrorIs(t, err, ErrRowNotFound) assert.Nil(t, user) assert.NoError(t, mock.ExpectationsWereMet()) } @@ -106,7 +106,7 @@ func TestGetByID_NotFound(t *testing.T) { user, err := repo.GetByID(99) // Then: ErrUserNotFound should be returned and user should be nil - assert.ErrorIs(t, err, ErrUserNotFound) + assert.ErrorIs(t, err, ErrRowNotFound) assert.Nil(t, user) assert.NoError(t, mock.ExpectationsWereMet()) } @@ -144,8 +144,11 @@ func TestCreateUser_DuplicateUsername(t *testing.T) { // When: calling Create with duplicate username created, err := repo.Create(newUser) - // Then: ErrUsernameAlreadyExists should be returned - assert.ErrorIs(t, err, ErrUsernameAlreadyExists) + // Then: UniqueConstraintError with field username should be returned + var ce *UniqueConstraintError + if assert.ErrorAs(t, err, &ce) { + assert.Equal(t, "username", ce.Field) + } assert.Nil(t, created) assert.NoError(t, mock.ExpectationsWereMet()) } @@ -163,8 +166,11 @@ func TestCreateUser_DuplicateEmail(t *testing.T) { // When: calling Create with duplicate email created, err := repo.Create(newUser) - // Then: ErrEmailAlreadyExists should be returned - assert.ErrorIs(t, err, ErrEmailAlreadyExists) + // Then: UniqueConstraintError with field email should be returned + var ce *UniqueConstraintError + if assert.ErrorAs(t, err, &ce) { + assert.Equal(t, "email", ce.Field) + } assert.Nil(t, created) assert.NoError(t, mock.ExpectationsWereMet()) } @@ -182,8 +188,11 @@ func TestCreateUser_DuplicateOther(t *testing.T) { // When: calling Create with some other duplicate created, err := repo.Create(newUser) - // Then: ErrUserAlreadyExists should be returned - assert.ErrorIs(t, err, ErrUserAlreadyExists) + // Then: UniqueConstraintError should be returned + var ce *UniqueConstraintError + if assert.ErrorAs(t, err, &ce) { + assert.Equal(t, "", ce.Field) + } assert.Nil(t, created) assert.NoError(t, mock.ExpectationsWereMet()) } @@ -217,7 +226,7 @@ func TestDeleteUser_NotFound(t *testing.T) { err := repo.Delete(99) // Then: ErrUserNotFound should be returned - assert.ErrorIs(t, err, ErrUserNotFound) + assert.ErrorIs(t, err, ErrRowNotFound) assert.NoError(t, mock.ExpectationsWereMet()) } @@ -254,7 +263,7 @@ func TestUpdateUser_NotFound(t *testing.T) { updated, err := repo.Update(user) // Then: ErrUserNotFound should be returned - assert.ErrorIs(t, err, ErrUserNotFound) + assert.ErrorIs(t, err, ErrRowNotFound) assert.Nil(t, updated) assert.NoError(t, mock.ExpectationsWereMet()) } diff --git a/internal/service/users.go b/internal/service/users.go index c1deecf..858e56b 100644 --- a/internal/service/users.go +++ b/internal/service/users.go @@ -29,29 +29,79 @@ func (s *userService) GetAll() ([]model.User, error) { } func (s *userService) GetByUsername(username string) (*model.User, error) { - return s.repo.GetByUsername(username) + user, err := s.repo.GetByUsername(username) + + if err != nil { + if errors.Is(err, repository.ErrRowNotFound) { + return nil, ErrUserNotFound + } + return nil, err + } + + return user, nil } func (s *userService) GetByID(id int64) (*model.User, error) { - return s.repo.GetByID(id) + user, err := s.repo.GetByID(id) + + if err != nil { + if errors.Is(err, repository.ErrRowNotFound) { + return nil, ErrUserNotFound + } + return nil, err + } + + return user, nil } func (s *userService) Create(user *model.User) (*model.User, error) { if err := ValidateUser(*user); err != nil { return nil, err } - return s.repo.Create(user) + user, err := s.repo.Create(user) + + if ce, ok := err.(*repository.UniqueConstraintError); ok { + return nil, handleUniqueConstraintError(ce) + } + + return user, err +} + +func handleUniqueConstraintError(err *repository.UniqueConstraintError) error { + switch err.Field { + case "username": + return ErrUsernameAlreadyExists + case "email": + return ErrEmailAlreadyExists + default: + return ErrUserAlreadyExists + } } func (s *userService) Delete(id int64) error { - return s.repo.Delete(id) + err := s.repo.Delete(id) + + if errors.Is(err, repository.ErrRowNotFound) { + return ErrUserNotFound + } + return err } func (s *userService) Update(user *model.User) (*model.User, error) { if err := ValidateUser(*user); err != nil { return nil, err } - return s.repo.Update(user) + + user, err := s.repo.Update(user) + + if errors.Is(err, repository.ErrRowNotFound) { + return nil, ErrUserNotFound + } + + if ce, ok := err.(*repository.UniqueConstraintError); ok { + return nil, handleUniqueConstraintError(ce) + } + return user, err } func ValidateUser(user model.User) error { @@ -71,6 +121,12 @@ var emailRegex = regexp.MustCompile(`^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$`) var usernameRegex = regexp.MustCompile(`^[a-z][a-z0-9_]{2,49}$`) var fullNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z' -]{0,98}[A-Za-z]$`) -var ErrInvalidEmail = errors.New("invalid email format") -var ErrInvalidUsername = errors.New("invalid username format (3-50 chars, lowercase letters, numbers, underscores, starts with letter)") -var ErrInvalidFullName = errors.New("invalid full name format (2-100 chars, letters, spaces, apostrophes, hyphens, starts/ends with letter)") +var ( + ErrUserNotFound = errors.New("user not found") + ErrUserAlreadyExists = errors.New("user already exists") + ErrUsernameAlreadyExists = errors.New("username already exists") + ErrEmailAlreadyExists = errors.New("email already exists") + ErrInvalidEmail = errors.New("invalid email format") + ErrInvalidUsername = errors.New("invalid username format (3-50 chars, lowercase letters, numbers, underscores, starts with letter)") + ErrInvalidFullName = errors.New("invalid full name format (2-100 chars, letters, spaces, apostrophes, hyphens, starts/ends with letter)") +)