Migrate from Gin to Echo framework and add comprehensive integration tests
Major changes: - Migrate all handlers from Gin to Echo framework - Add new apperrors, echohelpers, and validator packages - Update middleware for Echo compatibility - Add ArchivedHandler to task categorization chain (archived tasks go to cancelled_tasks column) - Add 6 new integration tests: - RecurringTaskLifecycle: NextDueDate advancement for weekly/monthly tasks - MultiUserSharing: Complex sharing with user removal - TaskStateTransitions: All state transitions and kanban column changes - DateBoundaryEdgeCases: Threshold boundary testing - CascadeOperations: Residence deletion cascade effects - MultiUserOperations: Shared residence collaboration - Add single-purpose repository functions for kanban columns (GetOverdueTasks, GetDueSoonTasks, etc.) - Fix RemoveUser route param mismatch (userId -> user_id) - Fix determineExpectedColumn helper to correctly prioritize in_progress over overdue 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/apperrors"
|
||||
"github.com/treytartt/casera-api/internal/config"
|
||||
"github.com/treytartt/casera-api/internal/dto/requests"
|
||||
"github.com/treytartt/casera-api/internal/dto/responses"
|
||||
@@ -18,18 +19,20 @@ import (
|
||||
"github.com/treytartt/casera-api/internal/repositories"
|
||||
)
|
||||
|
||||
// Deprecated: Legacy error constants - kept for reference during transition
|
||||
// Use apperrors package instead
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrUsernameTaken = errors.New("username already taken")
|
||||
ErrEmailTaken = errors.New("email already taken")
|
||||
ErrUserInactive = errors.New("user account is inactive")
|
||||
ErrInvalidCode = errors.New("invalid verification code")
|
||||
ErrCodeExpired = errors.New("verification code expired")
|
||||
ErrAlreadyVerified = errors.New("email already verified")
|
||||
ErrRateLimitExceeded = errors.New("too many requests, please try again later")
|
||||
ErrInvalidResetToken = errors.New("invalid or expired reset token")
|
||||
ErrAppleSignInFailed = errors.New("Apple Sign In failed")
|
||||
ErrGoogleSignInFailed = errors.New("Google Sign In failed")
|
||||
// ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
// ErrUsernameTaken = errors.New("username already taken")
|
||||
// ErrEmailTaken = errors.New("email already taken")
|
||||
// ErrUserInactive = errors.New("user account is inactive")
|
||||
// ErrInvalidCode = errors.New("invalid verification code")
|
||||
// ErrCodeExpired = errors.New("verification code expired")
|
||||
// ErrAlreadyVerified = errors.New("email already verified")
|
||||
// ErrRateLimitExceeded = errors.New("too many requests, please try again later")
|
||||
// ErrInvalidResetToken = errors.New("invalid or expired reset token")
|
||||
ErrAppleSignInFailed = errors.New("Apple Sign In failed")
|
||||
ErrGoogleSignInFailed = errors.New("Google Sign In failed")
|
||||
)
|
||||
|
||||
// AuthService handles authentication business logic
|
||||
@@ -63,25 +66,25 @@ func (s *AuthService) Login(req *requests.LoginRequest) (*responses.LoginRespons
|
||||
user, err := s.userRepo.FindByUsernameOrEmail(identifier)
|
||||
if err != nil {
|
||||
if errors.Is(err, repositories.ErrUserNotFound) {
|
||||
return nil, ErrInvalidCredentials
|
||||
return nil, apperrors.Unauthorized("error.invalid_credentials")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to find user: %w", err)
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if !user.IsActive {
|
||||
return nil, ErrUserInactive
|
||||
return nil, apperrors.Unauthorized("error.account_inactive")
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if !user.CheckPassword(req.Password) {
|
||||
return nil, ErrInvalidCredentials
|
||||
return nil, apperrors.Unauthorized("error.invalid_credentials")
|
||||
}
|
||||
|
||||
// Get or create auth token
|
||||
token, err := s.userRepo.GetOrCreateToken(user.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token: %w", err)
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Update last login
|
||||
@@ -101,19 +104,19 @@ func (s *AuthService) Register(req *requests.RegisterRequest) (*responses.Regist
|
||||
// Check if username exists
|
||||
exists, err := s.userRepo.ExistsByUsername(req.Username)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to check username: %w", err)
|
||||
return nil, "", apperrors.Internal(err)
|
||||
}
|
||||
if exists {
|
||||
return nil, "", ErrUsernameTaken
|
||||
return nil, "", apperrors.Conflict("error.username_taken")
|
||||
}
|
||||
|
||||
// Check if email exists
|
||||
exists, err = s.userRepo.ExistsByEmail(req.Email)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to check email: %w", err)
|
||||
return nil, "", apperrors.Internal(err)
|
||||
}
|
||||
if exists {
|
||||
return nil, "", ErrEmailTaken
|
||||
return nil, "", apperrors.Conflict("error.email_taken")
|
||||
}
|
||||
|
||||
// Create user
|
||||
@@ -127,12 +130,12 @@ func (s *AuthService) Register(req *requests.RegisterRequest) (*responses.Regist
|
||||
|
||||
// Hash password
|
||||
if err := user.SetPassword(req.Password); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to hash password: %w", err)
|
||||
return nil, "", apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Save user
|
||||
if err := s.userRepo.Create(user); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create user: %w", err)
|
||||
return nil, "", apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Create user profile
|
||||
@@ -152,7 +155,7 @@ func (s *AuthService) Register(req *requests.RegisterRequest) (*responses.Regist
|
||||
// Create auth token
|
||||
token, err := s.userRepo.GetOrCreateToken(user.ID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create token: %w", err)
|
||||
return nil, "", apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Generate confirmation code - use fixed code in debug mode for easier local testing
|
||||
@@ -203,10 +206,10 @@ func (s *AuthService) UpdateProfile(userID uint, req *requests.UpdateProfileRequ
|
||||
if req.Email != nil && *req.Email != user.Email {
|
||||
exists, err := s.userRepo.ExistsByEmail(*req.Email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check email: %w", err)
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
if exists {
|
||||
return nil, ErrEmailTaken
|
||||
return nil, apperrors.Conflict("error.email_already_taken")
|
||||
}
|
||||
user.Email = *req.Email
|
||||
}
|
||||
@@ -219,7 +222,7 @@ func (s *AuthService) UpdateProfile(userID uint, req *requests.UpdateProfileRequ
|
||||
}
|
||||
|
||||
if err := s.userRepo.Update(user); err != nil {
|
||||
return nil, fmt.Errorf("failed to update user: %w", err)
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Reload with profile
|
||||
@@ -237,18 +240,18 @@ func (s *AuthService) VerifyEmail(userID uint, code string) error {
|
||||
// Get user profile
|
||||
profile, err := s.userRepo.GetOrCreateProfile(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get profile: %w", err)
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Check if already verified
|
||||
if profile.Verified {
|
||||
return ErrAlreadyVerified
|
||||
return apperrors.BadRequest("error.email_already_verified")
|
||||
}
|
||||
|
||||
// Check for test code in debug mode
|
||||
if s.cfg.Server.Debug && code == "123456" {
|
||||
if err := s.userRepo.SetProfileVerified(userID, true); err != nil {
|
||||
return fmt.Errorf("failed to verify profile: %w", err)
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -257,22 +260,22 @@ func (s *AuthService) VerifyEmail(userID uint, code string) error {
|
||||
confirmCode, err := s.userRepo.FindConfirmationCode(userID, code)
|
||||
if err != nil {
|
||||
if errors.Is(err, repositories.ErrCodeNotFound) {
|
||||
return ErrInvalidCode
|
||||
return apperrors.BadRequest("error.invalid_verification_code")
|
||||
}
|
||||
if errors.Is(err, repositories.ErrCodeExpired) {
|
||||
return ErrCodeExpired
|
||||
return apperrors.BadRequest("error.verification_code_expired")
|
||||
}
|
||||
return err
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Mark code as used
|
||||
if err := s.userRepo.MarkConfirmationCodeUsed(confirmCode.ID); err != nil {
|
||||
return fmt.Errorf("failed to mark code as used: %w", err)
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Set profile as verified
|
||||
if err := s.userRepo.SetProfileVerified(userID, true); err != nil {
|
||||
return fmt.Errorf("failed to verify profile: %w", err)
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -283,12 +286,12 @@ func (s *AuthService) ResendVerificationCode(userID uint) (string, error) {
|
||||
// Get user profile
|
||||
profile, err := s.userRepo.GetOrCreateProfile(userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get profile: %w", err)
|
||||
return "", apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Check if already verified
|
||||
if profile.Verified {
|
||||
return "", ErrAlreadyVerified
|
||||
return "", apperrors.BadRequest("error.email_already_verified")
|
||||
}
|
||||
|
||||
// Generate new code - use fixed code in debug mode for easier local testing
|
||||
@@ -301,7 +304,7 @@ func (s *AuthService) ResendVerificationCode(userID uint) (string, error) {
|
||||
expiresAt := time.Now().UTC().Add(s.cfg.Security.ConfirmationExpiry)
|
||||
|
||||
if _, err := s.userRepo.CreateConfirmationCode(userID, code, expiresAt); err != nil {
|
||||
return "", fmt.Errorf("failed to create confirmation code: %w", err)
|
||||
return "", apperrors.Internal(err)
|
||||
}
|
||||
|
||||
return code, nil
|
||||
@@ -322,10 +325,10 @@ func (s *AuthService) ForgotPassword(email string) (string, *models.User, error)
|
||||
// Check rate limit
|
||||
count, err := s.userRepo.CountRecentPasswordResetRequests(user.ID)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to check rate limit: %w", err)
|
||||
return "", nil, apperrors.Internal(err)
|
||||
}
|
||||
if count >= int64(s.cfg.Security.MaxPasswordResetRate) {
|
||||
return "", nil, ErrRateLimitExceeded
|
||||
return "", nil, apperrors.TooManyRequests("error.rate_limit_exceeded")
|
||||
}
|
||||
|
||||
// Generate code and reset token - use fixed code in debug mode for easier local testing
|
||||
@@ -341,11 +344,11 @@ func (s *AuthService) ForgotPassword(email string) (string, *models.User, error)
|
||||
// Hash the code before storing
|
||||
codeHash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to hash code: %w", err)
|
||||
return "", nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
if _, err := s.userRepo.CreatePasswordResetCode(user.ID, string(codeHash), resetToken, expiresAt); err != nil {
|
||||
return "", nil, fmt.Errorf("failed to create reset code: %w", err)
|
||||
return "", nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
return code, user, nil
|
||||
@@ -357,9 +360,9 @@ func (s *AuthService) VerifyResetCode(email, code string) (string, error) {
|
||||
resetCode, user, err := s.userRepo.FindPasswordResetCodeByEmail(email)
|
||||
if err != nil {
|
||||
if errors.Is(err, repositories.ErrUserNotFound) || errors.Is(err, repositories.ErrCodeNotFound) {
|
||||
return "", ErrInvalidCode
|
||||
return "", apperrors.BadRequest("error.invalid_verification_code")
|
||||
}
|
||||
return "", err
|
||||
return "", apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Check for test code in debug mode
|
||||
@@ -371,18 +374,18 @@ func (s *AuthService) VerifyResetCode(email, code string) (string, error) {
|
||||
if !resetCode.CheckCode(code) {
|
||||
// Increment attempts
|
||||
s.userRepo.IncrementResetCodeAttempts(resetCode.ID)
|
||||
return "", ErrInvalidCode
|
||||
return "", apperrors.BadRequest("error.invalid_verification_code")
|
||||
}
|
||||
|
||||
// Check if code is still valid
|
||||
if !resetCode.IsValid() {
|
||||
if resetCode.Used {
|
||||
return "", ErrInvalidCode
|
||||
return "", apperrors.BadRequest("error.invalid_verification_code")
|
||||
}
|
||||
if resetCode.Attempts >= resetCode.MaxAttempts {
|
||||
return "", ErrRateLimitExceeded
|
||||
return "", apperrors.TooManyRequests("error.rate_limit_exceeded")
|
||||
}
|
||||
return "", ErrCodeExpired
|
||||
return "", apperrors.BadRequest("error.verification_code_expired")
|
||||
}
|
||||
|
||||
_ = user // user available if needed
|
||||
@@ -396,24 +399,24 @@ func (s *AuthService) ResetPassword(resetToken, newPassword string) error {
|
||||
resetCode, err := s.userRepo.FindPasswordResetCodeByToken(resetToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, repositories.ErrCodeNotFound) || errors.Is(err, repositories.ErrCodeExpired) {
|
||||
return ErrInvalidResetToken
|
||||
return apperrors.BadRequest("error.invalid_reset_token")
|
||||
}
|
||||
return err
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Get the user
|
||||
user, err := s.userRepo.FindByID(resetCode.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find user: %w", err)
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Update password
|
||||
if err := user.SetPassword(newPassword); err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
|
||||
if err := s.userRepo.Update(user); err != nil {
|
||||
return fmt.Errorf("failed to update user: %w", err)
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Mark reset code as used
|
||||
@@ -436,7 +439,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
|
||||
// 1. Verify the Apple JWT token
|
||||
claims, err := appleAuth.VerifyIdentityToken(ctx, req.IDToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrAppleSignInFailed, err)
|
||||
return nil, apperrors.Unauthorized("error.invalid_credentials").Wrap(err)
|
||||
}
|
||||
|
||||
// Use the subject from claims as the authoritative Apple ID
|
||||
@@ -451,17 +454,17 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
|
||||
// User already linked with this Apple ID - log them in
|
||||
user, err := s.userRepo.FindByIDWithProfile(existingAuth.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find user: %w", err)
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
return nil, ErrUserInactive
|
||||
return nil, apperrors.Unauthorized("error.account_inactive")
|
||||
}
|
||||
|
||||
// Get or create token
|
||||
token, err := s.userRepo.GetOrCreateToken(user.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token: %w", err)
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Update last login
|
||||
@@ -487,7 +490,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
|
||||
IsPrivateEmail: isPrivateRelayEmail(email) || claims.IsPrivateRelayEmail(),
|
||||
}
|
||||
if err := s.userRepo.CreateAppleSocialAuth(appleAuthRecord); err != nil {
|
||||
return nil, fmt.Errorf("failed to link Apple ID: %w", err)
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Mark as verified since Apple verified the email
|
||||
@@ -496,7 +499,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
|
||||
// Get or create token
|
||||
token, err := s.userRepo.GetOrCreateToken(existingUser.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token: %w", err)
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Update last login
|
||||
@@ -529,7 +532,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
|
||||
_ = user.SetPassword(randomPassword)
|
||||
|
||||
if err := s.userRepo.Create(user); err != nil {
|
||||
return nil, fmt.Errorf("failed to create user: %w", err)
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Create profile (already verified since Apple verified)
|
||||
@@ -554,13 +557,13 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
|
||||
IsPrivateEmail: isPrivateRelayEmail(email) || claims.IsPrivateRelayEmail(),
|
||||
}
|
||||
if err := s.userRepo.CreateAppleSocialAuth(appleAuthRecord); err != nil {
|
||||
return nil, fmt.Errorf("failed to create Apple auth: %w", err)
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Create token
|
||||
token, err := s.userRepo.GetOrCreateToken(user.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token: %w", err)
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Reload user with profile
|
||||
@@ -578,12 +581,12 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
|
||||
// 1. Verify the Google ID token
|
||||
tokenInfo, err := googleAuth.VerifyIDToken(ctx, req.IDToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrGoogleSignInFailed, err)
|
||||
return nil, apperrors.Unauthorized("error.invalid_credentials").Wrap(err)
|
||||
}
|
||||
|
||||
googleID := tokenInfo.Sub
|
||||
if googleID == "" {
|
||||
return nil, fmt.Errorf("%w: missing subject claim", ErrGoogleSignInFailed)
|
||||
return nil, apperrors.Unauthorized("error.invalid_credentials")
|
||||
}
|
||||
|
||||
// 2. Check if this Google ID is already linked to an account
|
||||
@@ -592,17 +595,17 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
|
||||
// User already linked with this Google ID - log them in
|
||||
user, err := s.userRepo.FindByIDWithProfile(existingAuth.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find user: %w", err)
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
return nil, ErrUserInactive
|
||||
return nil, apperrors.Unauthorized("error.account_inactive")
|
||||
}
|
||||
|
||||
// Get or create token
|
||||
token, err := s.userRepo.GetOrCreateToken(user.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token: %w", err)
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Update last login
|
||||
@@ -629,7 +632,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
|
||||
Picture: tokenInfo.Picture,
|
||||
}
|
||||
if err := s.userRepo.CreateGoogleSocialAuth(googleAuthRecord); err != nil {
|
||||
return nil, fmt.Errorf("failed to link Google ID: %w", err)
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Mark as verified since Google verified the email
|
||||
@@ -640,7 +643,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
|
||||
// Get or create token
|
||||
token, err := s.userRepo.GetOrCreateToken(existingUser.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token: %w", err)
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Update last login
|
||||
@@ -673,7 +676,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
|
||||
_ = user.SetPassword(randomPassword)
|
||||
|
||||
if err := s.userRepo.Create(user); err != nil {
|
||||
return nil, fmt.Errorf("failed to create user: %w", err)
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Create profile (already verified if Google verified email)
|
||||
@@ -699,13 +702,13 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
|
||||
Picture: tokenInfo.Picture,
|
||||
}
|
||||
if err := s.userRepo.CreateGoogleSocialAuth(googleAuthRecord); err != nil {
|
||||
return nil, fmt.Errorf("failed to create Google auth: %w", err)
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Create token
|
||||
token, err := s.userRepo.GetOrCreateToken(user.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token: %w", err)
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Reload user with profile
|
||||
|
||||
Reference in New Issue
Block a user