diff --git a/veza-backend-api/go.mod b/veza-backend-api/go.mod index c4417ae8f..e6291a958 100644 --- a/veza-backend-api/go.mod +++ b/veza-backend-api/go.mod @@ -149,6 +149,7 @@ require ( go.opentelemetry.io/otel v1.24.0 // indirect go.opentelemetry.io/otel/metric v1.24.0 // indirect go.opentelemetry.io/otel/trace v1.24.0 // indirect + go.uber.org/goleak v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.20.0 // indirect diff --git a/veza-backend-api/internal/config/config.go b/veza-backend-api/internal/config/config.go index f7eaa27d5..cdea1d582 100644 --- a/veza-backend-api/internal/config/config.go +++ b/veza-backend-api/internal/config/config.go @@ -876,6 +876,11 @@ func (c *Config) ValidateForEnvironment() error { return fmt.Errorf("OAUTH_ENCRYPTION_KEY is required in production (min 32 bytes for AES-256). Set OAUTH_ENCRYPTION_KEY with a 32-byte hex or base64 key") } + // 8. TASK-DEBT-010: JWT_ISSUER and JWT_AUDIENCE must be set for consistent token emission/validation + if c.JWTIssuer == "" || c.JWTAudience == "" { + return fmt.Errorf("JWT_ISSUER and JWT_AUDIENCE must be set in production for consistent JWT validation. Set JWT_ISSUER and JWT_AUDIENCE environment variables") + } + case EnvTest: // TEST: Validation adaptée aux tests // CORS peut être vide ou configuré explicitement diff --git a/veza-backend-api/internal/errors/codes.go b/veza-backend-api/internal/errors/codes.go index f1e16b57d..e949e7a3b 100644 --- a/veza-backend-api/internal/errors/codes.go +++ b/veza-backend-api/internal/errors/codes.go @@ -18,6 +18,7 @@ const ( ErrCodeNotFound ErrorCode = 3000 ErrCodeAlreadyExists ErrorCode = 3001 ErrCodeConflict ErrorCode = 3002 + ErrCodeLocked ErrorCode = 3004 // Business Logic (4000-4999) ErrCodeOperationNotAllowed ErrorCode = 4000 @@ -26,6 +27,9 @@ const ( // Rate Limiting (5000-5099) ErrCodeRateLimitExceeded ErrorCode = 5000 + // External Services (6000-6999) + ErrCodeServiceUnavailable ErrorCode = 6000 + // Internal (9000-9999) ErrCodeInternal ErrorCode = 9000 ErrCodeDatabase ErrorCode = 9001 diff --git a/veza-backend-api/internal/errors/errors.go b/veza-backend-api/internal/errors/errors.go index 7eca3870d..b72b0ed26 100644 --- a/veza-backend-api/internal/errors/errors.go +++ b/veza-backend-api/internal/errors/errors.go @@ -75,3 +75,18 @@ func NewForbiddenError(message string) *AppError { Message: message, } } + +// NewInternalError crée une nouvelle erreur interne +func NewInternalError(message string) *AppError { + return &AppError{Code: ErrCodeInternal, Message: message} +} + +// NewInternalErrorWrap enveloppe une erreur dans une erreur interne +func NewInternalErrorWrap(message string, err error) *AppError { + return &AppError{Code: ErrCodeInternal, Message: message, Err: err} +} + +// NewServiceUnavailableError crée une erreur 503 +func NewServiceUnavailableError(message string) *AppError { + return &AppError{Code: ErrCodeServiceUnavailable, Message: message} +} diff --git a/veza-backend-api/internal/handlers/chat_reaction_handler.go b/veza-backend-api/internal/handlers/chat_reaction_handler.go index 5894604cb..7a9ff2b93 100644 --- a/veza-backend-api/internal/handlers/chat_reaction_handler.go +++ b/veza-backend-api/internal/handlers/chat_reaction_handler.go @@ -5,6 +5,7 @@ import ( "veza-backend-api/internal/models" "veza-backend-api/internal/repositories" + apperrors "veza-backend-api/internal/errors" chatws "veza-backend-api/internal/websocket/chat" @@ -54,33 +55,33 @@ func (h *ChatReactionHandler) AddReaction(c *gin.Context) { roomID, err := uuid.Parse(c.Param("roomId")) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid room ID"}) + RespondWithAppError(c, apperrors.NewValidationError("Invalid room ID")) return } messageID, err := uuid.Parse(c.Param("messageId")) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message ID"}) + RespondWithAppError(c, apperrors.NewValidationError("Invalid message ID")) return } if !h.permissions.CanRead(c.Request.Context(), userID, roomID) { - c.JSON(http.StatusForbidden, gin.H{"error": "Not allowed to access this conversation"}) + RespondWithAppError(c, apperrors.NewForbiddenError("Not allowed to access this conversation")) return } var req AddReactionRequest if err := c.ShouldBindJSON(&req); err != nil || req.Emoji == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "emoji is required"}) + RespondWithAppError(c, apperrors.NewValidationError("emoji is required")) return } msg, err := h.msgRepo.GetByID(c.Request.Context(), messageID) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) + RespondWithAppError(c, apperrors.NewNotFoundError("Message")) return } if msg.ConversationID != roomID { - c.JSON(http.StatusBadRequest, gin.H{"error": "Message does not belong to this room"}) + RespondWithAppError(c, apperrors.NewValidationError("Message does not belong to this room")) return } @@ -92,7 +93,7 @@ func (h *ChatReactionHandler) AddReaction(c *gin.Context) { } if err := h.reactionRepo.Add(c.Request.Context(), reaction); err != nil { h.logger.Error("Failed to add reaction", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add reaction"}) + RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to add reaction", err)) return } @@ -116,27 +117,27 @@ func (h *ChatReactionHandler) RemoveReaction(c *gin.Context) { roomID, err := uuid.Parse(c.Param("roomId")) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid room ID"}) + RespondWithAppError(c, apperrors.NewValidationError("Invalid room ID")) return } messageID, err := uuid.Parse(c.Param("messageId")) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message ID"}) + RespondWithAppError(c, apperrors.NewValidationError("Invalid message ID")) return } if !h.permissions.CanRead(c.Request.Context(), userID, roomID) { - c.JSON(http.StatusForbidden, gin.H{"error": "Not allowed to access this conversation"}) + RespondWithAppError(c, apperrors.NewForbiddenError("Not allowed to access this conversation")) return } msg, err := h.msgRepo.GetByID(c.Request.Context(), messageID) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) + RespondWithAppError(c, apperrors.NewNotFoundError("Message")) return } if msg.ConversationID != roomID { - c.JSON(http.StatusBadRequest, gin.H{"error": "Message does not belong to this room"}) + RespondWithAppError(c, apperrors.NewValidationError("Message does not belong to this room")) return } @@ -148,7 +149,7 @@ func (h *ChatReactionHandler) RemoveReaction(c *gin.Context) { } if err != nil { h.logger.Error("Failed to remove reaction", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove reaction"}) + RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to remove reaction", err)) return } diff --git a/veza-backend-api/internal/handlers/chat_websocket_handler.go b/veza-backend-api/internal/handlers/chat_websocket_handler.go index 61802248b..7f5e02c70 100644 --- a/veza-backend-api/internal/handlers/chat_websocket_handler.go +++ b/veza-backend-api/internal/handlers/chat_websocket_handler.go @@ -1,13 +1,11 @@ package handlers import ( - "context" - "net/http" - "github.com/coder/websocket" "github.com/gin-gonic/gin" "go.uber.org/zap" + apperrors "veza-backend-api/internal/errors" "veza-backend-api/internal/services" chatws "veza-backend-api/internal/websocket/chat" ) @@ -34,7 +32,7 @@ func NewChatWebSocketHandler(chatService *services.ChatService, hub *chatws.Hub, func (h *ChatWebSocketHandler) HandleWebSocket(c *gin.Context) { tokenString := c.Query("token") if tokenString == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) + RespondWithAppError(c, apperrors.NewUnauthorizedError("missing token")) return } @@ -42,7 +40,7 @@ func (h *ChatWebSocketHandler) HandleWebSocket(c *gin.Context) { if err != nil { h.logger.Warn("Invalid chat token", zap.Error(err)) - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"}) + RespondWithAppError(c, apperrors.NewUnauthorizedError("invalid or expired token")) return } @@ -62,7 +60,7 @@ func (h *ChatWebSocketHandler) HandleWebSocket(c *gin.Context) { client.SendJSON(chatws.NewActionConfirmedResponse("connected", true)) - ctx := context.Background() + ctx := c.Request.Context() go client.WritePump(ctx) go client.ReadPump(ctx) } diff --git a/veza-backend-api/internal/handlers/error_response.go b/veza-backend-api/internal/handlers/error_response.go index 08405ae0f..8a44fc56e 100644 --- a/veza-backend-api/internal/handlers/error_response.go +++ b/veza-backend-api/internal/handlers/error_response.go @@ -1,137 +1,21 @@ package handlers import ( - "net/http" - "time" - - "veza-backend-api/internal/errors" + apperrors "veza-backend-api/internal/errors" + "veza-backend-api/internal/response" "github.com/gin-gonic/gin" ) -// ErrorResponse représente le format d'erreur standardisé selon ORIGIN_API_SPECIFICATION -// GO-014: Harmonisation format erreurs HTTP -type ErrorResponse struct { - Error struct { - Code int `json:"code"` - Message string `json:"message"` - Details []errors.ErrorDetail `json:"details,omitempty"` - RequestID string `json:"request_id,omitempty"` - Timestamp string `json:"timestamp"` - Context map[string]interface{} `json:"context,omitempty"` - } `json:"error"` -} - // RespondWithAppError répond avec une AppError au format standardisé ORIGIN_API_SPECIFICATION -// GO-014: Harmonisation format erreurs HTTP selon ORIGIN_API_SPECIFICATION -func RespondWithAppError(c *gin.Context, appErr *errors.AppError) { - statusCode := mapErrorCodeToHTTPStatus(appErr.Code) - - errorData := struct { - Code int `json:"code"` - Message string `json:"message"` - Details []errors.ErrorDetail `json:"details,omitempty"` - RequestID string `json:"request_id,omitempty"` - Timestamp string `json:"timestamp"` - Context map[string]interface{} `json:"context,omitempty"` - }{ - Code: int(appErr.Code), - Message: appErr.Message, - Details: appErr.Details, - RequestID: c.GetString("request_id"), - Timestamp: time.Now().UTC().Format(time.RFC3339), - Context: appErr.Context, - } - - c.JSON(statusCode, APIResponse{ - Success: false, - Data: nil, - Error: errorData, - }) +// Délègue au package response pour éviter duplication +func RespondWithAppError(c *gin.Context, appErr *apperrors.AppError) { + response.RespondWithAppError(c, appErr) } // RespondWithError répond avec un code d'erreur et un message au format standardisé -// GO-014: Harmonisation format erreurs HTTP selon ORIGIN_API_SPECIFICATION -func RespondWithError(c *gin.Context, code int, message string, details ...errors.ErrorDetail) { - statusCode := mapErrorCodeToHTTPStatus(errors.ErrorCode(code)) - - errorData := struct { - Code int `json:"code"` - Message string `json:"message"` - Details []errors.ErrorDetail `json:"details,omitempty"` - RequestID string `json:"request_id,omitempty"` - Timestamp string `json:"timestamp"` - }{ - Code: code, - Message: message, - Details: details, - RequestID: c.GetString("request_id"), - Timestamp: time.Now().UTC().Format(time.RFC3339), - } - - c.JSON(statusCode, APIResponse{ - Success: false, - Data: nil, - Error: errorData, - }) -} - -// mapErrorCodeToHTTPStatus mappe les codes d'erreur ORIGIN vers les codes HTTP -// GO-014: Harmonisation format erreurs HTTP selon ORIGIN_API_SPECIFICATION -func mapErrorCodeToHTTPStatus(code errors.ErrorCode) int { - // Authentication & Authorization (1000-1999) - if code >= 1000 && code < 2000 { - switch code { - case 1000, 1001, 1002, 1004, 1007, 1008: // Invalid credentials, token expired/invalid, 2FA - return http.StatusUnauthorized - case 1003, 1005, 1006: // Insufficient permissions, account issues - return http.StatusForbidden - default: - return http.StatusUnauthorized - } - } - - // Validation Errors (2000-2999) - if code >= 2000 && code < 3000 { - return http.StatusBadRequest - } - - // Resource Errors (3000-3999) - if code >= 3000 && code < 4000 { - switch code { - case 3000, 3003: // Not found, deleted - return http.StatusNotFound - case 3001, 3002: // Already exists, conflict - return http.StatusConflict - case 3004: // Locked - return http.StatusLocked - case 3005: // Quota exceeded - return http.StatusForbidden - default: - return http.StatusNotFound - } - } - - // Business Logic Errors (4000-4999) - if code >= 4000 && code < 5000 { - return http.StatusUnprocessableEntity - } - - // Rate Limiting (5000-5099) - if code >= 5000 && code < 5100 { - return http.StatusTooManyRequests - } - - // External Services (6000-6999) - if code >= 6000 && code < 7000 { - return http.StatusBadGateway - } - - // Internal Errors (9000-9999) - if code >= 9000 && code < 10000 { - return http.StatusInternalServerError - } - - // Default - return http.StatusInternalServerError +func RespondWithError(c *gin.Context, code int, message string, details ...apperrors.ErrorDetail) { + appErr := apperrors.New(apperrors.ErrorCode(code), message) + appErr.Details = details + response.RespondWithAppError(c, appErr) } diff --git a/veza-backend-api/internal/handlers/marketplace_handler.go b/veza-backend-api/internal/handlers/marketplace_handler.go index 2c47eaec2..2139e362c 100644 --- a/veza-backend-api/internal/handlers/marketplace_handler.go +++ b/veza-backend-api/internal/handlers/marketplace_handler.go @@ -6,6 +6,7 @@ import ( "strings" "veza-backend-api/internal/core/marketplace" + apperrors "veza-backend-api/internal/errors" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -37,7 +38,7 @@ func (h *MarketplaceExtHandler) GetWishlist(c *gin.Context) { items, err := h.service.GetWishlist(c.Request.Context(), userID) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get wishlist"}) + RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get wishlist", err)) return } @@ -64,17 +65,17 @@ func (h *MarketplaceExtHandler) AddToWishlist(c *gin.Context) { productID, err := uuid.Parse(req.ProductID) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid product ID"}) + RespondWithAppError(c, apperrors.NewValidationError("Invalid product ID")) return } item, err := h.service.AddToWishlist(c.Request.Context(), userID, productID) if err != nil { if errors.Is(err, marketplace.ErrProductNotFound) { - c.JSON(http.StatusNotFound, gin.H{"error": "Product not found"}) + RespondWithAppError(c, apperrors.NewNotFoundError("Product")) return } - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add to wishlist"}) + RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to add to wishlist", err)) return } @@ -90,12 +91,12 @@ func (h *MarketplaceExtHandler) RemoveFromWishlist(c *gin.Context) { productID, err := uuid.Parse(c.Param("productId")) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid product ID"}) + RespondWithAppError(c, apperrors.NewValidationError("Invalid product ID")) return } if err := h.service.RemoveFromWishlist(c.Request.Context(), userID, productID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove from wishlist"}) + RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to remove from wishlist", err)) return } @@ -113,7 +114,7 @@ func (h *MarketplaceExtHandler) GetCart(c *gin.Context) { items, err := h.service.GetCart(c.Request.Context(), userID) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cart"}) + RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get cart", err)) return } @@ -141,7 +142,7 @@ func (h *MarketplaceExtHandler) AddToCart(c *gin.Context) { productID, err := uuid.Parse(req.ProductID) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid product ID"}) + RespondWithAppError(c, apperrors.NewValidationError("Invalid product ID")) return } @@ -153,10 +154,10 @@ func (h *MarketplaceExtHandler) AddToCart(c *gin.Context) { item, err := h.service.AddToCart(c.Request.Context(), userID, productID, quantity) if err != nil { if errors.Is(err, marketplace.ErrProductNotFound) { - c.JSON(http.StatusNotFound, gin.H{"error": "Product not found"}) + RespondWithAppError(c, apperrors.NewNotFoundError("Product")) return } - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add to cart"}) + RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to add to cart", err)) return } @@ -172,12 +173,12 @@ func (h *MarketplaceExtHandler) RemoveFromCart(c *gin.Context) { itemID, err := uuid.Parse(c.Param("id")) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid item ID"}) + RespondWithAppError(c, apperrors.NewValidationError("Invalid item ID")) return } if err := h.service.RemoveFromCart(c.Request.Context(), userID, itemID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove from cart"}) + RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to remove from cart", err)) return } @@ -188,17 +189,17 @@ func (h *MarketplaceExtHandler) RemoveFromCart(c *gin.Context) { func (h *MarketplaceExtHandler) ValidatePromo(c *gin.Context) { code := strings.TrimSpace(c.Param("code")) if code == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Promo code is required"}) + RespondWithAppError(c, apperrors.NewValidationError("Promo code is required")) return } discount, err := h.service.ValidatePromoCode(c.Request.Context(), code) if err != nil { if errors.Is(err, marketplace.ErrPromoCodeInvalid) { - c.JSON(http.StatusNotFound, gin.H{"error": "Invalid or expired promo code"}) + RespondWithAppError(c, apperrors.NewNotFoundError("Invalid or expired promo code")) return } - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate promo code"}) + RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to validate promo code", err)) return } @@ -228,10 +229,10 @@ func (h *MarketplaceExtHandler) Checkout(c *gin.Context) { resp, err := h.service.Checkout(c.Request.Context(), userID, promoCode) if err != nil { if errors.Is(err, marketplace.ErrPromoCodeInvalid) { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired promo code"}) + RespondWithAppError(c, apperrors.NewValidationError("Invalid or expired promo code")) return } - c.JSON(http.StatusInternalServerError, gin.H{"error": "Checkout failed: " + err.Error()}) + RespondWithAppError(c, apperrors.NewInternalErrorWrap("Checkout failed", err)) return } diff --git a/veza-backend-api/internal/handlers/profile_handler.go b/veza-backend-api/internal/handlers/profile_handler.go index 8993e7f05..dd0e2b6de 100644 --- a/veza-backend-api/internal/handlers/profile_handler.go +++ b/veza-backend-api/internal/handlers/profile_handler.go @@ -339,7 +339,7 @@ func (h *ProfileHandler) FollowUser(c *gin.Context) { } // Suivre l'utilisateur - err = h.socialService.FollowUser(followerID, userID) + err = h.socialService.FollowUser(c.Request.Context(), followerID, userID) if err != nil { h.logger.Error("failed to follow user", zap.Error(err), @@ -389,7 +389,7 @@ func (h *ProfileHandler) UnfollowUser(c *gin.Context) { } // Ne plus suivre l'utilisateur - err = h.socialService.UnfollowUser(followerID, userID) + err = h.socialService.UnfollowUser(c.Request.Context(), followerID, userID) if err != nil { h.logger.Error("failed to unfollow user", zap.Error(err), diff --git a/veza-backend-api/internal/handlers/search_handlers.go b/veza-backend-api/internal/handlers/search_handlers.go index b8a19d459..e2d641eaa 100644 --- a/veza-backend-api/internal/handlers/search_handlers.go +++ b/veza-backend-api/internal/handlers/search_handlers.go @@ -5,6 +5,7 @@ import ( "strconv" "veza-backend-api/internal/services" + apperrors "veza-backend-api/internal/errors" "github.com/gin-gonic/gin" ) @@ -39,7 +40,7 @@ func NewSearchHandlersWithInterface(searchService SearchServiceInterface) *Searc func (sh *SearchHandlers) Search(c *gin.Context) { query := c.Query("q") if query == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Search query is required"}) + RespondWithAppError(c, apperrors.NewValidationError("Search query is required")) return } @@ -47,7 +48,7 @@ func (sh *SearchHandlers) Search(c *gin.Context) { results, err := sh.searchService.Search(query, types) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + RespondWithAppError(c, apperrors.NewInternalErrorWrap("Search failed", err)) return } @@ -58,7 +59,7 @@ func (sh *SearchHandlers) Search(c *gin.Context) { func (sh *SearchHandlers) Suggestions(c *gin.Context) { query := c.Query("q") if query == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter 'q' is required"}) + RespondWithAppError(c, apperrors.NewValidationError("Query parameter 'q' is required")) return } limit := 5 @@ -69,7 +70,7 @@ func (sh *SearchHandlers) Suggestions(c *gin.Context) { } results, err := sh.searchService.Suggestions(query, limit) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + RespondWithAppError(c, apperrors.NewInternalErrorWrap("Suggestions failed", err)) return } RespondSuccess(c, http.StatusOK, results) diff --git a/veza-backend-api/internal/handlers/settings_handler.go b/veza-backend-api/internal/handlers/settings_handler.go index 964e98205..d3877c355 100644 --- a/veza-backend-api/internal/handlers/settings_handler.go +++ b/veza-backend-api/internal/handlers/settings_handler.go @@ -7,6 +7,7 @@ import ( "veza-backend-api/internal/services" "veza-backend-api/internal/types" + apperrors "veza-backend-api/internal/errors" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -91,13 +92,13 @@ func (h *SettingsHandler) GetSettings(c *gin.Context) { return // Erreur déjà envoyée par GetUserIDUUID } if userID == uuid.Nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + RespondWithAppError(c, apperrors.NewUnauthorizedError("user not authenticated")) return } settings, err := h.userService.GetUserSettings(userID) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get settings"}) + RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to get settings", err)) return } @@ -113,7 +114,7 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) { return // Erreur déjà envoyée par GetUserIDUUID } if userID == uuid.Nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + RespondWithAppError(c, apperrors.NewUnauthorizedError("user not authenticated")) return } @@ -126,14 +127,14 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) { // Valider preferences si fournies if req.Preferences != nil { if err := h.validatePreferences(req.Preferences); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + RespondWithAppError(c, apperrors.NewValidationError(err.Error())) return } } // Mettre à jour settings if err := h.userService.UpdateUserSettings(userID, &req); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update settings"}) + RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to update settings", err)) return } diff --git a/veza-backend-api/internal/handlers/social_group_handler.go b/veza-backend-api/internal/handlers/social_group_handler.go index c3690ef1c..6a63a7bd3 100644 --- a/veza-backend-api/internal/handlers/social_group_handler.go +++ b/veza-backend-api/internal/handlers/social_group_handler.go @@ -3,10 +3,11 @@ package handlers import ( "errors" "net/http" - "strconv" "veza-backend-api/internal/core/social" "veza-backend-api/internal/utils" + apperrors "veza-backend-api/internal/errors" + "veza-backend-api/internal/pagination" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -57,36 +58,31 @@ func (h *GroupHandler) CreateGroup(c *gin.Context) { group, err := h.service.CreateGroup(c.Request.Context(), userID, req.Name, req.Description, isPublic) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create group"}) + RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to create group", err)) return } RespondSuccess(c, http.StatusCreated, group) } -// ListGroups returns all public groups +// ListGroups returns all public groups with standard pagination ?page=1&limit=20 func (h *GroupHandler) ListGroups(c *gin.Context) { - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) - offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) - if limit < 1 { - limit = 20 - } - if limit > 100 { - limit = 100 - } - if offset < 0 { - offset = 0 - } - - groups, total, err := h.service.ListGroups(c.Request.Context(), limit, offset) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list groups"}) + params, appErr := pagination.ParseParams(c) + if appErr != nil { + RespondWithAppError(c, appErr) return } + groups, total, err := h.service.ListGroups(c.Request.Context(), params.Limit, params.Offset()) + if err != nil { + RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to list groups", err)) + return + } + + meta := pagination.BuildMeta(params.Page, params.Limit, total) RespondSuccess(c, http.StatusOK, gin.H{ - "groups": groups, - "total": total, + "groups": groups, + "pagination": meta, }) } @@ -94,17 +90,17 @@ func (h *GroupHandler) ListGroups(c *gin.Context) { func (h *GroupHandler) GetGroup(c *gin.Context) { groupID, err := uuid.Parse(c.Param("id")) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group ID"}) + RespondWithAppError(c, apperrors.NewValidationError("Invalid group ID")) return } group, err := h.service.GetGroup(c.Request.Context(), groupID) if err != nil { if errors.Is(err, social.ErrGroupNotFound) { - c.JSON(http.StatusNotFound, gin.H{"error": "Group not found"}) + RespondWithAppError(c, apperrors.NewNotFoundError("Group")) return } - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get group"}) + RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get group", err)) return } @@ -431,33 +427,28 @@ func (h *GroupHandler) UpdateMemberRole(c *gin.Context) { RespondSuccess(c, http.StatusOK, gin.H{"message": "Role updated"}) } -// ListMyGroups returns groups the user is a member of (S2.5) +// ListMyGroups returns groups the user is a member of (S2.5) with standard pagination ?page=1&limit=20 func (h *GroupHandler) ListMyGroups(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) - offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) - if limit < 1 { - limit = 20 - } - if limit > 100 { - limit = 100 - } - if offset < 0 { - offset = 0 - } - - groups, total, err := h.service.ListMyGroups(c.Request.Context(), userID, limit, offset) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list groups"}) + params, appErr := pagination.ParseParams(c) + if appErr != nil { + RespondWithAppError(c, appErr) return } + groups, total, err := h.service.ListMyGroups(c.Request.Context(), userID, params.Limit, params.Offset()) + if err != nil { + RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to list groups", err)) + return + } + + meta := pagination.BuildMeta(params.Page, params.Limit, total) RespondSuccess(c, http.StatusOK, gin.H{ - "groups": groups, - "total": total, + "groups": groups, + "pagination": meta, }) } diff --git a/veza-backend-api/internal/handlers/stream_events_handler.go b/veza-backend-api/internal/handlers/stream_events_handler.go index ce9d6dfbd..a51b5b63c 100644 --- a/veza-backend-api/internal/handlers/stream_events_handler.go +++ b/veza-backend-api/internal/handlers/stream_events_handler.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" "go.uber.org/zap" + apperrors "veza-backend-api/internal/errors" "veza-backend-api/internal/services" ) @@ -43,7 +44,7 @@ func (h *StreamEventsHandler) HandleStreamEvent(c *gin.Context) { var req StreamEventRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Warn("Invalid stream event payload", zap.Error(err)) - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + RespondWithAppError(c, apperrors.NewValidationError("invalid payload")) return } diff --git a/veza-backend-api/internal/middleware/csrf.go b/veza-backend-api/internal/middleware/csrf.go index 2f39dace9..1ea6ac27c 100644 --- a/veza-backend-api/internal/middleware/csrf.go +++ b/veza-backend-api/internal/middleware/csrf.go @@ -13,6 +13,9 @@ import ( "github.com/google/uuid" "github.com/redis/go-redis/v9" "go.uber.org/zap" + + apperrors "veza-backend-api/internal/errors" + "veza-backend-api/internal/response" ) // CSRFMiddleware crée un middleware pour la protection CSRF @@ -81,13 +84,7 @@ func (m *CSRFMiddleware) Middleware() gin.HandlerFunc { // Récupérer le token CSRF depuis le header token := c.GetHeader("X-CSRF-Token") if token == "" { - c.JSON(403, gin.H{ - "success": false, - "error": gin.H{ - "code": 403, - "message": "CSRF token required", - }, - }) + response.RespondWithAppError(c, apperrors.NewForbiddenError("CSRF token required")) c.Abort() return } @@ -102,13 +99,7 @@ func (m *CSRFMiddleware) Middleware() gin.HandlerFunc { zap.String("user_id", userID.String()), zap.String("ip", c.ClientIP()), ) - c.JSON(403, gin.H{ - "success": false, - "error": gin.H{ - "code": 403, - "message": "Invalid or expired CSRF token", - }, - }) + response.RespondWithAppError(c, apperrors.NewForbiddenError("Invalid or expired CSRF token")) c.Abort() return } @@ -116,13 +107,7 @@ func (m *CSRFMiddleware) Middleware() gin.HandlerFunc { zap.Error(err), zap.String("user_id", userID.String()), ) - c.JSON(503, gin.H{ - "success": false, - "error": gin.H{ - "code": 503, - "message": "Service temporarily unavailable. Please retry later.", - }, - }) + response.RespondWithAppError(c, apperrors.NewServiceUnavailableError("Service temporarily unavailable. Please retry later.")) c.Abort() return } @@ -133,13 +118,7 @@ func (m *CSRFMiddleware) Middleware() gin.HandlerFunc { zap.String("user_id", userID.String()), zap.String("ip", c.ClientIP()), ) - c.JSON(403, gin.H{ - "success": false, - "error": gin.H{ - "code": 403, - "message": "Invalid CSRF token", - }, - }) + response.RespondWithAppError(c, apperrors.NewForbiddenError("Invalid CSRF token")) c.Abort() return } diff --git a/veza-backend-api/internal/middleware/ratelimit_redis.go b/veza-backend-api/internal/middleware/ratelimit_redis.go index 5bffc6d96..32c6018a3 100644 --- a/veza-backend-api/internal/middleware/ratelimit_redis.go +++ b/veza-backend-api/internal/middleware/ratelimit_redis.go @@ -2,7 +2,6 @@ package middleware import ( "fmt" - "net/http" "os" "strconv" "sync" @@ -11,6 +10,9 @@ import ( "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" "go.uber.org/zap" + + apperrors "veza-backend-api/internal/errors" + "veza-backend-api/internal/response" ) // RedisRateLimiter is a Redis-backed rate limiter with the same interface as SimpleRateLimiter. @@ -100,23 +102,10 @@ func (rl *RedisRateLimiter) Middleware() gin.HandlerFunc { c.Header("X-RateLimit-Remaining", "0") c.Header("X-RateLimit-Reset", strconv.FormatInt(resetTime, 10)) c.Header("Retry-After", strconv.Itoa(retryAfter)) - c.JSON(http.StatusTooManyRequests, gin.H{ - "success": false, - "error": gin.H{ - "code": 429, - "message": "Rate limit exceeded. Please try again later.", - "details": []gin.H{ - { - "field": "rate_limit", - "message": fmt.Sprintf("You have exceeded the rate limit of %d requests per %v", limit, window), - }, - }, - "retry_after": retryAfter, - "limit": limit, - "remaining": 0, - "reset": resetTime, - }, - }) + appErr := apperrors.New(apperrors.ErrCodeRateLimitExceeded, "Rate limit exceeded. Please try again later.") + appErr.Details = []apperrors.ErrorDetail{{Field: "rate_limit", Message: fmt.Sprintf("You have exceeded the rate limit of %d requests per %v", limit, window)}} + appErr.Context = map[string]interface{}{"retry_after": retryAfter, "limit": limit, "remaining": 0, "reset": resetTime} + response.RespondWithAppError(c, appErr) c.Abort() return } @@ -134,23 +123,10 @@ func (rl *RedisRateLimiter) Middleware() gin.HandlerFunc { c.Header("X-RateLimit-Remaining", "0") c.Header("X-RateLimit-Reset", strconv.FormatInt(resetTime, 10)) c.Header("Retry-After", strconv.Itoa(retryAfter)) - c.JSON(http.StatusTooManyRequests, gin.H{ - "success": false, - "error": gin.H{ - "code": 429, - "message": "Rate limit exceeded. Please try again later.", - "details": []gin.H{ - { - "field": "rate_limit", - "message": fmt.Sprintf("You have exceeded the rate limit of %d requests per %v", limit, window), - }, - }, - "retry_after": retryAfter, - "limit": limit, - "remaining": 0, - "reset": resetTime, - }, - }) + appErr := apperrors.New(apperrors.ErrCodeRateLimitExceeded, "Rate limit exceeded. Please try again later.") + appErr.Details = []apperrors.ErrorDetail{{Field: "rate_limit", Message: fmt.Sprintf("You have exceeded the rate limit of %d requests per %v", limit, window)}} + appErr.Context = map[string]interface{}{"retry_after": retryAfter, "limit": limit, "remaining": 0, "reset": resetTime} + response.RespondWithAppError(c, appErr) c.Abort() return } diff --git a/veza-backend-api/internal/middleware/request_logger.go b/veza-backend-api/internal/middleware/request_logger.go index 220c17843..cf2f8414b 100644 --- a/veza-backend-api/internal/middleware/request_logger.go +++ b/veza-backend-api/internal/middleware/request_logger.go @@ -100,6 +100,37 @@ func RequestLogger(logger *zap.Logger) gin.HandlerFunc { } } +// WithRequestID returns zap fields for request_id from context (TASK-DEBT-011). +// Use in handlers: logger.Info("msg", middleware.WithRequestID(c)...) +func WithRequestID(c *gin.Context) []zap.Field { + if id, exists := c.Get("request_id"); exists && id != "" { + return []zap.Field{zap.String("request_id", id.(string))} + } + return nil +} + +// WithUserID returns zap fields for user_id from context (TASK-DEBT-011). +// Use in handlers: logger.Info("msg", middleware.WithUserID(c)...) +func WithUserID(c *gin.Context) []zap.Field { + if id, exists := c.Get("user_id"); exists && id != nil { + return []zap.Field{zap.Any("user_id", id)} + } + return nil +} + +// WithRequestContext returns zap fields for request_id and user_id (TASK-DEBT-011). +// Use in handlers for structured logging: logger.Info("msg", middleware.WithRequestContext(c)...) +func WithRequestContext(c *gin.Context) []zap.Field { + var fields []zap.Field + if id, exists := c.Get("request_id"); exists && id != "" { + fields = append(fields, zap.String("request_id", id.(string))) + } + if id, exists := c.Get("user_id"); exists && id != nil { + fields = append(fields, zap.Any("user_id", id)) + } + return fields +} + // getEnvInt récupère une variable d'environnement entière avec une valeur par défaut // FIX #9: Helper pour lire SLOW_REQUEST_THRESHOLD_MS func getEnvInt(key string, defaultValue int) int { diff --git a/veza-backend-api/internal/middleware/user_rate_limiter.go b/veza-backend-api/internal/middleware/user_rate_limiter.go index 649947d9b..d2cf012e5 100644 --- a/veza-backend-api/internal/middleware/user_rate_limiter.go +++ b/veza-backend-api/internal/middleware/user_rate_limiter.go @@ -3,7 +3,6 @@ package middleware import ( "context" "fmt" - "net/http" "strconv" "sync" "time" @@ -13,6 +12,9 @@ import ( "github.com/redis/go-redis/v9" "go.uber.org/zap" "golang.org/x/time/rate" + + apperrors "veza-backend-api/internal/errors" + "veza-backend-api/internal/response" ) // UserRateLimiterConfig configuration pour le rate limiter par utilisateur @@ -59,11 +61,7 @@ func (url *UserRateLimiter) Middleware() gin.HandlerFunc { // Récupérer l'ID utilisateur depuis le contexte userIDInterface, exists := c.Get("user_id") if !exists { - // Si pas d'utilisateur authentifié, passer au suivant - // (ce middleware est pour les utilisateurs authentifiés uniquement) - c.JSON(http.StatusUnauthorized, gin.H{ - "error": "Authentication required for rate limiting", - }) + response.RespondWithAppError(c, apperrors.NewUnauthorizedError("Authentication required for rate limiting")) c.Abort() return } @@ -77,9 +75,7 @@ func (url *UserRateLimiter) Middleware() gin.HandlerFunc { var err error userID, err = uuid.Parse(v) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Invalid user ID format", - }) + response.RespondWithAppError(c, apperrors.NewValidationError("Invalid user ID format")) c.Abort() return } @@ -89,9 +85,7 @@ func (url *UserRateLimiter) Middleware() gin.HandlerFunc { var err error userID, err = uuid.Parse(userIDStr) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Invalid user ID format", - }) + response.RespondWithAppError(c, apperrors.NewValidationError("Invalid user ID format")) c.Abort() return } @@ -124,16 +118,19 @@ func (url *UserRateLimiter) Middleware() gin.HandlerFunc { c.Header("X-RateLimit-Reset", strconv.FormatInt(resetTime, 10)) if !allowed { - retryAfter := resetTime - time.Now().Unix() - if retryAfter < 0 { - retryAfter = 0 + appErr := apperrors.New(apperrors.ErrCodeRateLimitExceeded, "Rate limit exceeded") + appErr.Context = map[string]interface{}{ + "retry_after": func() int64 { + r := resetTime - time.Now().Unix() + if r < 0 { + return 0 + } + return r + }(), + "limit": limit, + "window": url.config.Window.String(), } - c.JSON(http.StatusTooManyRequests, gin.H{ - "error": "Rate limit exceeded", - "retry_after": retryAfter, - "limit": limit, - "window": url.config.Window.String(), - }) + response.RespondWithAppError(c, appErr) c.Abort() return } @@ -151,13 +148,9 @@ func (url *UserRateLimiter) Middleware() gin.HandlerFunc { if retryAfter < 0 { retryAfter = 0 } - - c.JSON(http.StatusTooManyRequests, gin.H{ - "error": "Rate limit exceeded", - "retry_after": retryAfter, - "limit": limit, - "window": url.config.Window.String(), - }) + appErr := apperrors.New(apperrors.ErrCodeRateLimitExceeded, "Rate limit exceeded") + appErr.Context = map[string]interface{}{"retry_after": retryAfter, "limit": limit, "window": url.config.Window.String()} + response.RespondWithAppError(c, appErr) c.Abort() return } diff --git a/veza-backend-api/internal/middleware/versioning.go b/veza-backend-api/internal/middleware/versioning.go index e82f2ccee..2a0e21f95 100644 --- a/veza-backend-api/internal/middleware/versioning.go +++ b/veza-backend-api/internal/middleware/versioning.go @@ -5,6 +5,9 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" + + apperrors "veza-backend-api/internal/errors" + "veza-backend-api/internal/response" ) // Versioning middleware pour gérer le versioning de l'API @@ -83,11 +86,9 @@ func (v *Versioning) RequireVersion(requiredVersion string) gin.HandlerFunc { currentVersion := GetVersion(c) if currentVersion != requiredVersion { - c.JSON(400, gin.H{ - "error": "API version mismatch", - "required_version": requiredVersion, - "provided_version": currentVersion, - }) + appErr := apperrors.NewValidationError("API version mismatch") + appErr.Context = map[string]interface{}{"required_version": requiredVersion, "provided_version": currentVersion} + response.RespondWithAppError(c, appErr) c.Abort() return } diff --git a/veza-backend-api/internal/pagination/pagination.go b/veza-backend-api/internal/pagination/pagination.go new file mode 100644 index 000000000..89fa3790d --- /dev/null +++ b/veza-backend-api/internal/pagination/pagination.go @@ -0,0 +1,95 @@ +package pagination + +import ( + "strconv" + + apperrors "veza-backend-api/internal/errors" + + "github.com/gin-gonic/gin" +) + +const ( + DefaultPageSize = 20 + MaxPageSize = 100 +) + +// Params holds parsed pagination parameters +type Params struct { + Page int + Limit int +} + +// Offset returns the offset for SQL queries (0-based) +func (p Params) Offset() int { + if p.Page < 1 { + return 0 + } + return (p.Page - 1) * p.Limit +} + +// PaginationMeta is the standard pagination object in responses +// Format: {"page": 1, "limit": 20, "total": 150, "total_pages": 8} +type PaginationMeta struct { + Page int `json:"page"` + Limit int `json:"limit"` + Total int64 `json:"total"` + TotalPages int `json:"total_pages"` +} + +// ParseParams extracts page and limit from query string +// Uses ?page=1&limit=20. Returns default page=1, limit=20. Max limit=100. +// Empty or invalid values default to page=1, limit=20. +// Returns an AppError if validation fails (explicit page < 1 or limit > 100). +func ParseParams(c *gin.Context) (Params, *apperrors.AppError) { + pageStr := c.DefaultQuery("page", "1") + limitStr := c.DefaultQuery("limit", strconv.Itoa(DefaultPageSize)) + + page, _ := strconv.Atoi(pageStr) + limit, _ := strconv.Atoi(limitStr) + + if page < 1 { + page = 1 + } + if limit < 1 { + limit = DefaultPageSize + } + if limit > MaxPageSize { + return Params{}, apperrors.NewValidationError("pagination: limit must be between 1 and 100") + } + + return Params{Page: page, Limit: limit}, nil +} + +// ParseParamsLenient parses params with silent normalization (for backward compat) +// Invalid values are set to defaults. Use ParseParams for strict validation. +func ParseParamsLenient(c *gin.Context) Params { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", strconv.Itoa(DefaultPageSize))) + if page < 1 { + page = 1 + } + if limit < 1 { + limit = DefaultPageSize + } + if limit > MaxPageSize { + limit = MaxPageSize + } + return Params{Page: page, Limit: limit} +} + +// BuildMeta constructs PaginationMeta from page, limit, total +func BuildMeta(page, limit int, total int64) PaginationMeta { + totalPages := 1 + if limit > 0 && total > 0 { + totalPages = int((total + int64(limit) - 1) / int64(limit)) + if totalPages < 1 { + totalPages = 1 + } + } + return PaginationMeta{ + Page: page, + Limit: limit, + Total: total, + TotalPages: totalPages, + } +} diff --git a/veza-backend-api/internal/pagination/pagination_test.go b/veza-backend-api/internal/pagination/pagination_test.go new file mode 100644 index 000000000..c4bd41282 --- /dev/null +++ b/veza-backend-api/internal/pagination/pagination_test.go @@ -0,0 +1,74 @@ +package pagination + +import ( + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestParseParams_Defaults(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/?page=&limit=", nil) + + params, err := ParseParams(c) + assert.Nil(t, err) + assert.Equal(t, 1, params.Page) + assert.Equal(t, DefaultPageSize, params.Limit) +} + +func TestParseParams_Explicit(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/?page=3&limit=50", nil) + + params, err := ParseParams(c) + assert.Nil(t, err) + assert.Equal(t, 3, params.Page) + assert.Equal(t, 50, params.Limit) +} + +func TestParseParams_ExplicitInvalidPage(t *testing.T) { + // Explicit page=0 should still default to 1 (lenient) - we only reject limit > 100 + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/?page=0&limit=20", nil) + + params, err := ParseParams(c) + assert.Nil(t, err) + assert.Equal(t, 1, params.Page) // normalized to 1 +} + +func TestParseParams_LimitTooHigh(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/?page=1&limit=150", nil) + + _, err := ParseParams(c) + assert.NotNil(t, err) +} + +func TestParams_Offset(t *testing.T) { + p := Params{Page: 1, Limit: 20} + assert.Equal(t, 0, p.Offset()) + + p = Params{Page: 2, Limit: 20} + assert.Equal(t, 20, p.Offset()) + + p = Params{Page: 5, Limit: 10} + assert.Equal(t, 40, p.Offset()) +} + +func TestBuildMeta(t *testing.T) { + meta := BuildMeta(1, 20, 150) + assert.Equal(t, 1, meta.Page) + assert.Equal(t, 20, meta.Limit) + assert.Equal(t, int64(150), meta.Total) + assert.Equal(t, 8, meta.TotalPages) +} diff --git a/veza-backend-api/internal/response/response.go b/veza-backend-api/internal/response/response.go index 9102c14ce..491454f65 100644 --- a/veza-backend-api/internal/response/response.go +++ b/veza-backend-api/internal/response/response.go @@ -73,32 +73,33 @@ func InternalServerError(c *gin.Context, message string) { // Error sends a custom error response with specified status code // P0: Migré vers format AppError standardisé func Error(c *gin.Context, status int, message string) { - // Convertir status HTTP vers ErrorCode var errorCode apperrors.ErrorCode switch status { case http.StatusBadRequest: errorCode = apperrors.ErrCodeValidation case http.StatusUnauthorized: - errorCode = apperrors.ErrCodeInvalidCredentials + errorCode = apperrors.ErrCodeUnauthorized case http.StatusForbidden: errorCode = apperrors.ErrCodeForbidden case http.StatusNotFound: errorCode = apperrors.ErrCodeNotFound case http.StatusConflict: errorCode = apperrors.ErrCodeConflict - case http.StatusInternalServerError: - errorCode = apperrors.ErrCodeInternal + case http.StatusTooManyRequests: + errorCode = apperrors.ErrCodeRateLimitExceeded + case http.StatusServiceUnavailable: + errorCode = apperrors.ErrCodeServiceUnavailable default: errorCode = apperrors.ErrCodeInternal } - appErr := apperrors.New(errorCode, message) - RespondWithAppError(c, status, appErr) + RespondWithAppError(c, appErr) } -// RespondWithAppError répond avec une AppError au format standardisé -// P0: Helper pour utiliser AppError depuis le package response -func RespondWithAppError(c *gin.Context, statusCode int, appErr *apperrors.AppError) { +// RespondWithAppError répond avec une AppError au format standardisé ORIGIN_API_SPECIFICATION +// Le code HTTP est dérivé automatiquement du ErrorCode +func RespondWithAppError(c *gin.Context, appErr *apperrors.AppError) { + statusCode := mapErrorCodeToHTTPStatus(appErr.Code) errorData := struct { Code int `json:"code"` Message string `json:"message"` @@ -114,14 +115,6 @@ func RespondWithAppError(c *gin.Context, statusCode int, appErr *apperrors.AppEr Timestamp: time.Now().UTC().Format(time.RFC3339), Context: appErr.Context, } - - // Utiliser la structure APIResponse standardisée - type APIResponse struct { - Success bool `json:"success"` - Data interface{} `json:"data,omitempty"` - Error interface{} `json:"error,omitempty"` - } - c.JSON(statusCode, APIResponse{ Success: false, Data: nil, @@ -129,8 +122,49 @@ func RespondWithAppError(c *gin.Context, statusCode int, appErr *apperrors.AppEr }) } +func mapErrorCodeToHTTPStatus(code apperrors.ErrorCode) int { + if code >= 1000 && code < 2000 { + switch code { + case apperrors.ErrCodeForbidden, apperrors.ErrCodeQuotaExceeded: + return http.StatusForbidden + default: + return http.StatusUnauthorized + } + } + if code >= 2000 && code < 3000 { + return http.StatusBadRequest + } + if code >= 3000 && code < 4000 { + switch code { + case apperrors.ErrCodeNotFound: + return http.StatusNotFound + case apperrors.ErrCodeAlreadyExists, apperrors.ErrCodeConflict: + return http.StatusConflict + case apperrors.ErrCodeLocked: + return http.StatusLocked + default: + return http.StatusNotFound + } + } + if code >= 4000 && code < 5000 { + return http.StatusUnprocessableEntity + } + if code >= 5000 && code < 5100 { + return http.StatusTooManyRequests + } + if code >= 6000 && code < 7000 { + if code == apperrors.ErrCodeServiceUnavailable { + return http.StatusServiceUnavailable + } + return http.StatusBadGateway + } + if code >= 9000 && code < 10000 { + return http.StatusInternalServerError + } + return http.StatusInternalServerError +} + // ValidationError sends a 400 Bad Request response with detailed validation errors -// P0: Migré vers format AppError standardisé func ValidationError(c *gin.Context, message string, details map[string]string) { errorDetails := make([]apperrors.ErrorDetail, 0, len(details)) for field, msg := range details { @@ -140,5 +174,5 @@ func ValidationError(c *gin.Context, message string, details map[string]string) }) } appErr := apperrors.NewValidationError(message, errorDetails...) - RespondWithAppError(c, http.StatusBadRequest, appErr) + RespondWithAppError(c, appErr) } diff --git a/veza-backend-api/internal/services/social_service.go b/veza-backend-api/internal/services/social_service.go index 9859b1b60..e60567d42 100644 --- a/veza-backend-api/internal/services/social_service.go +++ b/veza-backend-api/internal/services/social_service.go @@ -38,9 +38,7 @@ func NewSocialService(db *database.Database, logger *zap.Logger) *SocialService } // FollowUser creates a follow relationship -func (ss *SocialService) FollowUser(followerID, followedID uuid.UUID) error { - ctx := context.Background() - +func (ss *SocialService) FollowUser(ctx context.Context, followerID, followedID uuid.UUID) error { _, err := ss.db.ExecContext(ctx, ` INSERT INTO follows (follower_id, followed_id) VALUES ($1, $2) @@ -60,9 +58,7 @@ func (ss *SocialService) FollowUser(followerID, followedID uuid.UUID) error { } // UnfollowUser removes a follow relationship -func (ss *SocialService) UnfollowUser(followerID, followedID uuid.UUID) error { - ctx := context.Background() - +func (ss *SocialService) UnfollowUser(ctx context.Context, followerID, followedID uuid.UUID) error { _, err := ss.db.ExecContext(ctx, ` DELETE FROM follows WHERE follower_id = $1 AND followed_id = $2 diff --git a/veza-backend-api/internal/services/social_service_test.go b/veza-backend-api/internal/services/social_service_test.go index 1947b7456..51284d26e 100644 --- a/veza-backend-api/internal/services/social_service_test.go +++ b/veza-backend-api/internal/services/social_service_test.go @@ -86,7 +86,7 @@ func TestSocialService_FollowUser_Success(t *testing.T) { followerID := uuid.New() followedID := uuid.New() - err := service.FollowUser(followerID, followedID) + err := service.FollowUser(context.Background(), followerID, followedID) assert.NoError(t, err) } @@ -97,11 +97,11 @@ func TestSocialService_FollowUser_Duplicate(t *testing.T) { followedID := uuid.New() // First follow - err := service.FollowUser(followerID, followedID) + err := service.FollowUser(context.Background(), followerID, followedID) assert.NoError(t, err) // Try to follow again (should not error due to ON CONFLICT DO NOTHING) - err = service.FollowUser(followerID, followedID) + err = service.FollowUser(context.Background(), followerID, followedID) assert.NoError(t, err) } @@ -112,11 +112,11 @@ func TestSocialService_UnfollowUser_Success(t *testing.T) { followedID := uuid.New() // First follow - err := service.FollowUser(followerID, followedID) + err := service.FollowUser(context.Background(), followerID, followedID) assert.NoError(t, err) // Then unfollow - err = service.UnfollowUser(followerID, followedID) + err = service.UnfollowUser(context.Background(), followerID, followedID) assert.NoError(t, err) } @@ -127,7 +127,7 @@ func TestSocialService_UnfollowUser_NotFollowing(t *testing.T) { followedID := uuid.New() // Try to unfollow without following first - err := service.UnfollowUser(followerID, followedID) + err := service.UnfollowUser(context.Background(), followerID, followedID) assert.NoError(t, err) // Should not error, just do nothing } @@ -255,9 +255,9 @@ func TestSocialService_GetFollowersCount_Success(t *testing.T) { follower2 := uuid.New() // Create follows - err := service.FollowUser(follower1, userID) + err := service.FollowUser(context.Background(), follower1, userID) assert.NoError(t, err) - err = service.FollowUser(follower2, userID) + err = service.FollowUser(context.Background(), follower2, userID) assert.NoError(t, err) count, err := service.GetFollowersCount(userID) @@ -283,9 +283,9 @@ func TestSocialService_GetFollowingCount_Success(t *testing.T) { followed2 := uuid.New() // Create follows - err := service.FollowUser(userID, followed1) + err := service.FollowUser(context.Background(), userID, followed1) assert.NoError(t, err) - err = service.FollowUser(userID, followed2) + err = service.FollowUser(context.Background(), userID, followed2) assert.NoError(t, err) count, err := service.GetFollowingCount(userID) @@ -318,7 +318,7 @@ func TestSocialService_IsFollowing_True(t *testing.T) { followedID := uuid.New() // Create follow - err := service.FollowUser(followerID, followedID) + err := service.FollowUser(context.Background(), followerID, followedID) assert.NoError(t, err) isFollowing, err := service.IsFollowing(followerID, followedID) diff --git a/veza-backend-api/internal/websocket/chat/benchmark_test.go b/veza-backend-api/internal/websocket/chat/benchmark_test.go index 4058508b7..d739bbfb9 100644 --- a/veza-backend-api/internal/websocket/chat/benchmark_test.go +++ b/veza-backend-api/internal/websocket/chat/benchmark_test.go @@ -16,6 +16,7 @@ func TestHub_ConcurrentConnections(t *testing.T) { hub := NewHub(logger, nil) go hub.Run() time.Sleep(10 * time.Millisecond) + defer hub.Shutdown() const numUsers = 100 const messagesPerUser = 10 diff --git a/veza-backend-api/internal/websocket/chat/hub.go b/veza-backend-api/internal/websocket/chat/hub.go index 488f42567..d84fd4fd2 100644 --- a/veza-backend-api/internal/websocket/chat/hub.go +++ b/veza-backend-api/internal/websocket/chat/hub.go @@ -15,6 +15,7 @@ type Hub struct { register chan *Client unregister chan *Client broadcast chan *RoomBroadcast + done chan struct{} // TASK-DEBT-008: lifecycle - close to stop Run() mu sync.RWMutex logger *zap.Logger presenceService *ChatPresenceService @@ -37,6 +38,7 @@ func NewHub(logger *zap.Logger, presenceService *ChatPresenceService) *Hub { register: make(chan *Client), unregister: make(chan *Client), broadcast: make(chan *RoomBroadcast, 256), + done: make(chan struct{}), logger: logger, presenceService: presenceService, } @@ -45,6 +47,8 @@ func NewHub(logger *zap.Logger, presenceService *ChatPresenceService) *Hub { func (h *Hub) Run() { for { select { + case <-h.done: + return case client := <-h.register: h.mu.Lock() h.clients[client] = true @@ -204,3 +208,8 @@ func (h *Hub) GetConnectedUsersCount() int { defer h.mu.RUnlock() return len(h.userIndex) } + +// Shutdown stops the Hub's Run loop (TASK-DEBT-008: goroutine lifecycle) +func (h *Hub) Shutdown() { + close(h.done) +} diff --git a/veza-backend-api/internal/websocket/chat/hub_test.go b/veza-backend-api/internal/websocket/chat/hub_test.go index 40a04ff58..d9171f467 100644 --- a/veza-backend-api/internal/websocket/chat/hub_test.go +++ b/veza-backend-api/internal/websocket/chat/hub_test.go @@ -6,6 +6,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" + "go.uber.org/goleak" "go.uber.org/zap/zaptest" ) @@ -26,7 +27,9 @@ func newTestClient(hub *Hub, userID uuid.UUID) *Client { } func TestHub_RegisterAndUnregister(t *testing.T) { + defer goleak.VerifyNone(t) hub := newTestHub(t) + defer hub.Shutdown() userID := uuid.New() client := newTestClient(hub, userID) @@ -44,7 +47,9 @@ func TestHub_RegisterAndUnregister(t *testing.T) { } func TestHub_JoinAndLeaveRoom(t *testing.T) { + defer goleak.VerifyNone(t) hub := newTestHub(t) + defer hub.Shutdown() userID := uuid.New() roomID := uuid.New() client := newTestClient(hub, userID) @@ -63,7 +68,9 @@ func TestHub_JoinAndLeaveRoom(t *testing.T) { } func TestHub_BroadcastToRoom(t *testing.T) { + defer goleak.VerifyNone(t) hub := newTestHub(t) + defer hub.Shutdown() user1 := uuid.New() user2 := uuid.New() @@ -93,7 +100,9 @@ func TestHub_BroadcastToRoom(t *testing.T) { } func TestHub_BroadcastToRoom_ExcludesSender(t *testing.T) { + defer goleak.VerifyNone(t) hub := newTestHub(t) + defer hub.Shutdown() user1 := uuid.New() user2 := uuid.New() @@ -118,7 +127,9 @@ func TestHub_BroadcastToRoom_ExcludesSender(t *testing.T) { } func TestHub_SendToUser(t *testing.T) { + defer goleak.VerifyNone(t) hub := newTestHub(t) + defer hub.Shutdown() userID := uuid.New() otherID := uuid.New() @@ -138,7 +149,9 @@ func TestHub_SendToUser(t *testing.T) { } func TestHub_MultipleClientsSameUser(t *testing.T) { + defer goleak.VerifyNone(t) hub := newTestHub(t) + defer hub.Shutdown() userID := uuid.New() client1 := newTestClient(hub, userID)