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

@@ -4,14 +4,15 @@ import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/dto/requests"
"github.com/treytartt/casera-api/internal/dto/responses"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/services"
"github.com/treytartt/casera-api/internal/validator"
)
// AuthHandler handles authentication endpoints
@@ -43,65 +44,38 @@ func (h *AuthHandler) SetGoogleAuthService(googleAuth *services.GoogleAuthServic
}
// Login handles POST /api/auth/login/
func (h *AuthHandler) Login(c *gin.Context) {
func (h *AuthHandler) Login(c echo.Context) error {
var req requests.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
response, err := h.authService.Login(&req)
if err != nil {
status := http.StatusUnauthorized
message := i18n.LocalizedMessage(c, "error.invalid_credentials")
if errors.Is(err, services.ErrUserInactive) {
message = i18n.LocalizedMessage(c, "error.account_inactive")
}
log.Debug().Err(err).Str("identifier", req.Username).Msg("Login failed")
c.JSON(status, responses.ErrorResponse{Error: message})
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// Register handles POST /api/auth/register/
func (h *AuthHandler) Register(c *gin.Context) {
func (h *AuthHandler) Register(c echo.Context) error {
var req requests.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
response, confirmationCode, err := h.authService.Register(&req)
if err != nil {
status := http.StatusBadRequest
message := err.Error()
if errors.Is(err, services.ErrUsernameTaken) {
message = i18n.LocalizedMessage(c, "error.username_taken")
} else if errors.Is(err, services.ErrEmailTaken) {
message = i18n.LocalizedMessage(c, "error.email_taken")
} else {
status = http.StatusInternalServerError
message = i18n.LocalizedMessage(c, "error.registration_failed")
log.Error().Err(err).Msg("Registration failed")
}
c.JSON(status, responses.ErrorResponse{Error: message})
return
log.Debug().Err(err).Msg("Registration failed")
return err
}
// Send welcome email with confirmation code (async)
@@ -113,15 +87,14 @@ func (h *AuthHandler) Register(c *gin.Context) {
}()
}
c.JSON(http.StatusCreated, response)
return c.JSON(http.StatusCreated, response)
}
// Logout handles POST /api/auth/logout/
func (h *AuthHandler) Logout(c *gin.Context) {
func (h *AuthHandler) Logout(c echo.Context) error {
token := middleware.GetAuthToken(c)
if token == "" {
c.JSON(http.StatusUnauthorized, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.not_authenticated")})
return
return apperrors.Unauthorized("error.not_authenticated")
}
// Invalidate token in database
@@ -131,101 +104,73 @@ func (h *AuthHandler) Logout(c *gin.Context) {
// Invalidate token in cache
if h.cache != nil {
if err := h.cache.InvalidateAuthToken(c.Request.Context(), token); err != nil {
if err := h.cache.InvalidateAuthToken(c.Request().Context(), token); err != nil {
log.Warn().Err(err).Msg("Failed to invalidate token in cache")
}
}
c.JSON(http.StatusOK, responses.MessageResponse{Message: i18n.LocalizedMessage(c, "message.logged_out")})
return c.JSON(http.StatusOK, responses.MessageResponse{Message: "Logged out successfully"})
}
// CurrentUser handles GET /api/auth/me/
func (h *AuthHandler) CurrentUser(c *gin.Context) {
user := middleware.MustGetAuthUser(c)
if user == nil {
return
func (h *AuthHandler) CurrentUser(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
response, err := h.authService.GetCurrentUser(user.ID)
if err != nil {
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to get current user")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.failed_to_get_user")})
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// UpdateProfile handles PUT/PATCH /api/auth/profile/
func (h *AuthHandler) UpdateProfile(c *gin.Context) {
user := middleware.MustGetAuthUser(c)
if user == nil {
return
func (h *AuthHandler) UpdateProfile(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
var req requests.UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
response, err := h.authService.UpdateProfile(user.ID, &req)
if err != nil {
if errors.Is(err, services.ErrEmailTaken) {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.email_already_taken")})
return
}
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to update profile")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.failed_to_update_profile")})
return
log.Debug().Err(err).Uint("user_id", user.ID).Msg("Failed to update profile")
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// VerifyEmail handles POST /api/auth/verify-email/
func (h *AuthHandler) VerifyEmail(c *gin.Context) {
user := middleware.MustGetAuthUser(c)
if user == nil {
return
func (h *AuthHandler) VerifyEmail(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
var req requests.VerifyEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
err := h.authService.VerifyEmail(user.ID, req.Code)
err = h.authService.VerifyEmail(user.ID, req.Code)
if err != nil {
status := http.StatusBadRequest
message := err.Error()
if errors.Is(err, services.ErrInvalidCode) {
message = i18n.LocalizedMessage(c, "error.invalid_verification_code")
} else if errors.Is(err, services.ErrCodeExpired) {
message = i18n.LocalizedMessage(c, "error.verification_code_expired")
} else if errors.Is(err, services.ErrAlreadyVerified) {
message = i18n.LocalizedMessage(c, "error.email_already_verified")
} else {
status = http.StatusInternalServerError
message = i18n.LocalizedMessage(c, "error.verification_failed")
log.Error().Err(err).Uint("user_id", user.ID).Msg("Email verification failed")
}
c.JSON(status, responses.ErrorResponse{Error: message})
return
log.Debug().Err(err).Uint("user_id", user.ID).Msg("Email verification failed")
return err
}
// Send post-verification welcome email with tips (async)
@@ -237,29 +182,23 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) {
}()
}
c.JSON(http.StatusOK, responses.VerifyEmailResponse{
Message: i18n.LocalizedMessage(c, "message.email_verified"),
return c.JSON(http.StatusOK, responses.VerifyEmailResponse{
Message: "Email verified successfully",
Verified: true,
})
}
// ResendVerification handles POST /api/auth/resend-verification/
func (h *AuthHandler) ResendVerification(c *gin.Context) {
user := middleware.MustGetAuthUser(c)
if user == nil {
return
func (h *AuthHandler) ResendVerification(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
code, err := h.authService.ResendVerificationCode(user.ID)
if err != nil {
if errors.Is(err, services.ErrAlreadyVerified) {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.email_already_verified")})
return
}
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to resend verification")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.failed_to_resend_verification")})
return
log.Debug().Err(err).Uint("user_id", user.ID).Msg("Failed to resend verification")
return err
}
// Send verification email (async)
@@ -271,33 +210,29 @@ func (h *AuthHandler) ResendVerification(c *gin.Context) {
}()
}
c.JSON(http.StatusOK, responses.MessageResponse{Message: i18n.LocalizedMessage(c, "message.verification_email_sent")})
return c.JSON(http.StatusOK, responses.MessageResponse{Message: "Verification email sent"})
}
// ForgotPassword handles POST /api/auth/forgot-password/
func (h *AuthHandler) ForgotPassword(c *gin.Context) {
func (h *AuthHandler) ForgotPassword(c echo.Context) error {
var req requests.ForgotPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
code, user, err := h.authService.ForgotPassword(req.Email)
if err != nil {
if errors.Is(err, services.ErrRateLimitExceeded) {
c.JSON(http.StatusTooManyRequests, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.rate_limit_exceeded"),
})
return
var appErr *apperrors.AppError
if errors.As(err, &appErr) && appErr.Code == http.StatusTooManyRequests {
// Only reveal rate limit errors
return err
}
log.Error().Err(err).Str("email", req.Email).Msg("Forgot password failed")
// Don't reveal errors to prevent email enumeration
// Don't reveal other errors to prevent email enumeration
}
// Send password reset email (async) - only if user found
@@ -310,116 +245,82 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) {
}
// Always return success to prevent email enumeration
c.JSON(http.StatusOK, responses.ForgotPasswordResponse{
Message: i18n.LocalizedMessage(c, "message.password_reset_email_sent"),
return c.JSON(http.StatusOK, responses.ForgotPasswordResponse{
Message: "Password reset email sent",
})
}
// VerifyResetCode handles POST /api/auth/verify-reset-code/
func (h *AuthHandler) VerifyResetCode(c *gin.Context) {
func (h *AuthHandler) VerifyResetCode(c echo.Context) error {
var req requests.VerifyResetCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
resetToken, err := h.authService.VerifyResetCode(req.Email, req.Code)
if err != nil {
status := http.StatusBadRequest
message := i18n.LocalizedMessage(c, "error.invalid_verification_code")
if errors.Is(err, services.ErrCodeExpired) {
message = i18n.LocalizedMessage(c, "error.verification_code_expired")
} else if errors.Is(err, services.ErrRateLimitExceeded) {
status = http.StatusTooManyRequests
message = i18n.LocalizedMessage(c, "error.too_many_attempts")
}
c.JSON(status, responses.ErrorResponse{Error: message})
return
log.Debug().Err(err).Str("email", req.Email).Msg("Verify reset code failed")
return err
}
c.JSON(http.StatusOK, responses.VerifyResetCodeResponse{
Message: i18n.LocalizedMessage(c, "message.reset_code_verified"),
return c.JSON(http.StatusOK, responses.VerifyResetCodeResponse{
Message: "Reset code verified",
ResetToken: resetToken,
})
}
// ResetPassword handles POST /api/auth/reset-password/
func (h *AuthHandler) ResetPassword(c *gin.Context) {
func (h *AuthHandler) ResetPassword(c echo.Context) error {
var req requests.ResetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
err := h.authService.ResetPassword(req.ResetToken, req.NewPassword)
if err != nil {
status := http.StatusBadRequest
message := i18n.LocalizedMessage(c, "error.invalid_reset_token")
if errors.Is(err, services.ErrInvalidResetToken) {
message = i18n.LocalizedMessage(c, "error.invalid_reset_token")
} else {
status = http.StatusInternalServerError
message = i18n.LocalizedMessage(c, "error.password_reset_failed")
log.Error().Err(err).Msg("Password reset failed")
}
c.JSON(status, responses.ErrorResponse{Error: message})
return
log.Debug().Err(err).Msg("Password reset failed")
return err
}
c.JSON(http.StatusOK, responses.ResetPasswordResponse{
Message: i18n.LocalizedMessage(c, "message.password_reset_success"),
return c.JSON(http.StatusOK, responses.ResetPasswordResponse{
Message: "Password reset successful",
})
}
// AppleSignIn handles POST /api/auth/apple-sign-in/
func (h *AuthHandler) AppleSignIn(c *gin.Context) {
func (h *AuthHandler) AppleSignIn(c echo.Context) error {
var req requests.AppleSignInRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
if h.appleAuthService == nil {
log.Error().Msg("Apple auth service not configured")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.apple_signin_not_configured"),
})
return
return &apperrors.AppError{
Code: 500,
MessageKey: "error.apple_signin_not_configured",
}
}
response, err := h.authService.AppleSignIn(c.Request.Context(), h.appleAuthService, &req)
response, err := h.authService.AppleSignIn(c.Request().Context(), h.appleAuthService, &req)
if err != nil {
status := http.StatusUnauthorized
message := i18n.LocalizedMessage(c, "error.apple_signin_failed")
if errors.Is(err, services.ErrUserInactive) {
message = i18n.LocalizedMessage(c, "error.account_inactive")
} else if errors.Is(err, services.ErrAppleSignInFailed) {
message = i18n.LocalizedMessage(c, "error.invalid_apple_token")
// Check for legacy Apple Sign In error (not yet migrated)
if errors.Is(err, services.ErrAppleSignInFailed) {
log.Debug().Err(err).Msg("Apple Sign In failed (legacy error)")
return apperrors.Unauthorized("error.invalid_apple_token")
}
log.Debug().Err(err).Msg("Apple Sign In failed")
c.JSON(status, responses.ErrorResponse{Error: message})
return
return err
}
// Send welcome email for new users (async)
@@ -431,44 +332,37 @@ func (h *AuthHandler) AppleSignIn(c *gin.Context) {
}()
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GoogleSignIn handles POST /api/auth/google-sign-in/
func (h *AuthHandler) GoogleSignIn(c *gin.Context) {
func (h *AuthHandler) GoogleSignIn(c echo.Context) error {
var req requests.GoogleSignInRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
if h.googleAuthService == nil {
log.Error().Msg("Google auth service not configured")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.google_signin_not_configured"),
})
return
return &apperrors.AppError{
Code: 500,
MessageKey: "error.google_signin_not_configured",
}
}
response, err := h.authService.GoogleSignIn(c.Request.Context(), h.googleAuthService, &req)
response, err := h.authService.GoogleSignIn(c.Request().Context(), h.googleAuthService, &req)
if err != nil {
status := http.StatusUnauthorized
message := i18n.LocalizedMessage(c, "error.google_signin_failed")
if errors.Is(err, services.ErrUserInactive) {
message = i18n.LocalizedMessage(c, "error.account_inactive")
} else if errors.Is(err, services.ErrGoogleSignInFailed) {
message = i18n.LocalizedMessage(c, "error.invalid_google_token")
// Check for legacy Google Sign In error (not yet migrated)
if errors.Is(err, services.ErrGoogleSignInFailed) {
log.Debug().Err(err).Msg("Google Sign In failed (legacy error)")
return apperrors.Unauthorized("error.invalid_google_token")
}
log.Debug().Err(err).Msg("Google Sign In failed")
c.JSON(status, responses.ErrorResponse{Error: message})
return
return err
}
// Send welcome email for new users (async)
@@ -480,5 +374,5 @@ func (h *AuthHandler) GoogleSignIn(c *gin.Context) {
}()
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}