using System.Text.Json;
using FluentAssertions;
using FluentValidation;
using FluentValidation.Results;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using NSubstitute;
using ProposalSystem.Api.Middleware;
using ProposalSystem.Application.Common;
using Xunit;
namespace ProposalSystem.Tests.Middleware;
///
/// GlobalExceptionHandler tests — verifies the RFC 7807 ProblemDetails
/// mapping and the machine-readable "code" extension (SHOC error-code
/// vocabulary convention). Clients branch on code, so the code values are
/// wire contract: changing one is a breaking API change.
///
public class GlobalExceptionHandlerTests
{
private static async Task<(int Status, JsonElement Body)> InvokeWith(Exception exception)
{
var logger = Substitute.For>();
var handler = new GlobalExceptionHandler(logger);
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
await handler.InvokeAsync(context, _ => throw exception);
context.Response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(context.Response.Body);
var body = JsonDocument.Parse(await reader.ReadToEndAsync()).RootElement.Clone();
return (context.Response.StatusCode, body);
}
[Fact(DisplayName = "BusinessRuleException maps to 422 with its business code")]
public async Task BusinessRuleException_Maps422WithCode()
{
var (status, body) = await InvokeWith(
new BusinessRuleException("CancelNotAllowed", "Sent proposals cannot be canceled"));
status.Should().Be(422);
body.GetProperty("code").GetString().Should().Be("CancelNotAllowed");
body.GetProperty("title").GetString().Should().Be("Business Rule Violation");
body.GetProperty("detail").GetString().Should().Be("Sent proposals cannot be canceled");
}
[Fact(DisplayName = "InvalidOperationException maps to 400 InvalidStateTransition")]
public async Task InvalidOperationException_Maps400InvalidStateTransition()
{
var (status, body) = await InvokeWith(new InvalidOperationException("bad transition"));
status.Should().Be(400);
body.GetProperty("code").GetString().Should().Be("InvalidStateTransition");
// Detail must stay generic — no internal exception text on the wire.
body.GetProperty("detail").GetString().Should().NotContain("bad transition");
}
[Fact(DisplayName = "ValidationException maps to 400 ValidationFailed")]
public async Task ValidationException_Maps400ValidationFailed()
{
var failures = new[] { new ValidationFailure("Name", "Name is required") };
var (status, body) = await InvokeWith(new ValidationException(failures));
status.Should().Be(400);
body.GetProperty("code").GetString().Should().Be("ValidationFailed");
body.GetProperty("detail").GetString().Should().Contain("Name is required");
}
[Theory(DisplayName = "Standard exceptions map to their status and code")]
[InlineData(typeof(KeyNotFoundException), 404, "NotFound")]
[InlineData(typeof(UnauthorizedAccessException), 401, "Unauthorized")]
[InlineData(typeof(ApplicationException), 500, "InternalError")]
public async Task StandardExceptions_MapToStatusAndCode(Type exceptionType, int expectedStatus, string expectedCode)
{
var exception = (Exception)Activator.CreateInstance(exceptionType)!;
var (status, body) = await InvokeWith(exception);
status.Should().Be(expectedStatus);
body.GetProperty("code").GetString().Should().Be(expectedCode);
}
[Fact(DisplayName = "Responses use application/problem+json")]
public async Task Responses_UseProblemJsonContentType()
{
var logger = Substitute.For>();
var handler = new GlobalExceptionHandler(logger);
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
await handler.InvokeAsync(context, _ => throw new KeyNotFoundException());
context.Response.ContentType.Should().Be("application/problem+json");
}
}