DealException/NotFoundException/Validation/Conflict/ServiceUnavailable с кодом ошибки, тексты в ErrorMessages.resx. Общий HTTP-обработчик (DealExceptionHandler) и маппинг в gRPC-интерцепторе: доменные ошибки → статус, прочие → обобщённый текст без стектрейса. Правила закреплены в код-стайле §10.
84 lines
3.0 KiB
C#
84 lines
3.0 KiB
C#
using System.Text.Json;
|
|
using Deal.Api.Middleware;
|
|
using Deal.SharedKernel.Errors;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Logging;
|
|
using NSubstitute;
|
|
|
|
namespace Deal.Tests.Unit.Api;
|
|
|
|
/// <summary>
|
|
/// Тесты обработчика необработанных исключений HTTP.
|
|
/// </summary>
|
|
public sealed class DealExceptionHandlerTests
|
|
{
|
|
[Fact]
|
|
public async Task NotFound_MapsTo404WithCodeAndRussianDetail()
|
|
{
|
|
DefaultHttpContext context = CreateContext();
|
|
DealExceptionHandler handler = new(Substitute.For<ILogger<DealExceptionHandler>>());
|
|
|
|
bool handled = await handler.TryHandleAsync(
|
|
context,
|
|
new NotFoundException("Карточка", "c_1"),
|
|
CancellationToken.None);
|
|
|
|
Assert.True(handled);
|
|
Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode);
|
|
(string detail, string code) = await ReadBodyAsync(context);
|
|
Assert.Equal(DealErrorCodes.NotFound, code);
|
|
Assert.Contains("Карточка", detail);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unavailable_MapsTo503()
|
|
{
|
|
DefaultHttpContext context = CreateContext();
|
|
DealExceptionHandler handler = new(Substitute.For<ILogger<DealExceptionHandler>>());
|
|
|
|
await handler.TryHandleAsync(
|
|
context,
|
|
new ServiceUnavailableException("ИИ"),
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(StatusCodes.Status503ServiceUnavailable, context.Response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnexpectedException_MapsToGeneric500WithoutStackOrDetails()
|
|
{
|
|
DefaultHttpContext context = CreateContext();
|
|
DealExceptionHandler handler = new(Substitute.For<ILogger<DealExceptionHandler>>());
|
|
|
|
bool handled = await handler.TryHandleAsync(
|
|
context,
|
|
new InvalidOperationException("секретная внутренняя деталь"),
|
|
CancellationToken.None);
|
|
|
|
Assert.True(handled);
|
|
Assert.Equal(StatusCodes.Status500InternalServerError, context.Response.StatusCode);
|
|
(string detail, string code) = await ReadBodyAsync(context);
|
|
Assert.Equal(DealErrorCodes.Internal, code);
|
|
Assert.DoesNotContain("секретная внутренняя деталь", detail);
|
|
Assert.DoesNotContain("at ", detail);
|
|
}
|
|
|
|
private static DefaultHttpContext CreateContext()
|
|
{
|
|
var context = new DefaultHttpContext();
|
|
context.Response.Body = new MemoryStream();
|
|
return context;
|
|
}
|
|
|
|
private static async Task<(string Detail, string Code)> ReadBodyAsync(HttpContext context)
|
|
{
|
|
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
|
using var reader = new StreamReader(context.Response.Body);
|
|
string json = await reader.ReadToEndAsync();
|
|
using JsonDocument document = JsonDocument.Parse(json);
|
|
return (
|
|
document.RootElement.GetProperty("detail").GetString() ?? string.Empty,
|
|
document.RootElement.GetProperty("code").GetString() ?? string.Empty);
|
|
}
|
|
}
|