This commit is contained in:
senke 2026-03-06 19:13:16 +01:00
parent 41d55e107d
commit 2a4de3ce21
26 changed files with 451 additions and 346 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
})
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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,
}
}

View 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)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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