Подключён 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>
185 lines
6.8 KiB
C#
185 lines
6.8 KiB
C#
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();
|
||
}
|
||
}
|