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"); } // ── Concurrency 409 envelopes (ADR 0004) ───────────────────────────────── // These are SHOC's shapes verbatim, NOT ProblemDetails. Both bodies are wire // contract: web/mobile branch on message + currentState / status + code. private static ProposalSystem.Application.DTOs.ProposalResponse SampleState(Guid id) => new( id, "P-001", "WO-1", null, "Customer", "Addr", "Scope", null, ProposalSystem.Domain.Entities.ServiceCategory.HVAC, ProposalSystem.Domain.Entities.Priority.Standard, ProposalSystem.Domain.Entities.ProposalStatus.InReview, 100m, null, "winner", Guid.NewGuid(), "Submitter", DateTime.UtcNow, null, null, null, null, 1, null, DateTime.UtcNow, DateTime.UtcNow, "AAAAAAAAAAI="); [Fact(DisplayName = "ProposalConcurrencyException maps to 409 { message, currentState } (SHOC envelope)")] public async Task ConcurrencyException_Maps409WithCurrentState() { var id = Guid.NewGuid(); var (status, body) = await InvokeWith(new ProposalConcurrencyException(SampleState(id))); status.Should().Be(409); body.GetProperty("message").GetString() .Should().Be("The record was modified by another user. Refresh and retry."); body.GetProperty("currentState").GetProperty("id").GetString().Should().Be(id.ToString()); body.GetProperty("currentState").GetProperty("notes").GetString().Should().Be("winner"); body.GetProperty("currentState").GetProperty("rowVersion").GetString().Should().Be("AAAAAAAAAAI="); // Enums must serialize as strings (matching the MVC pipeline), or client // schema validation of currentState fails and the state is discarded. body.GetProperty("currentState").GetProperty("serviceCategory").GetString().Should().Be("HVAC"); body.GetProperty("currentState").GetProperty("status").GetString().Should().Be("InReview"); // The guarded envelope is NOT the fallback shape. body.TryGetProperty("status", out _).Should().BeFalse(); body.TryGetProperty("code", out _).Should().BeFalse(); } [Fact(DisplayName = "ProposalConcurrencyException with no reloadable state carries currentState: null")] public async Task ConcurrencyException_NullState_SerializesNull() { var (status, body) = await InvokeWith(new ProposalConcurrencyException(null)); status.Should().Be(409); body.GetProperty("currentState").ValueKind.Should().Be(JsonValueKind.Null); } [Fact(DisplayName = "Bare DbUpdateConcurrencyException maps to the 409 fallback { status, message, code }")] public async Task DbUpdateConcurrencyException_Maps409Fallback() { var (status, body) = await InvokeWith( new Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException("boom")); status.Should().Be(409); body.GetProperty("status").GetString().Should().Be("Conflict"); body.GetProperty("message").GetString() .Should().Be("The record was modified by another user. Refresh and retry."); body.GetProperty("code").GetInt32().Should().Be(409); body.TryGetProperty("currentState", out _).Should().BeFalse(); } }