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:
parent
8f0773eab3
commit
eacf7e5cc8
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
135
src/food-market.api/Infrastructure/Validation/Validators.cs
Normal file
135
src/food-market.api/Infrastructure/Validation/Validators.cs
Normal 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.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
using Hangfire.PostgreSql;
|
using Hangfire.PostgreSql;
|
||||||
using Prometheus;
|
using Prometheus;
|
||||||
|
using FluentValidation;
|
||||||
using foodmarket.Api.Infrastructure.RateLimiting;
|
using foodmarket.Api.Infrastructure.RateLimiting;
|
||||||
using foodmarket.Api.Infrastructure.Tenancy;
|
using foodmarket.Api.Infrastructure.Tenancy;
|
||||||
using foodmarket.Api.Seed;
|
using foodmarket.Api.Seed;
|
||||||
|
|
@ -167,11 +168,17 @@
|
||||||
// см. Resources/EmailTemplates/*.html. Singleton с in-memory cache.
|
// см. Resources/EmailTemplates/*.html. Singleton с in-memory cache.
|
||||||
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Email.EmailTemplates>();
|
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Email.EmailTemplates>();
|
||||||
builder.Services.AddDataProtection();
|
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 =>
|
builder.Services.AddControllers(o =>
|
||||||
{
|
{
|
||||||
// Глобальный action filter — пишет audit-log при успешных мутациях
|
// Глобальный action filter — пишет audit-log при успешных мутациях
|
||||||
// в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3).
|
// в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3).
|
||||||
o.Filters.AddService<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
|
o.Filters.AddService<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
|
||||||
|
o.Filters.AddService<foodmarket.Api.Infrastructure.Validation.ValidationFilter>();
|
||||||
});
|
});
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen(opts =>
|
builder.Services.AddSwaggerGen(opts =>
|
||||||
|
|
|
||||||
184
tests/food-market.UnitTests/ValidatorTests.cs
Normal file
184
tests/food-market.UnitTests/ValidatorTests.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue