food-market/tests/food-market.UnitTests/ValidatorTests.cs
nns eacf7e5cc8 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>
2026-05-28 17:40:46 +05:00

185 lines
6.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
}
}