fix(backend): Fix integration test failures - GlobalExceptionHandler and test isolation

Fixed 8 failing integration tests by addressing two root causes:

1. GlobalExceptionHandler returning incorrect HTTP status codes
   - Added handling for UnauthorizedAccessException → 401
   - Added handling for ArgumentException/InvalidOperationException → 400
   - Added handling for DbUpdateException (duplicate key) → 409
   - Now correctly maps exception types to HTTP status codes

2. Test isolation issue with shared HttpClient
   - Modified DatabaseFixture to create new HttpClient for each test
   - Prevents Authorization header pollution between tests
   - Ensures clean test state for authentication tests

Test Results:
- Before: 23/31 passed (8 failed)
- After: 31/31 passed (0 failed)

Changes:
- Enhanced GlobalExceptionHandler with proper status code mapping
- Fixed DatabaseFixture.Client to create isolated instances
- All authentication and RBAC tests now pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2025-11-03 17:38:40 +01:00
parent 4183b10b39
commit dbbb49e5b6
2 changed files with 82 additions and 4 deletions

View File

@@ -2,6 +2,7 @@ using System.Diagnostics;
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions; using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
namespace ColaFlow.API.Handlers; namespace ColaFlow.API.Handlers;
@@ -25,7 +26,7 @@ public sealed class GlobalExceptionHandler : IExceptionHandler
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
// Log with appropriate level based on exception type // Log with appropriate level based on exception type
if (exception is ValidationException or DomainException or NotFoundException) if (exception is ValidationException or DomainException or NotFoundException or UnauthorizedAccessException or ArgumentException)
{ {
_logger.LogWarning(exception, "Client error occurred: {Message}", exception.Message); _logger.LogWarning(exception, "Client error occurred: {Message}", exception.Message);
} }
@@ -39,6 +40,10 @@ public sealed class GlobalExceptionHandler : IExceptionHandler
ValidationException validationEx => CreateValidationProblemDetails(httpContext, validationEx), ValidationException validationEx => CreateValidationProblemDetails(httpContext, validationEx),
DomainException domainEx => CreateDomainProblemDetails(httpContext, domainEx), DomainException domainEx => CreateDomainProblemDetails(httpContext, domainEx),
NotFoundException notFoundEx => CreateNotFoundProblemDetails(httpContext, notFoundEx), NotFoundException notFoundEx => CreateNotFoundProblemDetails(httpContext, notFoundEx),
UnauthorizedAccessException unauthorizedEx => CreateUnauthorizedProblemDetails(httpContext, unauthorizedEx),
ArgumentException argumentEx => CreateBadRequestProblemDetails(httpContext, argumentEx),
InvalidOperationException invalidOpEx => CreateBadRequestProblemDetails(httpContext, invalidOpEx),
DbUpdateException dbUpdateEx when IsDuplicateKeyViolation(dbUpdateEx) => CreateConflictProblemDetails(httpContext, dbUpdateEx),
_ => CreateInternalServerErrorProblemDetails(httpContext, exception) _ => CreateInternalServerErrorProblemDetails(httpContext, exception)
}; };
@@ -48,6 +53,15 @@ public sealed class GlobalExceptionHandler : IExceptionHandler
return true; // Exception handled return true; // Exception handled
} }
private static bool IsDuplicateKeyViolation(DbUpdateException exception)
{
// Check for duplicate key violation in SQL Server or PostgreSQL
var innerException = exception.InnerException?.Message ?? string.Empty;
return innerException.Contains("duplicate key", StringComparison.OrdinalIgnoreCase) ||
innerException.Contains("unique constraint", StringComparison.OrdinalIgnoreCase) ||
innerException.Contains("IX_", StringComparison.OrdinalIgnoreCase);
}
private static ProblemDetails CreateValidationProblemDetails( private static ProblemDetails CreateValidationProblemDetails(
HttpContext context, HttpContext context,
ValidationException exception) ValidationException exception)
@@ -110,6 +124,60 @@ public sealed class GlobalExceptionHandler : IExceptionHandler
}; };
} }
private static ProblemDetails CreateUnauthorizedProblemDetails(
HttpContext context,
UnauthorizedAccessException exception)
{
return new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc7235#section-3.1",
Title = "Unauthorized",
Status = StatusCodes.Status401Unauthorized,
Detail = exception.Message,
Instance = context.Request.Path,
Extensions =
{
["traceId"] = Activity.Current?.Id ?? context.TraceIdentifier
}
};
}
private static ProblemDetails CreateBadRequestProblemDetails(
HttpContext context,
Exception exception)
{
return new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1",
Title = "Bad Request",
Status = StatusCodes.Status400BadRequest,
Detail = exception.Message,
Instance = context.Request.Path,
Extensions =
{
["traceId"] = Activity.Current?.Id ?? context.TraceIdentifier
}
};
}
private static ProblemDetails CreateConflictProblemDetails(
HttpContext context,
DbUpdateException exception)
{
return new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.8",
Title = "Conflict",
Status = StatusCodes.Status409Conflict,
Detail = "A resource with the same identifier already exists.",
Instance = context.Request.Path,
Extensions =
{
["traceId"] = Activity.Current?.Id ?? context.TraceIdentifier
}
};
}
private static ProblemDetails CreateInternalServerErrorProblemDetails( private static ProblemDetails CreateInternalServerErrorProblemDetails(
HttpContext context, HttpContext context,
Exception exception) Exception exception)

View File

@@ -8,18 +8,28 @@ namespace ColaFlow.Modules.Identity.IntegrationTests.Infrastructure;
public class DatabaseFixture : IDisposable public class DatabaseFixture : IDisposable
{ {
public ColaFlowWebApplicationFactory Factory { get; } public ColaFlowWebApplicationFactory Factory { get; }
public HttpClient Client { get; }
// Note: Client property is kept for backward compatibility but creates new instances
// Tests should call CreateClient() for isolation to avoid shared state issues
public HttpClient Client => CreateClient();
public DatabaseFixture() public DatabaseFixture()
{ {
// Use In-Memory Database for fast, isolated tests // Use In-Memory Database for fast, isolated tests
Factory = new ColaFlowWebApplicationFactory(useInMemoryDatabase: true); Factory = new ColaFlowWebApplicationFactory(useInMemoryDatabase: true);
Client = Factory.CreateClient(); }
/// <summary>
/// Creates a new HttpClient for each test to ensure test isolation
/// Prevents Authorization header sharing between tests
/// </summary>
public HttpClient CreateClient()
{
return Factory.CreateClient();
} }
public void Dispose() public void Dispose()
{ {
Client?.Dispose();
Factory?.Dispose(); Factory?.Dispose();
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }