veza/veza-backend-api/tests/error_handling/error_recovery_test.go

578 lines
18 KiB
Go

//go:build integration || error_handling
// +build integration error_handling
package error_handling
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap/zaptest"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/middleware"
"veza-backend-api/internal/models"
"veza-backend-api/internal/response"
)
// setupErrorHandlingTestRouter crée un router de test pour les tests de gestion d'erreurs
func setupErrorHandlingTestRouter(t *testing.T) (*gin.Engine, *gorm.DB, func()) {
gin.SetMode(gin.TestMode)
logger := zaptest.NewLogger(t)
// Setup in-memory SQLite database
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
db.Exec("PRAGMA foreign_keys = ON")
// Auto-migrate models
err = db.AutoMigrate(
&models.User{},
&models.Track{},
)
require.NoError(t, err)
// Create router with error handler
router := gin.New()
router.Use(middleware.ErrorHandler(logger, nil, false))
// Test endpoints that return different error types
router.GET("/test/validation-error", func(c *gin.Context) {
c.Error(apperrors.NewValidationError("validation failed", apperrors.ErrorDetail{
Field: "email",
Message: "invalid email format",
}))
c.Abort()
})
router.GET("/test/not-found", func(c *gin.Context) {
c.Error(apperrors.NewNotFoundError("resource"))
c.Abort()
})
router.GET("/test/unauthorized", func(c *gin.Context) {
c.Error(apperrors.NewUnauthorizedError("unauthorized"))
c.Abort()
})
router.GET("/test/forbidden", func(c *gin.Context) {
c.Error(apperrors.NewForbiddenError("forbidden"))
c.Abort()
})
router.GET("/test/internal-error", func(c *gin.Context) {
c.Error(apperrors.New(apperrors.ErrCodeInternal, "internal server error"))
c.Abort()
})
router.GET("/test/database-error", func(c *gin.Context) {
c.Error(apperrors.New(apperrors.ErrCodeDatabase, "database error"))
c.Abort()
})
router.GET("/test/conflict", func(c *gin.Context) {
c.Error(apperrors.New(apperrors.ErrCodeConflict, "resource conflict"))
c.Abort()
})
router.GET("/test/rate-limit", func(c *gin.Context) {
c.Error(apperrors.New(apperrors.ErrCodeRateLimitExceeded, "rate limit exceeded"))
c.Abort()
})
router.GET("/test/quota-exceeded", func(c *gin.Context) {
c.Error(apperrors.New(apperrors.ErrCodeQuotaExceeded, "quota exceeded"))
c.Abort()
})
// Endpoint that recovers from error
router.GET("/test/recovery", func(c *gin.Context) {
// Simulate error
if c.Query("error") == "true" {
c.Error(apperrors.New(apperrors.ErrCodeInternal, "simulated error"))
c.Abort()
return
}
// Success path
response.Success(c, gin.H{"message": "recovered"}, "success")
})
// Endpoint with error details
router.POST("/test/validation-details", func(c *gin.Context) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
details := []apperrors.ErrorDetail{
{Field: "email", Message: "email is required"},
{Field: "password", Message: "password is required"},
}
c.Error(apperrors.NewValidationError("validation failed", details...))
c.Abort()
return
}
response.Success(c, gin.H{"email": req.Email}, "valid")
})
cleanup := func() {
// Cleanup handled by in-memory DB
}
return router, db, cleanup
}
// TestErrorResponse_Format teste que les réponses d'erreur sont au format standardisé
func TestErrorResponse_Format(t *testing.T) {
router, _, cleanup := setupErrorHandlingTestRouter(t)
defer cleanup()
tests := []struct {
name string
endpoint string
expectedStatus int
expectedCode apperrors.ErrorCode
validateError func(t *testing.T, body []byte)
}{
{
name: "Validation Error",
endpoint: "/test/validation-error",
expectedStatus: http.StatusBadRequest,
expectedCode: apperrors.ErrCodeValidation,
validateError: func(t *testing.T, body []byte) {
var resp map[string]interface{}
err := json.Unmarshal(body, &resp)
require.NoError(t, err)
errorData, ok := resp["error"].(map[string]interface{})
require.True(t, ok, "Error should be a map")
assert.Equal(t, float64(apperrors.ErrCodeValidation), errorData["code"])
assert.Contains(t, errorData, "message")
assert.Contains(t, errorData, "details")
},
},
{
name: "Not Found Error",
endpoint: "/test/not-found",
expectedStatus: http.StatusNotFound,
expectedCode: apperrors.ErrCodeNotFound,
validateError: func(t *testing.T, body []byte) {
var resp map[string]interface{}
err := json.Unmarshal(body, &resp)
require.NoError(t, err)
errorData, ok := resp["error"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, float64(apperrors.ErrCodeNotFound), errorData["code"])
},
},
{
name: "Unauthorized Error",
endpoint: "/test/unauthorized",
expectedStatus: http.StatusUnauthorized,
expectedCode: apperrors.ErrCodeUnauthorized,
validateError: func(t *testing.T, body []byte) {
var resp map[string]interface{}
err := json.Unmarshal(body, &resp)
require.NoError(t, err)
errorData, ok := resp["error"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, float64(apperrors.ErrCodeUnauthorized), errorData["code"])
},
},
{
name: "Forbidden Error",
endpoint: "/test/forbidden",
expectedStatus: http.StatusForbidden,
expectedCode: apperrors.ErrCodeForbidden,
validateError: func(t *testing.T, body []byte) {
var resp map[string]interface{}
err := json.Unmarshal(body, &resp)
require.NoError(t, err)
errorData, ok := resp["error"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, float64(apperrors.ErrCodeForbidden), errorData["code"])
},
},
{
name: "Internal Server Error",
endpoint: "/test/internal-error",
expectedStatus: http.StatusInternalServerError,
expectedCode: apperrors.ErrCodeInternal,
validateError: func(t *testing.T, body []byte) {
var resp map[string]interface{}
err := json.Unmarshal(body, &resp)
require.NoError(t, err)
errorData, ok := resp["error"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, float64(apperrors.ErrCodeInternal), errorData["code"])
},
},
{
name: "Database Error",
endpoint: "/test/database-error",
expectedStatus: http.StatusInternalServerError,
expectedCode: apperrors.ErrCodeDatabase,
validateError: func(t *testing.T, body []byte) {
var resp map[string]interface{}
err := json.Unmarshal(body, &resp)
require.NoError(t, err)
errorData, ok := resp["error"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, float64(apperrors.ErrCodeDatabase), errorData["code"])
},
},
{
name: "Conflict Error",
endpoint: "/test/conflict",
expectedStatus: http.StatusConflict,
expectedCode: apperrors.ErrCodeConflict,
validateError: func(t *testing.T, body []byte) {
var resp map[string]interface{}
err := json.Unmarshal(body, &resp)
require.NoError(t, err)
errorData, ok := resp["error"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, float64(apperrors.ErrCodeConflict), errorData["code"])
},
},
{
name: "Rate Limit Error",
endpoint: "/test/rate-limit",
expectedStatus: http.StatusTooManyRequests,
expectedCode: apperrors.ErrCodeRateLimitExceeded,
validateError: func(t *testing.T, body []byte) {
var resp map[string]interface{}
err := json.Unmarshal(body, &resp)
require.NoError(t, err)
errorData, ok := resp["error"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, float64(apperrors.ErrCodeRateLimitExceeded), errorData["code"])
},
},
{
name: "Quota Exceeded Error",
endpoint: "/test/quota-exceeded",
expectedStatus: http.StatusForbidden,
expectedCode: apperrors.ErrCodeQuotaExceeded,
validateError: func(t *testing.T, body []byte) {
var resp map[string]interface{}
err := json.Unmarshal(body, &resp)
require.NoError(t, err)
errorData, ok := resp["error"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, float64(apperrors.ErrCodeQuotaExceeded), errorData["code"])
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tt.endpoint, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.expectedStatus, w.Code,
fmt.Sprintf("Expected status %d, got %d", tt.expectedStatus, w.Code))
if tt.validateError != nil {
tt.validateError(t, w.Body.Bytes())
}
})
}
}
// TestErrorResponse_Details teste que les détails d'erreur sont présents quand nécessaire
func TestErrorResponse_Details(t *testing.T) {
router, _, cleanup := setupErrorHandlingTestRouter(t)
defer cleanup()
// Test validation error with details
req := httptest.NewRequest(http.MethodGet, "/test/validation-error", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
errorData, ok := resp["error"].(map[string]interface{})
require.True(t, ok)
details, ok := errorData["details"].([]interface{})
require.True(t, ok, "Details should be present")
assert.NotEmpty(t, details, "Details should not be empty")
// Verify detail structure
detail, ok := details[0].(map[string]interface{})
require.True(t, ok)
assert.Contains(t, detail, "field")
assert.Contains(t, detail, "message")
}
// TestErrorRecovery teste la récupération après erreur
func TestErrorRecovery(t *testing.T) {
router, _, cleanup := setupErrorHandlingTestRouter(t)
defer cleanup()
// Test error case
req := httptest.NewRequest(http.MethodGet, "/test/recovery?error=true", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
var errorResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &errorResp)
require.NoError(t, err)
assert.Contains(t, errorResp, "error")
// Test recovery (success case)
req2 := httptest.NewRequest(http.MethodGet, "/test/recovery", nil)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusOK, w2.Code)
var successResp map[string]interface{}
err = json.Unmarshal(w2.Body.Bytes(), &successResp)
require.NoError(t, err)
assert.True(t, successResp["success"].(bool))
assert.Contains(t, successResp, "data")
}
// TestErrorRecovery_ValidationDetails teste la récupération avec détails de validation
func TestErrorRecovery_ValidationDetails(t *testing.T) {
router, _, cleanup := setupErrorHandlingTestRouter(t)
defer cleanup()
// Test with invalid input (should return validation error)
invalidPayload := map[string]interface{}{}
body, _ := json.Marshal(invalidPayload)
req := httptest.NewRequest(http.MethodPost, "/test/validation-details", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var errorResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &errorResp)
require.NoError(t, err)
errorData, ok := errorResp["error"].(map[string]interface{})
require.True(t, ok)
details, ok := errorData["details"].([]interface{})
require.True(t, ok)
assert.Len(t, details, 2, "Should have 2 validation errors")
// Test with valid input (should succeed)
validPayload := map[string]interface{}{
"email": "test@example.com",
"password": "password123",
}
body2, _ := json.Marshal(validPayload)
req2 := httptest.NewRequest(http.MethodPost, "/test/validation-details", bytes.NewBuffer(body2))
req2.Header.Set("Content-Type", "application/json")
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusOK, w2.Code)
var successResp map[string]interface{}
err = json.Unmarshal(w2.Body.Bytes(), &successResp)
require.NoError(t, err)
assert.True(t, successResp["success"].(bool))
}
// TestErrorResponse_Timestamp teste que le timestamp est présent dans les erreurs
func TestErrorResponse_Timestamp(t *testing.T) {
router, _, cleanup := setupErrorHandlingTestRouter(t)
defer cleanup()
req := httptest.NewRequest(http.MethodGet, "/test/validation-error", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
errorData, ok := resp["error"].(map[string]interface{})
require.True(t, ok)
// Note: The error handler might not include timestamp in the current format
// This test verifies the error structure is valid
assert.Contains(t, errorData, "code")
assert.Contains(t, errorData, "message")
}
// TestErrorResponse_Context teste que le contexte est présent dans les erreurs
func TestErrorResponse_Context(t *testing.T) {
router, _, cleanup := setupErrorHandlingTestRouter(t)
defer cleanup()
req := httptest.NewRequest(http.MethodGet, "/test/validation-error", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
errorData, ok := resp["error"].(map[string]interface{})
require.True(t, ok)
// Context might be present if set by middleware
// This test verifies the error structure is valid
assert.Contains(t, errorData, "code")
assert.Contains(t, errorData, "message")
}
// TestErrorResponse_Consistency teste que toutes les erreurs suivent le même format
func TestErrorResponse_Consistency(t *testing.T) {
router, _, cleanup := setupErrorHandlingTestRouter(t)
defer cleanup()
endpoints := []string{
"/test/validation-error",
"/test/not-found",
"/test/unauthorized",
"/test/forbidden",
"/test/internal-error",
}
for _, endpoint := range endpoints {
t.Run(fmt.Sprintf("Consistency check: %s", endpoint), func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, endpoint, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
// All errors should have the same structure
assert.Contains(t, resp, "error")
errorData, ok := resp["error"].(map[string]interface{})
require.True(t, ok, "Error should be a map")
// Required fields
assert.Contains(t, errorData, "code")
assert.Contains(t, errorData, "message")
// Code should be a number
code, ok := errorData["code"].(float64)
assert.True(t, ok, "Code should be a number")
assert.Greater(t, code, float64(0), "Code should be positive")
})
}
}
// TestErrorRecovery_RetryLogic teste la logique de retry après erreur
func TestErrorRecovery_RetryLogic(t *testing.T) {
router, _, cleanup := setupErrorHandlingTestRouter(t)
defer cleanup()
// Simulate retry after error
// First request: error
req1 := httptest.NewRequest(http.MethodGet, "/test/recovery?error=true", nil)
w1 := httptest.NewRecorder()
router.ServeHTTP(w1, req1)
assert.Equal(t, http.StatusInternalServerError, w1.Code)
// Second request: success (recovery)
req2 := httptest.NewRequest(http.MethodGet, "/test/recovery", nil)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusOK, w2.Code)
var successResp map[string]interface{}
err := json.Unmarshal(w2.Body.Bytes(), &successResp)
require.NoError(t, err)
assert.True(t, successResp["success"].(bool))
}
// TestErrorHandling_AppErrorWrapper teste que les AppError sont correctement wrappées
func TestErrorHandling_AppErrorWrapper(t *testing.T) {
router, _, cleanup := setupErrorHandlingTestRouter(t)
defer cleanup()
req := httptest.NewRequest(http.MethodGet, "/test/validation-error", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
errorData, ok := resp["error"].(map[string]interface{})
require.True(t, ok)
// Verify AppError structure
assert.Equal(t, float64(apperrors.ErrCodeValidation), errorData["code"])
assert.NotEmpty(t, errorData["message"])
}
// TestErrorHandling_HTTPStatusMapping teste que les codes d'erreur sont correctement mappés vers les codes HTTP
func TestErrorHandling_HTTPStatusMapping(t *testing.T) {
router, _, cleanup := setupErrorHandlingTestRouter(t)
defer cleanup()
tests := []struct {
endpoint string
expectedStatus int
expectedCode apperrors.ErrorCode
}{
{"/test/validation-error", http.StatusBadRequest, apperrors.ErrCodeValidation},
{"/test/not-found", http.StatusNotFound, apperrors.ErrCodeNotFound},
{"/test/unauthorized", http.StatusUnauthorized, apperrors.ErrCodeUnauthorized},
{"/test/forbidden", http.StatusForbidden, apperrors.ErrCodeForbidden},
{"/test/internal-error", http.StatusInternalServerError, apperrors.ErrCodeInternal},
{"/test/database-error", http.StatusInternalServerError, apperrors.ErrCodeDatabase},
{"/test/conflict", http.StatusConflict, apperrors.ErrCodeConflict},
{"/test/rate-limit", http.StatusTooManyRequests, apperrors.ErrCodeRateLimitExceeded},
{"/test/quota-exceeded", http.StatusForbidden, apperrors.ErrCodeQuotaExceeded},
}
for _, tt := range tests {
t.Run(tt.endpoint, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tt.endpoint, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.expectedStatus, w.Code,
fmt.Sprintf("Expected HTTP status %d for error code %d", tt.expectedStatus, tt.expectedCode))
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
errorData, ok := resp["error"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, float64(tt.expectedCode), errorData["code"])
})
}
}