diff --git a/src/food-market.api/Infrastructure/Validation/ValidationFilter.cs b/src/food-market.api/Infrastructure/Validation/ValidationFilter.cs new file mode 100644 index 0000000..aacf89a --- /dev/null +++ b/src/food-market.api/Infrastructure/Validation/ValidationFilter.cs @@ -0,0 +1,43 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace foodmarket.Api.Infrastructure.Validation; + +/// Action filter, который автоматически прогоняет +/// по каждому body-параметру, для которого +/// validator зарегистрирован в DI. На неуспех — возвращает 400 ValidationProblemDetails +/// (стандартный формат RFC 7807, фронт уже знает как парсить ModelState). +/// +/// Зачем не FluentValidation.AspNetCore: пакет официально deprecated +/// (см. https://docs.fluentvalidation.net/en/latest/aspnet.html). Текущая +/// рекомендация — ручная интеграция через DI + minimal filter. У нас тонкий +/// слой, читается прозрачно. +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 для рантайм-типа значения. + var validatorType = typeof(IValidator<>).MakeGenericType(value.GetType()); + var validator = _sp.GetService(validatorType) as IValidator; + if (validator is null) continue; + + var ctxV = new ValidationContext(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(); + } +} diff --git a/src/food-market.api/Infrastructure/Validation/Validators.cs b/src/food-market.api/Infrastructure/Validation/Validators.cs new file mode 100644 index 0000000..9b8718f --- /dev/null +++ b/src/food-market.api/Infrastructure/Validation/Validators.cs @@ -0,0 +1,135 @@ +using FluentValidation; + +namespace foodmarket.Api.Infrastructure.Validation; + +// ────────────────────────────────────────────────────────────────────────────── +// FluentValidation-валидаторы для основных input-DTO. Зарегистрированы +// автоматически через AddValidatorsFromAssemblyContaining(). +// +// Зачем выносим из контроллеров: единый формат ошибок (ValidationProblemDetails), +// тестируемые юнит-тестами без HTTP, переиспользуемые правила (например, BIN +// длиной 12 цифр — одинаково для Counterparty.Create и Counterparty.Update), +// читаемые правила (RuleFor + цепочка). +// +// Стиль: лаконично, на одно поле — одна цепочка. Сообщения по-русски (UI ждёт RU). +// ────────────────────────────────────────────────────────────────────────────── + +public sealed class SupplyInputValidator : AbstractValidator +{ + 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 +{ + 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 +{ + 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 +{ + 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 +{ + 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."); + }); + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 4f423ca..2127966 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -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(); builder.Services.AddDataProtection(); + // FluentValidation: автоматическая регистрация валидаторов из сборки api. + // ValidationFilter гоняет валидаторы на каждом контроллер-action перед + // вызовом — fail возвращает 400 ValidationProblemDetails (RFC 7807). + builder.Services.AddValidatorsFromAssemblyContaining(); + builder.Services.AddScoped(); builder.Services.AddControllers(o => { // Глобальный action filter — пишет audit-log при успешных мутациях // в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3). o.Filters.AddService(); + o.Filters.AddService(); }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(opts => diff --git a/tests/food-market.UnitTests/ValidatorTests.cs b/tests/food-market.UnitTests/ValidatorTests.cs new file mode 100644 index 0000000..3462d9f --- /dev/null +++ b/tests/food-market.UnitTests/ValidatorTests.cs @@ -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()); + _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()); + _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(), + Barcodes: Array.Empty()); + + [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(); + } +}