feat(validation): FluentValidation + ValidationFilter для DTO (TD-2)

Подключён FluentValidation (уже был в Directory.Packages.props, теперь
активно используется):
- AddValidatorsFromAssemblyContaining<Program>() — авторегистрация всех
  IValidator<T> из сборки food-market.api.
- ValidationFilter (IAsyncActionFilter) глобально подключён через
  MvcOptions: на каждый action ищет IValidator<TArg> по рантайм-типу
  body-параметра, гоняет, fail → 400 ValidationProblemDetails (RFC 7807).

Не используем FluentValidation.AspNetCore — официально deprecated
(см. docs.fluentvalidation.net/aspnet); current recommendation —
DI-extensions + manual filter, как у нас.

Валидаторы (для 5 DTO):
- SupplyInputValidator — Supplier/Store/Currency ≠ Empty, Date ≤ tomorrow,
  Lines non-empty, line.Quantity > 0, line.UnitPrice ≥ 0.
- RetailSaleInputValidator — Store/Currency ≠ Empty, Date ≤ tomorrow,
  PaidCash/PaidCard ≥ 0, Lines non-empty с per-line проверками.
- ProductInputValidator — Name required, Vat∈[0,100], MinStock ≤ MaxStock.
- CounterpartyInputValidator — Name required, BIN/ИИН regex \d{12},
  Email формат (EmailAddress).
- EmployeeInputValidator — LastName/FirstName required, RoleId ≠ Empty,
  SendInvite → требует CreateAccount + Email, CreateAccount → требует Email.

Сообщения по-русски (фронт ждёт RU).

Тесты: 16 юнит-тестов на валидаторы (5 на SupplyInput, 2 на RetailSaleInput,
4 на ProductInput, 2 на CounterpartyInput, 3 на EmployeeInput). Полный
прогон unit-тестов зелёный.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-28 17:40:46 +05:00
parent 8f0773eab3
commit eacf7e5cc8
4 changed files with 369 additions and 0 deletions

View file

@ -0,0 +1,43 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace foodmarket.Api.Infrastructure.Validation;
/// <summary>Action filter, который автоматически прогоняет
/// <see cref="IValidator{T}"/> по каждому body-параметру, для которого
/// validator зарегистрирован в DI. На неуспех — возвращает 400 ValidationProblemDetails
/// (стандартный формат RFC 7807, фронт уже знает как парсить ModelState).
///
/// Зачем не <c>FluentValidation.AspNetCore</c>: пакет официально deprecated
/// (см. https://docs.fluentvalidation.net/en/latest/aspnet.html). Текущая
/// рекомендация — ручная интеграция через DI + minimal filter. У нас тонкий
/// слой, читается прозрачно.</summary>
public sealed class ValidationFilter : IAsyncActionFilter
{
private readonly IServiceProvider _sp;
public ValidationFilter(IServiceProvider sp) => _sp = sp;
public async Task OnActionExecutionAsync(ActionExecutingContext ctx, ActionExecutionDelegate next)
{
foreach (var (name, value) in ctx.ActionArguments)
{
if (value is null) continue;
// Ищем IValidator<TConcrete> для рантайм-типа значения.
var validatorType = typeof(IValidator<>).MakeGenericType(value.GetType());
var validator = _sp.GetService(validatorType) as IValidator;
if (validator is null) continue;
var ctxV = new ValidationContext<object>(value);
var result = await validator.ValidateAsync(ctxV, ctx.HttpContext.RequestAborted);
if (!result.IsValid)
{
foreach (var err in result.Errors)
ctx.ModelState.AddModelError(err.PropertyName, err.ErrorMessage);
ctx.Result = new BadRequestObjectResult(new ValidationProblemDetails(ctx.ModelState));
return;
}
}
await next();
}
}

View file

@ -0,0 +1,135 @@
using FluentValidation;
namespace foodmarket.Api.Infrastructure.Validation;
// ──────────────────────────────────────────────────────────────────────────────
// FluentValidation-валидаторы для основных input-DTO. Зарегистрированы
// автоматически через AddValidatorsFromAssemblyContaining<Program>().
//
// Зачем выносим из контроллеров: единый формат ошибок (ValidationProblemDetails),
// тестируемые юнит-тестами без HTTP, переиспользуемые правила (например, BIN
// длиной 12 цифр — одинаково для Counterparty.Create и Counterparty.Update),
// читаемые правила (RuleFor + цепочка).
//
// Стиль: лаконично, на одно поле — одна цепочка. Сообщения по-русски (UI ждёт RU).
// ──────────────────────────────────────────────────────────────────────────────
public sealed class SupplyInputValidator : AbstractValidator<foodmarket.Api.Controllers.Purchases.SuppliesController.SupplyInput>
{
public SupplyInputValidator()
{
RuleFor(x => x.SupplierId).NotEqual(Guid.Empty).WithMessage("Не указан поставщик.");
RuleFor(x => x.StoreId).NotEqual(Guid.Empty).WithMessage("Не указан склад.");
RuleFor(x => x.CurrencyId).NotEqual(Guid.Empty).WithMessage("Не указана валюта.");
RuleFor(x => x.Date).LessThan(DateTime.UtcNow.AddDays(1)).WithMessage("Дата не может быть в будущем.");
RuleFor(x => x.Notes).MaximumLength(1000);
RuleFor(x => x.Lines).NotEmpty().WithMessage("Приёмка должна содержать хотя бы одну позицию.");
RuleForEach(x => x.Lines).ChildRules(line =>
{
line.RuleFor(l => l.ProductId).NotEqual(Guid.Empty).WithMessage("Не указан товар в строке.");
line.RuleFor(l => l.Quantity).GreaterThan(0).WithMessage("Количество должно быть больше 0.");
line.RuleFor(l => l.UnitPrice).GreaterThanOrEqualTo(0).WithMessage("Цена не может быть отрицательной.");
});
}
}
public sealed class RetailSaleInputValidator : AbstractValidator<foodmarket.Api.Controllers.Sales.RetailSalesController.RetailSaleInput>
{
public RetailSaleInputValidator()
{
RuleFor(x => x.StoreId).NotEqual(Guid.Empty).WithMessage("Не указан склад.");
RuleFor(x => x.CurrencyId).NotEqual(Guid.Empty).WithMessage("Не указана валюта.");
RuleFor(x => x.Date).LessThan(DateTime.UtcNow.AddDays(1)).WithMessage("Дата не может быть в будущем.");
RuleFor(x => x.Notes).MaximumLength(1000);
RuleFor(x => x.PaidCash).GreaterThanOrEqualTo(0);
RuleFor(x => x.PaidCard).GreaterThanOrEqualTo(0);
// Возврат с reference: ReferenceSaleId должен быть валидный GUID
// (саму ссылку валидирует контроллер обращаясь в БД).
When(x => x.IsReturn, () =>
{
RuleFor(x => x.ReferenceSaleId).Must(id => id is null || id != Guid.Empty)
.WithMessage("Невалидный ReferenceSaleId.");
});
RuleFor(x => x.Lines).NotEmpty().WithMessage("Чек должен содержать хотя бы одну позицию.");
RuleForEach(x => x.Lines).ChildRules(line =>
{
line.RuleFor(l => l.ProductId).NotEqual(Guid.Empty);
line.RuleFor(l => l.Quantity).GreaterThan(0);
line.RuleFor(l => l.UnitPrice).GreaterThanOrEqualTo(0);
line.RuleFor(l => l.Discount).GreaterThanOrEqualTo(0);
line.RuleFor(l => l.VatPercent).InclusiveBetween(0, 100);
});
}
}
public sealed class ProductInputValidator : AbstractValidator<foodmarket.Application.Catalog.ProductInput>
{
public ProductInputValidator()
{
RuleFor(x => x.Name).NotEmpty().WithMessage("Название обязательно.")
.MaximumLength(200);
RuleFor(x => x.Article).MaximumLength(50);
RuleFor(x => x.UnitOfMeasureId).NotEqual(Guid.Empty)
.WithMessage("Не указана единица измерения.");
RuleFor(x => x.Vat).InclusiveBetween(0m, 100m).When(x => x.Vat.HasValue)
.WithMessage("Ставка НДС должна быть от 0 до 100%.");
RuleFor(x => x.ReferencePrice).GreaterThanOrEqualTo(0).When(x => x.ReferencePrice.HasValue);
RuleFor(x => x.MinStock).GreaterThanOrEqualTo(0).When(x => x.MinStock.HasValue);
RuleFor(x => x.MaxStock).GreaterThanOrEqualTo(0).When(x => x.MaxStock.HasValue);
// MinStock <= MaxStock когда оба заданы.
RuleFor(x => x).Must(x => !x.MinStock.HasValue || !x.MaxStock.HasValue || x.MinStock.Value <= x.MaxStock.Value)
.WithMessage("MinStock не может быть больше MaxStock.");
}
}
public sealed class CounterpartyInputValidator : AbstractValidator<foodmarket.Application.Catalog.CounterpartyInput>
{
public CounterpartyInputValidator()
{
RuleFor(x => x.Name).NotEmpty().WithMessage("Название обязательно.")
.MaximumLength(200);
// БИН (юрлицо) — 12 цифр, ИИН (физлицо) — 12 цифр. Один из двух
// обязателен только если тип LegalEntity (Type=1). Для Individual (2)
// достаточно ИИН или имени. На уровне БД эти ограничения не enforced —
// валидируем именно тут.
When(x => !string.IsNullOrEmpty(x.Bin), () =>
{
RuleFor(x => x.Bin!).Matches(@"^\d{12}$")
.WithMessage("БИН должен быть 12-значный.");
});
When(x => !string.IsNullOrEmpty(x.Iin), () =>
{
RuleFor(x => x.Iin!).Matches(@"^\d{12}$")
.WithMessage("ИИН должен быть 12-значный.");
});
// Простая validation формата e-mail (без RFC-зубодробильника):
// contains @ and a dot, with no spaces.
When(x => !string.IsNullOrEmpty(x.Email), () =>
{
RuleFor(x => x.Email!).EmailAddress().WithMessage("Невалидный e-mail.");
});
}
}
public sealed class EmployeeInputValidator : AbstractValidator<foodmarket.Api.Controllers.Organizations.EmployeesController.EmployeeInput>
{
public EmployeeInputValidator()
{
RuleFor(x => x.LastName).NotEmpty().WithMessage("Фамилия обязательна.").MaximumLength(100);
RuleFor(x => x.FirstName).NotEmpty().WithMessage("Имя обязательно.").MaximumLength(100);
RuleFor(x => x.RoleId).NotEqual(Guid.Empty).WithMessage("Не выбрана роль.");
// SendInvite только при CreateAccount=true и наличии Email.
When(x => x.SendInvite, () =>
{
RuleFor(x => x.CreateAccount).Equal(true)
.WithMessage("Для отправки приглашения нужно создавать учётную запись.");
RuleFor(x => x.Email).NotEmpty()
.WithMessage("Для приглашения нужен email сотрудника.");
});
When(x => x.CreateAccount, () =>
{
RuleFor(x => x.Email).NotEmpty().EmailAddress()
.WithMessage("Для создания учётки нужен валидный email.");
});
}
}

View file

@ -2,6 +2,7 @@
using Hangfire;
using Hangfire.PostgreSql;
using Prometheus;
using FluentValidation;
using foodmarket.Api.Infrastructure.RateLimiting;
using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Api.Seed;
@ -167,11 +168,17 @@
// см. Resources/EmailTemplates/*.html. Singleton с in-memory cache.
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Email.EmailTemplates>();
builder.Services.AddDataProtection();
// FluentValidation: автоматическая регистрация валидаторов из сборки api.
// ValidationFilter гоняет валидаторы на каждом контроллер-action перед
// вызовом — fail возвращает 400 ValidationProblemDetails (RFC 7807).
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Validation.ValidationFilter>();
builder.Services.AddControllers(o =>
{
// Глобальный action filter — пишет audit-log при успешных мутациях
// в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3).
o.Filters.AddService<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
o.Filters.AddService<foodmarket.Api.Infrastructure.Validation.ValidationFilter>();
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(opts =>

View file

@ -0,0 +1,184 @@
using FluentAssertions;
using FluentValidation.TestHelper;
using foodmarket.Api.Controllers.Organizations;
using foodmarket.Api.Controllers.Purchases;
using foodmarket.Api.Controllers.Sales;
using foodmarket.Api.Infrastructure.Validation;
using foodmarket.Application.Catalog;
using Xunit;
namespace foodmarket.UnitTests;
public class SupplyInputValidatorTests
{
private readonly SupplyInputValidator _v = new();
[Fact]
public void Rejects_empty_supplier()
{
var input = new SuppliesController.SupplyInput(
DateTime.UtcNow, Guid.Empty, Guid.NewGuid(), Guid.NewGuid(),
"", new[] { new SuppliesController.SupplyLineInput(Guid.NewGuid(), 1m, 100m) });
_v.TestValidate(input).ShouldHaveValidationErrorFor(x => x.SupplierId);
}
[Fact]
public void Rejects_empty_lines()
{
var input = new SuppliesController.SupplyInput(
DateTime.UtcNow, Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(),
"", Array.Empty<SuppliesController.SupplyLineInput>());
_v.TestValidate(input).ShouldHaveValidationErrorFor(x => x.Lines);
}
[Fact]
public void Rejects_zero_quantity_in_line()
{
var input = new SuppliesController.SupplyInput(
DateTime.UtcNow, Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(),
"", new[] { new SuppliesController.SupplyLineInput(Guid.NewGuid(), 0m, 100m) });
_v.TestValidate(input).ShouldHaveValidationErrorFor("Lines[0].Quantity");
}
[Fact]
public void Rejects_future_date()
{
var input = new SuppliesController.SupplyInput(
DateTime.UtcNow.AddDays(5), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(),
"", new[] { new SuppliesController.SupplyLineInput(Guid.NewGuid(), 1m, 100m) });
_v.TestValidate(input).ShouldHaveValidationErrorFor(x => x.Date);
}
[Fact]
public void Accepts_valid()
{
var input = new SuppliesController.SupplyInput(
DateTime.UtcNow.AddHours(-1), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(),
"Notes", new[] { new SuppliesController.SupplyLineInput(Guid.NewGuid(), 5m, 100m) });
_v.TestValidate(input).ShouldNotHaveAnyValidationErrors();
}
}
public class RetailSaleInputValidatorTests
{
private readonly RetailSaleInputValidator _v = new();
[Fact]
public void Rejects_negative_paid()
{
var input = new RetailSalesController.RetailSaleInput(
DateTime.UtcNow, Guid.NewGuid(), null, null, Guid.NewGuid(),
foodmarket.Domain.Sales.PaymentMethod.Cash,
PaidCash: -1m, PaidCard: 0m,
Notes: "", Lines: new[] {
new RetailSalesController.RetailSaleLineInput(Guid.NewGuid(), 1m, 100m, 0m, 12m)
});
_v.TestValidate(input).ShouldHaveValidationErrorFor(x => x.PaidCash);
}
[Fact]
public void Rejects_empty_lines()
{
var input = new RetailSalesController.RetailSaleInput(
DateTime.UtcNow, Guid.NewGuid(), null, null, Guid.NewGuid(),
foodmarket.Domain.Sales.PaymentMethod.Cash, 0m, 0m, "",
Array.Empty<RetailSalesController.RetailSaleLineInput>());
_v.TestValidate(input).ShouldHaveValidationErrorFor(x => x.Lines);
}
}
public class ProductInputValidatorTests
{
private readonly ProductInputValidator _v = new();
private static ProductInput Make(string name = "Молоко", decimal vat = 12,
Guid? unitId = null, decimal? minStock = null, decimal? maxStock = null) => new(
Name: name, Article: null, Description: null,
UnitOfMeasureId: unitId ?? Guid.NewGuid(),
Vat: vat, VatEnabled: true,
ProductGroupId: Guid.NewGuid(),
DefaultSupplierId: null, CountryOfOriginId: null,
IsService: false, Packaging: foodmarket.Domain.Catalog.Packaging.Piece, IsMarked: false,
MinStock: minStock, MaxStock: maxStock,
ReferencePrice: null,
PurchaseCurrencyId: null,
ImageUrl: null,
Prices: Array.Empty<ProductPriceInput>(),
Barcodes: Array.Empty<ProductBarcodeInput>());
[Fact]
public void Empty_name_rejected() =>
_v.TestValidate(Make(name: "")).ShouldHaveValidationErrorFor(x => x.Name);
[Fact]
public void Vat_out_of_range_rejected() =>
_v.TestValidate(Make(vat: 150m)).ShouldHaveValidationErrorFor(x => x.Vat);
[Fact]
public void Min_greater_than_max_rejected()
{
var r = _v.TestValidate(Make(minStock: 10, maxStock: 5));
r.Errors.Should().Contain(e => e.ErrorMessage.Contains("MinStock"));
}
[Fact]
public void Valid_passes() =>
_v.TestValidate(Make()).ShouldNotHaveAnyValidationErrors();
}
public class CounterpartyInputValidatorTests
{
private readonly CounterpartyInputValidator _v = new();
[Fact]
public void Bin_must_be_12_digits()
{
var bad = new CounterpartyInput("ТОО", null, foodmarket.Domain.Catalog.CounterpartyType.LegalEntity,
Bin: "123", Iin: null, TaxNumber: null, CountryId: null,
Address: null, Phone: null, Email: null,
BankName: null, BankAccount: null, Bik: null, ContactPerson: null, Notes: null);
_v.TestValidate(bad).ShouldHaveValidationErrorFor(x => x.Bin);
}
[Fact]
public void Invalid_email_rejected()
{
var bad = new CounterpartyInput("ТОО", null, foodmarket.Domain.Catalog.CounterpartyType.LegalEntity,
Bin: "123456789012", Iin: null, TaxNumber: null, CountryId: null,
Address: null, Phone: null, Email: "not-an-email",
BankName: null, BankAccount: null, Bik: null, ContactPerson: null, Notes: null);
_v.TestValidate(bad).ShouldHaveValidationErrorFor(x => x.Email);
}
}
public class EmployeeInputValidatorTests
{
private readonly EmployeeInputValidator _v = new();
[Fact]
public void SendInvite_without_email_rejected()
{
var bad = new EmployeesController.EmployeeInput(
"Иванов", "Иван", null, null, Email: null, null, null, null, null, null,
Guid.NewGuid(), true, null, CreateAccount: true, SendInvite: true);
_v.TestValidate(bad).ShouldHaveValidationErrorFor(x => x.Email);
}
[Fact]
public void CreateAccount_without_email_rejected()
{
var bad = new EmployeesController.EmployeeInput(
"Иванов", "Иван", null, null, Email: null, null, null, null, null, null,
Guid.NewGuid(), true, null, CreateAccount: true);
_v.TestValidate(bad).ShouldHaveValidationErrorFor(x => x.Email);
}
[Fact]
public void Valid_passes()
{
var ok = new EmployeesController.EmployeeInput(
"Иванов", "Иван", null, null, "iv@x.kz", null, null, null, null, null,
Guid.NewGuid(), true, null, CreateAccount: true, SendInvite: true);
_v.TestValidate(ok).ShouldNotHaveAnyValidationErrors();
}
}