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:
Trey t
2025-12-16 13:52:08 -06:00
parent c51f1ce34a
commit 6dac34e373
98 changed files with 8209 additions and 4425 deletions

View File

@@ -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