v0.9.8
This commit is contained in:
parent
41d55e107d
commit
2a4de3ce21
26 changed files with 451 additions and 346 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
95
veza-backend-api/internal/pagination/pagination.go
Normal file
95
veza-backend-api/internal/pagination/pagination.go
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
74
veza-backend-api/internal/pagination/pagination_test.go
Normal file
74
veza-backend-api/internal/pagination/pagination_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue