diff --git a/.claude/skills/minimal-api/references/patterns.md b/.claude/skills/minimal-api/references/patterns.md index 3c7f1159..5a13c716 100644 --- a/.claude/skills/minimal-api/references/patterns.md +++ b/.claude/skills/minimal-api/references/patterns.md @@ -363,24 +363,37 @@ public static class ProductsPermissions ## Validation Classes +Use FluentValidation `AbstractValidator`. The framework provides a `ToValidationErrors()` extension that converts FluentValidation's `ValidationResult` to the `Dictionary` shape consumed by `ValidationException` and the RFC 7807 response writer. + ```csharp -public static class CreateRequestValidator +public sealed class CreateRequestValidator : AbstractValidator { - public static ValidationResult Validate(CreateProductRequest request) => - new ValidationBuilder() - .AddErrorIf( - string.IsNullOrWhiteSpace(request.Name), - "Name", - "Product name is required." - ) - .AddErrorIf(request.Price <= 0, "Price", "Price must be greater than zero.") - .Build(); + public CreateRequestValidator() + { + RuleFor(x => x.Name).NotEmpty().WithMessage("Product name is required."); + RuleFor(x => x.Price).GreaterThan(0).WithMessage("Price must be greater than zero."); + } } ``` -Usage in endpoint: +Register once per module in `ConfigureServices`: + ```csharp -var validation = CreateRequestValidator.Validate(request); -if (!validation.IsValid) - throw new ValidationException(validation.Errors); +services.AddValidatorsFromAssemblyContaining(); +``` + +Usage in endpoint (async lambda, inject `IValidator`): + +```csharp +async ( + CreateProductRequest request, + IValidator validator, + IProductContracts contracts +) => +{ + var validation = await validator.ValidateAsync(request); + if (!validation.IsValid) + throw new Core.Exceptions.ValidationException(validation.ToValidationErrors()); + // ... +} ``` diff --git a/.claude/skills/simplemodule/references/endpoints.md b/.claude/skills/simplemodule/references/endpoints.md index 1e203f9b..ddc1bbfa 100644 --- a/.claude/skills/simplemodule/references/endpoints.md +++ b/.claude/skills/simplemodule/references/endpoints.md @@ -150,14 +150,29 @@ var body = await JsonSerializer.DeserializeAsync(context.Request.Body); ## Validation +Use FluentValidation `AbstractValidator`. Register via `services.AddValidatorsFromAssemblyContaining()` in `ConfigureServices`. Inject `IValidator` into the endpoint handler. + ```csharp -public static class CreateRequestValidator +public sealed class CreateRequestValidator : AbstractValidator +{ + public CreateRequestValidator() + { + RuleFor(x => x.Name).NotEmpty().WithMessage("Product name is required."); + RuleFor(x => x.Price).GreaterThan(0).WithMessage("Price must be greater than zero."); + } +} + +// In the endpoint lambda: +async ( + CreateProductRequest request, + IValidator validator, + IProductContracts products +) => { - public static ValidationResult Validate(CreateProductRequest request) => - new ValidationBuilder() - .AddErrorIf(string.IsNullOrWhiteSpace(request.Name), "Name", "Product name is required.") - .AddErrorIf(request.Price <= 0, "Price", "Price must be greater than zero.") - .Build(); + var validation = await validator.ValidateAsync(request); + if (!validation.IsValid) + throw new Core.Exceptions.ValidationException(validation.ToValidationErrors()); + // ... } ``` diff --git a/Directory.Packages.props b/Directory.Packages.props index 53ce23e0..ffda4f54 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -60,6 +60,9 @@ + + + diff --git a/cli/SimpleModule.Cli/Templates/FeatureTemplates.cs b/cli/SimpleModule.Cli/Templates/FeatureTemplates.cs index 22c1b824..178cc064 100644 --- a/cli/SimpleModule.Cli/Templates/FeatureTemplates.cs +++ b/cli/SimpleModule.Cli/Templates/FeatureTemplates.cs @@ -277,11 +277,7 @@ string singularName var newBody = new List { resultLines[braceStart], // opening brace - " var errors = new Dictionary();", - "", - " // TODO: add validation rules", - "", - " return errors.Count > 0 ? ValidationResult.WithErrors(errors) : ValidationResult.Success;", + " // TODO: add validation rules via RuleFor(x => x.Prop)...", }; // Keep the closing brace @@ -369,20 +365,16 @@ private static string FallbackValidator( string singularName ) => $$""" - using SimpleModule.Core.Validation; + using FluentValidation; using SimpleModule.{{moduleName}}.Contracts; namespace SimpleModule.{{moduleName}}.Endpoints.{{moduleName}}; - public static class {{featureName}}RequestValidator + public sealed class {{featureName}}RequestValidator : AbstractValidator<{{singularName}}> { - public static ValidationResult Validate({{singularName}} request) + public {{featureName}}RequestValidator() { - var errors = new Dictionary(); - - // TODO: add validation rules - - return errors.Count > 0 ? ValidationResult.WithErrors(errors) : ValidationResult.Success; + // TODO: add validation rules via RuleFor(x => x.Prop)... } } """; diff --git a/framework/SimpleModule.Core/SimpleModule.Core.csproj b/framework/SimpleModule.Core/SimpleModule.Core.csproj index c8cd3274..011fc56f 100644 --- a/framework/SimpleModule.Core/SimpleModule.Core.csproj +++ b/framework/SimpleModule.Core/SimpleModule.Core.csproj @@ -8,5 +8,7 @@ + + diff --git a/framework/SimpleModule.Core/Validation/ValidationBuilder.cs b/framework/SimpleModule.Core/Validation/ValidationBuilder.cs deleted file mode 100644 index 8eafb6ac..00000000 --- a/framework/SimpleModule.Core/Validation/ValidationBuilder.cs +++ /dev/null @@ -1,140 +0,0 @@ -using System.Text.RegularExpressions; - -namespace SimpleModule.Core.Validation; - -public sealed partial class ValidationBuilder -{ - private readonly Dictionary> _errors = []; - - public ValidationBuilder AddErrorIf(bool condition, string field, string message) - { - if (condition) - { - if (!_errors.TryGetValue(field, out var list)) - { - list = []; - _errors[field] = list; - } - - list.Add(message); - } - - return this; - } - - public ValidationBuilder Required(string? value, string field, string? message = null) - { - return AddErrorIf( - string.IsNullOrWhiteSpace(value), - field, - message ?? $"{field} is required." - ); - } - - public ValidationBuilder MaxLength( - string? value, - string field, - int maxLength, - string? message = null - ) - { - return AddErrorIf( - value is not null && value.Length > maxLength, - field, - message ?? $"{field} must be at most {maxLength} characters." - ); - } - - public ValidationBuilder MinLength( - string? value, - string field, - int minLength, - string? message = null - ) - { - return AddErrorIf( - value is not null && value.Length < minLength, - field, - message ?? $"{field} must be at least {minLength} characters." - ); - } - - public ValidationBuilder LengthBetween( - string? value, - string field, - int minLength, - int maxLength, - string? message = null - ) - { - return AddErrorIf( - value is not null && (value.Length < minLength || value.Length > maxLength), - field, - message ?? $"{field} must be between {minLength} and {maxLength} characters." - ); - } - - public ValidationBuilder MatchesPattern( - string? value, - string field, - string pattern, - string? message = null - ) - { - // Static Regex.IsMatch caches a small set of compiled regexes internally, - // which is fine for the handful of patterns typical callers use. - return AddErrorIf( - value is not null && !Regex.IsMatch(value, pattern, RegexOptions.Compiled), - field, - message ?? $"{field} has an invalid format." - ); - } - - public ValidationBuilder Email(string? value, string field, string? message = null) - { - return AddErrorIf( - !string.IsNullOrWhiteSpace(value) && !EmailRegex().IsMatch(value), - field, - message ?? $"{field} must be a valid email address." - ); - } - - public ValidationBuilder GreaterThan( - decimal value, - string field, - decimal min, - string? message = null - ) - { - return AddErrorIf(value <= min, field, message ?? $"{field} must be greater than {min}."); - } - - public ValidationBuilder Between( - decimal value, - string field, - decimal min, - decimal max, - string? message = null - ) - { - return AddErrorIf( - value < min || value > max, - field, - message ?? $"{field} must be between {min} and {max}." - ); - } - - public ValidationResult Build() - { - if (_errors.Count == 0) - { - return ValidationResult.Success; - } - - var errors = _errors.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToArray()); - return ValidationResult.WithErrors(errors); - } - - [GeneratedRegex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.Compiled)] - private static partial Regex EmailRegex(); -} diff --git a/framework/SimpleModule.Core/Validation/ValidationResult.cs b/framework/SimpleModule.Core/Validation/ValidationResult.cs deleted file mode 100644 index c35f509e..00000000 --- a/framework/SimpleModule.Core/Validation/ValidationResult.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace SimpleModule.Core.Validation; - -public sealed record ValidationResult(bool IsValid, Dictionary Errors) -{ - public static ValidationResult Success { get; } = new(true, new Dictionary()); - - public static ValidationResult WithErrors(Dictionary errors) => - new(false, errors); -} diff --git a/framework/SimpleModule.Core/Validation/ValidationResultExtensions.cs b/framework/SimpleModule.Core/Validation/ValidationResultExtensions.cs new file mode 100644 index 00000000..144ed389 --- /dev/null +++ b/framework/SimpleModule.Core/Validation/ValidationResultExtensions.cs @@ -0,0 +1,28 @@ +using FluentValidation.Results; + +namespace SimpleModule.Core.Validation; + +/// +/// Bridges FluentValidation's to the +/// shape consumed by +/// and +/// the RFC 7807 errors extension written by GlobalExceptionHandler. +/// +public static class ValidationResultExtensions +{ + /// + /// Groups validation failures by property name, flattening their error + /// messages into a string[] per field. + /// + public static Dictionary ToValidationErrors(this ValidationResult result) + { + ArgumentNullException.ThrowIfNull(result); + return result + .Errors.GroupBy(e => e.PropertyName, StringComparer.Ordinal) + .ToDictionary( + g => g.Key, + g => g.Select(e => e.ErrorMessage).ToArray(), + StringComparer.Ordinal + ); + } +} diff --git a/modules/Email/src/SimpleModule.Email/EmailModule.cs b/modules/Email/src/SimpleModule.Email/EmailModule.cs index d26c0aaf..ecfbc525 100644 --- a/modules/Email/src/SimpleModule.Email/EmailModule.cs +++ b/modules/Email/src/SimpleModule.Email/EmailModule.cs @@ -1,3 +1,4 @@ +using FluentValidation; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -23,6 +24,7 @@ public class EmailModule : IModule, IModuleServices public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { services.AddModuleDbContext(configuration, EmailConstants.ModuleName); + services.AddValidatorsFromAssemblyContaining(); var emailSection = configuration.GetSection("Email"); services.Configure(emailSection); diff --git a/modules/Email/src/SimpleModule.Email/Endpoints/Messages/SendEmailEndpoint.cs b/modules/Email/src/SimpleModule.Email/Endpoints/Messages/SendEmailEndpoint.cs index fc06639d..626c30fd 100644 --- a/modules/Email/src/SimpleModule.Email/Endpoints/Messages/SendEmailEndpoint.cs +++ b/modules/Email/src/SimpleModule.Email/Endpoints/Messages/SendEmailEndpoint.cs @@ -1,10 +1,11 @@ +using FluentValidation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Authorization; +using SimpleModule.Core.Validation; using SimpleModule.Email.Contracts; -using SimpleModule.Email.Validators; namespace SimpleModule.Email.Endpoints.Messages; @@ -16,11 +17,17 @@ public class SendEmailEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPost( Route, - async (SendEmailRequest request, IEmailContracts emailContracts) => + async ( + SendEmailRequest request, + IValidator validator, + IEmailContracts emailContracts + ) => { - var validation = SendEmailRequestValidator.Validate(request); + var validation = await validator.ValidateAsync(request); if (!validation.IsValid) - throw new Core.Exceptions.ValidationException(validation.Errors); + throw new Core.Exceptions.ValidationException( + validation.ToValidationErrors() + ); var message = await emailContracts.SendEmailAsync(request); return TypedResults.Ok(message); diff --git a/modules/Email/src/SimpleModule.Email/Endpoints/Templates/CreateTemplateEndpoint.cs b/modules/Email/src/SimpleModule.Email/Endpoints/Templates/CreateTemplateEndpoint.cs index 407ef2ea..82b58bf4 100644 --- a/modules/Email/src/SimpleModule.Email/Endpoints/Templates/CreateTemplateEndpoint.cs +++ b/modules/Email/src/SimpleModule.Email/Endpoints/Templates/CreateTemplateEndpoint.cs @@ -1,10 +1,11 @@ +using FluentValidation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; +using SimpleModule.Core.Validation; using SimpleModule.Email.Contracts; -using SimpleModule.Email.Validators; namespace SimpleModule.Email.Endpoints.Templates; @@ -16,13 +17,19 @@ public class CreateTemplateEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPost( Route, - (CreateEmailTemplateRequest request, IEmailContracts emailContracts) => + async ( + CreateEmailTemplateRequest request, + IValidator validator, + IEmailContracts emailContracts + ) => { - var validation = CreateEmailTemplateRequestValidator.Validate(request); + var validation = await validator.ValidateAsync(request); if (!validation.IsValid) - throw new Core.Exceptions.ValidationException(validation.Errors); + throw new Core.Exceptions.ValidationException( + validation.ToValidationErrors() + ); - return CrudEndpoints.Create( + return await CrudEndpoints.Create( () => emailContracts.CreateTemplateAsync(request), t => $"/api/email/templates/{t.Id.Value}" ); diff --git a/modules/Email/src/SimpleModule.Email/Endpoints/Templates/UpdateTemplateEndpoint.cs b/modules/Email/src/SimpleModule.Email/Endpoints/Templates/UpdateTemplateEndpoint.cs index 51ee3523..09b5487e 100644 --- a/modules/Email/src/SimpleModule.Email/Endpoints/Templates/UpdateTemplateEndpoint.cs +++ b/modules/Email/src/SimpleModule.Email/Endpoints/Templates/UpdateTemplateEndpoint.cs @@ -1,10 +1,11 @@ +using FluentValidation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; +using SimpleModule.Core.Validation; using SimpleModule.Email.Contracts; -using SimpleModule.Email.Validators; namespace SimpleModule.Email.Endpoints.Templates; @@ -16,13 +17,20 @@ public class UpdateTemplateEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPut( Route, - (int id, UpdateEmailTemplateRequest request, IEmailContracts emailContracts) => + async ( + int id, + UpdateEmailTemplateRequest request, + IValidator validator, + IEmailContracts emailContracts + ) => { - var validation = UpdateEmailTemplateRequestValidator.Validate(request); + var validation = await validator.ValidateAsync(request); if (!validation.IsValid) - throw new Core.Exceptions.ValidationException(validation.Errors); + throw new Core.Exceptions.ValidationException( + validation.ToValidationErrors() + ); - return CrudEndpoints.Update(() => + return await CrudEndpoints.Update(() => emailContracts.UpdateTemplateAsync(EmailTemplateId.From(id), request) ); } diff --git a/modules/Email/src/SimpleModule.Email/Pages/CreateTemplateEndpoint.cs b/modules/Email/src/SimpleModule.Email/Pages/CreateTemplateEndpoint.cs index 4b1ad76d..fc1d0123 100644 --- a/modules/Email/src/SimpleModule.Email/Pages/CreateTemplateEndpoint.cs +++ b/modules/Email/src/SimpleModule.Email/Pages/CreateTemplateEndpoint.cs @@ -1,3 +1,4 @@ +using FluentValidation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -5,8 +6,8 @@ using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Inertia; +using SimpleModule.Core.Validation; using SimpleModule.Email.Contracts; -using SimpleModule.Email.Validators; namespace SimpleModule.Email.Pages; @@ -21,7 +22,11 @@ public void Map(IEndpointRouteBuilder app) app.MapPost( "/templates", - async ([AsParameters] CreateTemplateForm form, IEmailContracts emailContracts) => + async ( + [AsParameters] CreateTemplateForm form, + IValidator validator, + IEmailContracts emailContracts + ) => { var request = new CreateEmailTemplateRequest { @@ -31,9 +36,11 @@ public void Map(IEndpointRouteBuilder app) Body = form.Body, IsHtml = form.IsHtml, }; - var validation = CreateEmailTemplateRequestValidator.Validate(request); + var validation = await validator.ValidateAsync(request); if (!validation.IsValid) - throw new Core.Exceptions.ValidationException(validation.Errors); + throw new Core.Exceptions.ValidationException( + validation.ToValidationErrors() + ); await emailContracts.CreateTemplateAsync(request); return Results.Redirect( diff --git a/modules/Email/src/SimpleModule.Email/Pages/EditTemplateEndpoint.cs b/modules/Email/src/SimpleModule.Email/Pages/EditTemplateEndpoint.cs index c399fda9..ac850a52 100644 --- a/modules/Email/src/SimpleModule.Email/Pages/EditTemplateEndpoint.cs +++ b/modules/Email/src/SimpleModule.Email/Pages/EditTemplateEndpoint.cs @@ -1,3 +1,4 @@ +using FluentValidation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -5,8 +6,8 @@ using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Inertia; +using SimpleModule.Core.Validation; using SimpleModule.Email.Contracts; -using SimpleModule.Email.Validators; namespace SimpleModule.Email.Pages; @@ -35,6 +36,7 @@ public void Map(IEndpointRouteBuilder app) async ( int id, [AsParameters] UpdateTemplateForm form, + IValidator validator, IEmailContracts emailContracts ) => { @@ -45,9 +47,11 @@ IEmailContracts emailContracts Body = form.Body, IsHtml = form.IsHtml, }; - var validation = UpdateEmailTemplateRequestValidator.Validate(request); + var validation = await validator.ValidateAsync(request); if (!validation.IsValid) - throw new Core.Exceptions.ValidationException(validation.Errors); + throw new Core.Exceptions.ValidationException( + validation.ToValidationErrors() + ); await emailContracts.UpdateTemplateAsync(EmailTemplateId.From(id), request); return Results.Redirect( diff --git a/modules/Email/src/SimpleModule.Email/Validators/CreateEmailTemplateRequestValidator.cs b/modules/Email/src/SimpleModule.Email/Validators/CreateEmailTemplateRequestValidator.cs index ed635fe1..4fc5175e 100644 --- a/modules/Email/src/SimpleModule.Email/Validators/CreateEmailTemplateRequestValidator.cs +++ b/modules/Email/src/SimpleModule.Email/Validators/CreateEmailTemplateRequestValidator.cs @@ -1,40 +1,37 @@ using System.Text.RegularExpressions; -using SimpleModule.Core.Validation; +using FluentValidation; using SimpleModule.Email.Contracts; namespace SimpleModule.Email.Validators; -public static partial class CreateEmailTemplateRequestValidator +public sealed partial class CreateEmailTemplateRequestValidator + : AbstractValidator { - public static ValidationResult Validate(CreateEmailTemplateRequest request) => - new ValidationBuilder() - .AddErrorIf(string.IsNullOrWhiteSpace(request.Name), "Name", "Name is required.") - .AddErrorIf(request.Name?.Length > 200, "Name", "Name must not exceed 200 characters.") - .AddErrorIf(string.IsNullOrWhiteSpace(request.Slug), "Slug", "Slug is required.") - .AddErrorIf(request.Slug?.Length > 200, "Slug", "Slug must not exceed 200 characters.") - .AddErrorIf( - !string.IsNullOrWhiteSpace(request.Slug) && !SlugPattern().IsMatch(request.Slug), - "Slug", - "Slug must be lowercase alphanumeric with hyphens." - ) - .AddErrorIf( - string.IsNullOrWhiteSpace(request.Subject), - "Subject", - "Subject is required." - ) - .AddErrorIf( - request.Subject?.Length > 500, - "Subject", - "Subject must not exceed 500 characters." - ) - .AddErrorIf(string.IsNullOrWhiteSpace(request.Body), "Body", "Body is required.") - .AddErrorIf( - !string.IsNullOrWhiteSpace(request.DefaultReplyTo) - && !SendEmailRequestValidator.EmailPattern().IsMatch(request.DefaultReplyTo), - "DefaultReplyTo", - "Invalid email format." - ) - .Build(); + public CreateEmailTemplateRequestValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Name is required.") + .MaximumLength(200) + .WithMessage("Name must not exceed 200 characters."); + RuleFor(x => x.Slug) + .NotEmpty() + .WithMessage("Slug is required.") + .MaximumLength(200) + .WithMessage("Slug must not exceed 200 characters.") + .Must(s => string.IsNullOrWhiteSpace(s) || SlugPattern().IsMatch(s)) + .WithMessage("Slug must be lowercase alphanumeric with hyphens."); + RuleFor(x => x.Subject) + .NotEmpty() + .WithMessage("Subject is required.") + .MaximumLength(500) + .WithMessage("Subject must not exceed 500 characters."); + RuleFor(x => x.Body).NotEmpty().WithMessage("Body is required."); + RuleFor(x => x.DefaultReplyTo) + .EmailAddress() + .When(x => !string.IsNullOrWhiteSpace(x.DefaultReplyTo)) + .WithMessage("Invalid email format."); + } [GeneratedRegex(@"^[a-z0-9]+(-[a-z0-9]+)*$")] private static partial Regex SlugPattern(); diff --git a/modules/Email/src/SimpleModule.Email/Validators/SendEmailRequestValidator.cs b/modules/Email/src/SimpleModule.Email/Validators/SendEmailRequestValidator.cs index cb51563d..4d48b153 100644 --- a/modules/Email/src/SimpleModule.Email/Validators/SendEmailRequestValidator.cs +++ b/modules/Email/src/SimpleModule.Email/Validators/SendEmailRequestValidator.cs @@ -1,38 +1,26 @@ -using System.Text.RegularExpressions; -using SimpleModule.Core.Validation; +using FluentValidation; using SimpleModule.Email.Contracts; namespace SimpleModule.Email.Validators; -public static partial class SendEmailRequestValidator +public sealed class SendEmailRequestValidator : AbstractValidator { - public static ValidationResult Validate(SendEmailRequest request) => - new ValidationBuilder() - .AddErrorIf(string.IsNullOrWhiteSpace(request.To), "To", "Recipient is required.") - .AddErrorIf( - !string.IsNullOrWhiteSpace(request.To) && !EmailPattern().IsMatch(request.To), - "To", - "Invalid email format." - ) - .AddErrorIf( - !string.IsNullOrWhiteSpace(request.ReplyTo) - && !EmailPattern().IsMatch(request.ReplyTo), - "ReplyTo", - "Invalid email format." - ) - .AddErrorIf( - string.IsNullOrWhiteSpace(request.Subject), - "Subject", - "Subject is required." - ) - .AddErrorIf( - request.Subject?.Length > 500, - "Subject", - "Subject must not exceed 500 characters." - ) - .AddErrorIf(string.IsNullOrWhiteSpace(request.Body), "Body", "Body is required.") - .Build(); - - [GeneratedRegex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$")] - internal static partial Regex EmailPattern(); + public SendEmailRequestValidator() + { + RuleFor(x => x.To).NotEmpty().WithMessage("Recipient is required."); + RuleFor(x => x.To) + .EmailAddress() + .WithMessage("Invalid email format.") + .When(x => !string.IsNullOrWhiteSpace(x.To)); + RuleFor(x => x.ReplyTo) + .EmailAddress() + .WithMessage("Invalid email format.") + .When(x => !string.IsNullOrWhiteSpace(x.ReplyTo)); + RuleFor(x => x.Subject) + .NotEmpty() + .WithMessage("Subject is required.") + .MaximumLength(500) + .WithMessage("Subject must not exceed 500 characters."); + RuleFor(x => x.Body).NotEmpty().WithMessage("Body is required."); + } } diff --git a/modules/Email/src/SimpleModule.Email/Validators/UpdateEmailTemplateRequestValidator.cs b/modules/Email/src/SimpleModule.Email/Validators/UpdateEmailTemplateRequestValidator.cs index 1f3a5e7d..df16f1e8 100644 --- a/modules/Email/src/SimpleModule.Email/Validators/UpdateEmailTemplateRequestValidator.cs +++ b/modules/Email/src/SimpleModule.Email/Validators/UpdateEmailTemplateRequestValidator.cs @@ -1,30 +1,27 @@ -using SimpleModule.Core.Validation; +using FluentValidation; using SimpleModule.Email.Contracts; namespace SimpleModule.Email.Validators; -public static class UpdateEmailTemplateRequestValidator +public sealed class UpdateEmailTemplateRequestValidator + : AbstractValidator { - public static ValidationResult Validate(UpdateEmailTemplateRequest request) => - new ValidationBuilder() - .AddErrorIf(string.IsNullOrWhiteSpace(request.Name), "Name", "Name is required.") - .AddErrorIf(request.Name?.Length > 200, "Name", "Name must not exceed 200 characters.") - .AddErrorIf( - string.IsNullOrWhiteSpace(request.Subject), - "Subject", - "Subject is required." - ) - .AddErrorIf( - request.Subject?.Length > 500, - "Subject", - "Subject must not exceed 500 characters." - ) - .AddErrorIf(string.IsNullOrWhiteSpace(request.Body), "Body", "Body is required.") - .AddErrorIf( - !string.IsNullOrWhiteSpace(request.DefaultReplyTo) - && !SendEmailRequestValidator.EmailPattern().IsMatch(request.DefaultReplyTo), - "DefaultReplyTo", - "Invalid email format." - ) - .Build(); + public UpdateEmailTemplateRequestValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Name is required.") + .MaximumLength(200) + .WithMessage("Name must not exceed 200 characters."); + RuleFor(x => x.Subject) + .NotEmpty() + .WithMessage("Subject is required.") + .MaximumLength(500) + .WithMessage("Subject must not exceed 500 characters."); + RuleFor(x => x.Body).NotEmpty().WithMessage("Body is required."); + RuleFor(x => x.DefaultReplyTo) + .EmailAddress() + .When(x => !string.IsNullOrWhiteSpace(x.DefaultReplyTo)) + .WithMessage("Invalid email format."); + } } diff --git a/modules/Email/tests/SimpleModule.Email.Tests/Unit/ValidatorTests.cs b/modules/Email/tests/SimpleModule.Email.Tests/Unit/ValidatorTests.cs index 200b7f53..70a9e8a2 100644 --- a/modules/Email/tests/SimpleModule.Email.Tests/Unit/ValidatorTests.cs +++ b/modules/Email/tests/SimpleModule.Email.Tests/Unit/ValidatorTests.cs @@ -6,6 +6,10 @@ namespace SimpleModule.Email.Tests.Unit; public sealed class ValidatorTests { + private readonly SendEmailRequestValidator _sendValidator = new(); + private readonly CreateEmailTemplateRequestValidator _createValidator = new(); + private readonly UpdateEmailTemplateRequestValidator _updateValidator = new(); + [Fact] public void SendEmailRequestValidator_WithValidRequest_ReturnsSuccess() { @@ -15,7 +19,7 @@ public void SendEmailRequestValidator_WithValidRequest_ReturnsSuccess() Subject = "Test", Body = "Hello", }; - var result = SendEmailRequestValidator.Validate(request); + var result = _sendValidator.Validate(request); result.IsValid.Should().BeTrue(); } @@ -28,9 +32,9 @@ public void SendEmailRequestValidator_WithEmptyTo_ReturnsError() Subject = "Test", Body = "Hello", }; - var result = SendEmailRequestValidator.Validate(request); + var result = _sendValidator.Validate(request); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("To"); + result.Errors.Should().Contain(e => e.PropertyName == "To"); } [Fact] @@ -42,9 +46,9 @@ public void SendEmailRequestValidator_WithInvalidEmail_ReturnsError() Subject = "Test", Body = "Hello", }; - var result = SendEmailRequestValidator.Validate(request); + var result = _sendValidator.Validate(request); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("To"); + result.Errors.Should().Contain(e => e.PropertyName == "To"); } [Fact] @@ -57,9 +61,9 @@ public void SendEmailRequestValidator_WithInvalidReplyTo_ReturnsError() Subject = "Test", Body = "Hello", }; - var result = SendEmailRequestValidator.Validate(request); + var result = _sendValidator.Validate(request); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("ReplyTo"); + result.Errors.Should().Contain(e => e.PropertyName == "ReplyTo"); } [Fact] @@ -72,7 +76,7 @@ public void CreateEmailTemplateRequestValidator_WithValidRequest_ReturnsSuccess( Subject = "Welcome {{name}}", Body = "Hello!", }; - var result = CreateEmailTemplateRequestValidator.Validate(request); + var result = _createValidator.Validate(request); result.IsValid.Should().BeTrue(); } @@ -86,9 +90,9 @@ public void CreateEmailTemplateRequestValidator_WithInvalidSlug_ReturnsError() Subject = "Welcome", Body = "Hello!", }; - var result = CreateEmailTemplateRequestValidator.Validate(request); + var result = _createValidator.Validate(request); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("Slug"); + result.Errors.Should().Contain(e => e.PropertyName == "Slug"); } [Fact] @@ -100,8 +104,8 @@ public void UpdateEmailTemplateRequestValidator_WithEmptyName_ReturnsError() Subject = "Test", Body = "Hello!", }; - var result = UpdateEmailTemplateRequestValidator.Validate(request); + var result = _updateValidator.Validate(request); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("Name"); + result.Errors.Should().Contain(e => e.PropertyName == "Name"); } } diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/CreateBasemapEndpoint.cs b/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/CreateBasemapEndpoint.cs index 9651e224..60bfc012 100644 --- a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/CreateBasemapEndpoint.cs +++ b/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/CreateBasemapEndpoint.cs @@ -1,9 +1,10 @@ +using FluentValidation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; -using SimpleModule.Core.Exceptions; +using SimpleModule.Core.Validation; using SimpleModule.Map.Contracts; namespace SimpleModule.Map.Endpoints.Basemaps; @@ -16,15 +17,21 @@ public class CreateBasemapEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPost( Route, - (CreateBasemapRequest request, IMapContracts map) => + async ( + CreateBasemapRequest request, + IValidator validator, + IMapContracts map + ) => { - var validation = CreateBasemapRequestValidator.Validate(request); + var validation = await validator.ValidateAsync(request); if (!validation.IsValid) { - throw new ValidationException(validation.Errors); + throw new Core.Exceptions.ValidationException( + validation.ToValidationErrors() + ); } - return CrudEndpoints.Create( + return await CrudEndpoints.Create( () => map.CreateBasemapAsync(request), b => $"{MapConstants.RoutePrefix}/basemaps/{b.Id}" ); diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/CreateBasemapRequestValidator.cs b/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/CreateBasemapRequestValidator.cs index 089997c7..3c742838 100644 --- a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/CreateBasemapRequestValidator.cs +++ b/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/CreateBasemapRequestValidator.cs @@ -1,22 +1,17 @@ -using SimpleModule.Core.Validation; +using FluentValidation; using SimpleModule.Map.Contracts; namespace SimpleModule.Map.Endpoints.Basemaps; -public static class CreateBasemapRequestValidator +public sealed class CreateBasemapRequestValidator : AbstractValidator { - public static ValidationResult Validate(CreateBasemapRequest request) => - new ValidationBuilder() - .AddErrorIf(string.IsNullOrWhiteSpace(request.Name), "Name", "Name is required.") - .AddErrorIf( - string.IsNullOrWhiteSpace(request.StyleUrl), - "StyleUrl", - "StyleUrl is required." - ) - .AddErrorIf( - request.StyleUrl?.Length > 2048, - "StyleUrl", - "StyleUrl must be 2048 characters or fewer." - ) - .Build(); + public CreateBasemapRequestValidator() + { + RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required."); + RuleFor(x => x.StyleUrl) + .NotEmpty() + .WithMessage("StyleUrl is required.") + .MaximumLength(2048) + .WithMessage("StyleUrl must be 2048 characters or fewer."); + } } diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/UpdateBasemapEndpoint.cs b/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/UpdateBasemapEndpoint.cs index ef6e0851..6c635838 100644 --- a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/UpdateBasemapEndpoint.cs +++ b/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/UpdateBasemapEndpoint.cs @@ -1,9 +1,10 @@ +using FluentValidation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; -using SimpleModule.Core.Exceptions; +using SimpleModule.Core.Validation; using SimpleModule.Map.Contracts; namespace SimpleModule.Map.Endpoints.Basemaps; @@ -16,15 +17,22 @@ public class UpdateBasemapEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPut( Route, - (BasemapId id, UpdateBasemapRequest request, IMapContracts map) => + async ( + BasemapId id, + UpdateBasemapRequest request, + IValidator validator, + IMapContracts map + ) => { - var validation = UpdateBasemapRequestValidator.Validate(request); + var validation = await validator.ValidateAsync(request); if (!validation.IsValid) { - throw new ValidationException(validation.Errors); + throw new Core.Exceptions.ValidationException( + validation.ToValidationErrors() + ); } - return CrudEndpoints.Update(() => map.UpdateBasemapAsync(id, request)); + return await CrudEndpoints.Update(() => map.UpdateBasemapAsync(id, request)); } ) .RequirePermission(MapPermissions.ManageSources); diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/UpdateBasemapRequestValidator.cs b/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/UpdateBasemapRequestValidator.cs index ea91228a..4ff8bb8f 100644 --- a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/UpdateBasemapRequestValidator.cs +++ b/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/UpdateBasemapRequestValidator.cs @@ -1,22 +1,17 @@ -using SimpleModule.Core.Validation; +using FluentValidation; using SimpleModule.Map.Contracts; namespace SimpleModule.Map.Endpoints.Basemaps; -public static class UpdateBasemapRequestValidator +public sealed class UpdateBasemapRequestValidator : AbstractValidator { - public static ValidationResult Validate(UpdateBasemapRequest request) => - new ValidationBuilder() - .AddErrorIf(string.IsNullOrWhiteSpace(request.Name), "Name", "Name is required.") - .AddErrorIf( - string.IsNullOrWhiteSpace(request.StyleUrl), - "StyleUrl", - "StyleUrl is required." - ) - .AddErrorIf( - request.StyleUrl?.Length > 2048, - "StyleUrl", - "StyleUrl must be 2048 characters or fewer." - ) - .Build(); + public UpdateBasemapRequestValidator() + { + RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required."); + RuleFor(x => x.StyleUrl) + .NotEmpty() + .WithMessage("StyleUrl is required.") + .MaximumLength(2048) + .WithMessage("StyleUrl must be 2048 characters or fewer."); + } } diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/DefaultMap/UpdateDefaultMapEndpoint.cs b/modules/Map/src/SimpleModule.Map/Endpoints/DefaultMap/UpdateDefaultMapEndpoint.cs index defa243a..a8715164 100644 --- a/modules/Map/src/SimpleModule.Map/Endpoints/DefaultMap/UpdateDefaultMapEndpoint.cs +++ b/modules/Map/src/SimpleModule.Map/Endpoints/DefaultMap/UpdateDefaultMapEndpoint.cs @@ -1,10 +1,10 @@ +using FluentValidation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Options; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; -using SimpleModule.Core.Exceptions; +using SimpleModule.Core.Validation; using SimpleModule.Map.Contracts; namespace SimpleModule.Map.Endpoints.DefaultMap; @@ -17,22 +17,21 @@ public class UpdateDefaultMapEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPut( Route, - ( + async ( UpdateDefaultMapRequest request, - IMapContracts map, - IOptions options + IValidator validator, + IMapContracts map ) => { - var validation = UpdateDefaultMapRequestValidator.Validate( - request, - options.Value.MaxLayersPerMap - ); + var validation = await validator.ValidateAsync(request); if (!validation.IsValid) { - throw new ValidationException(validation.Errors); + throw new Core.Exceptions.ValidationException( + validation.ToValidationErrors() + ); } - return CrudEndpoints.Update(() => map.UpdateDefaultMapAsync(request)); + return await CrudEndpoints.Update(() => map.UpdateDefaultMapAsync(request)); } ) .RequirePermission(MapPermissions.Update); diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/DefaultMap/UpdateDefaultMapRequestValidator.cs b/modules/Map/src/SimpleModule.Map/Endpoints/DefaultMap/UpdateDefaultMapRequestValidator.cs index 4677184f..51c459a8 100644 --- a/modules/Map/src/SimpleModule.Map/Endpoints/DefaultMap/UpdateDefaultMapRequestValidator.cs +++ b/modules/Map/src/SimpleModule.Map/Endpoints/DefaultMap/UpdateDefaultMapRequestValidator.cs @@ -1,27 +1,24 @@ -using SimpleModule.Core.Validation; +using FluentValidation; +using Microsoft.Extensions.Options; using SimpleModule.Map.Contracts; namespace SimpleModule.Map.Endpoints.DefaultMap; -public static class UpdateDefaultMapRequestValidator +public sealed class UpdateDefaultMapRequestValidator : AbstractValidator { - public static ValidationResult Validate(UpdateDefaultMapRequest request, int maxLayers) => - new ValidationBuilder() - .AddErrorIf( - request.CenterLat is < -90 or > 90, - "CenterLat", - "Latitude must be between -90 and 90." - ) - .AddErrorIf( - request.CenterLng is < -180 or > 180, - "CenterLng", - "Longitude must be between -180 and 180." - ) - .AddErrorIf(request.Zoom is < 0 or > 24, "Zoom", "Zoom must be between 0 and 24.") - .AddErrorIf( - request.Layers?.Count > maxLayers, - "Layers", - $"A map may not contain more than {maxLayers} layers." - ) - .Build(); + public UpdateDefaultMapRequestValidator(IOptions options) + { + var maxLayers = options.Value.MaxLayersPerMap; + + RuleFor(x => x.CenterLat) + .InclusiveBetween(-90, 90) + .WithMessage("Latitude must be between -90 and 90."); + RuleFor(x => x.CenterLng) + .InclusiveBetween(-180, 180) + .WithMessage("Longitude must be between -180 and 180."); + RuleFor(x => x.Zoom).InclusiveBetween(0, 24).WithMessage("Zoom must be between 0 and 24."); + RuleFor(x => x.Layers) + .Must(layers => layers is null || layers.Count <= maxLayers) + .WithMessage($"A map may not contain more than {maxLayers} layers."); + } } diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/CreateEndpoint.cs b/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/CreateEndpoint.cs index 5d636bec..d96b54f1 100644 --- a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/CreateEndpoint.cs +++ b/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/CreateEndpoint.cs @@ -1,9 +1,10 @@ +using FluentValidation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; -using SimpleModule.Core.Exceptions; +using SimpleModule.Core.Validation; using SimpleModule.Map.Contracts; namespace SimpleModule.Map.Endpoints.LayerSources; @@ -16,15 +17,21 @@ public class CreateLayerSourceEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPost( Route, - (CreateLayerSourceRequest request, IMapContracts map) => + async ( + CreateLayerSourceRequest request, + IValidator validator, + IMapContracts map + ) => { - var validation = CreateLayerSourceRequestValidator.Validate(request); + var validation = await validator.ValidateAsync(request); if (!validation.IsValid) { - throw new ValidationException(validation.Errors); + throw new Core.Exceptions.ValidationException( + validation.ToValidationErrors() + ); } - return CrudEndpoints.Create( + return await CrudEndpoints.Create( () => map.CreateLayerSourceAsync(request), s => $"{MapConstants.RoutePrefix}/sources/{s.Id}" ); diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/CreateLayerSourceRequestValidator.cs b/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/CreateLayerSourceRequestValidator.cs index 6368df39..a12a7583 100644 --- a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/CreateLayerSourceRequestValidator.cs +++ b/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/CreateLayerSourceRequestValidator.cs @@ -1,19 +1,20 @@ -using SimpleModule.Core.Validation; +using FluentValidation; using SimpleModule.Map.Contracts; namespace SimpleModule.Map.Endpoints.LayerSources; -public static class CreateLayerSourceRequestValidator +public sealed class CreateLayerSourceRequestValidator : AbstractValidator { - public static ValidationResult Validate(CreateLayerSourceRequest request) => - new ValidationBuilder() - .AddErrorIf(string.IsNullOrWhiteSpace(request.Name), "Name", "Name is required.") - .AddErrorIf(string.IsNullOrWhiteSpace(request.Url), "Url", "Url is required.") - .AddErrorIf(request.Url?.Length > 2048, "Url", "Url must be 2048 characters or fewer.") - .AddErrorIf( - !Enum.IsDefined(request.Type), - "Type", - "Type must be a known LayerSourceType." - ) - .Build(); + public CreateLayerSourceRequestValidator() + { + RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required."); + RuleFor(x => x.Url) + .NotEmpty() + .WithMessage("Url is required.") + .MaximumLength(2048) + .WithMessage("Url must be 2048 characters or fewer."); + RuleFor(x => x.Type) + .Must(t => Enum.IsDefined(t)) + .WithMessage("Type must be a known LayerSourceType."); + } } diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/UpdateEndpoint.cs b/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/UpdateEndpoint.cs index c9454d5b..d39c5a28 100644 --- a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/UpdateEndpoint.cs +++ b/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/UpdateEndpoint.cs @@ -1,9 +1,10 @@ +using FluentValidation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; -using SimpleModule.Core.Exceptions; +using SimpleModule.Core.Validation; using SimpleModule.Map.Contracts; namespace SimpleModule.Map.Endpoints.LayerSources; @@ -16,15 +17,24 @@ public class UpdateLayerSourceEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPut( Route, - (LayerSourceId id, UpdateLayerSourceRequest request, IMapContracts map) => + async ( + LayerSourceId id, + UpdateLayerSourceRequest request, + IValidator validator, + IMapContracts map + ) => { - var validation = UpdateLayerSourceRequestValidator.Validate(request); + var validation = await validator.ValidateAsync(request); if (!validation.IsValid) { - throw new ValidationException(validation.Errors); + throw new Core.Exceptions.ValidationException( + validation.ToValidationErrors() + ); } - return CrudEndpoints.Update(() => map.UpdateLayerSourceAsync(id, request)); + return await CrudEndpoints.Update(() => + map.UpdateLayerSourceAsync(id, request) + ); } ) .RequirePermission(MapPermissions.ManageSources); diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/UpdateLayerSourceRequestValidator.cs b/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/UpdateLayerSourceRequestValidator.cs index c294c291..e546a8a9 100644 --- a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/UpdateLayerSourceRequestValidator.cs +++ b/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/UpdateLayerSourceRequestValidator.cs @@ -1,19 +1,20 @@ -using SimpleModule.Core.Validation; +using FluentValidation; using SimpleModule.Map.Contracts; namespace SimpleModule.Map.Endpoints.LayerSources; -public static class UpdateLayerSourceRequestValidator +public sealed class UpdateLayerSourceRequestValidator : AbstractValidator { - public static ValidationResult Validate(UpdateLayerSourceRequest request) => - new ValidationBuilder() - .AddErrorIf(string.IsNullOrWhiteSpace(request.Name), "Name", "Name is required.") - .AddErrorIf(string.IsNullOrWhiteSpace(request.Url), "Url", "Url is required.") - .AddErrorIf(request.Url?.Length > 2048, "Url", "Url must be 2048 characters or fewer.") - .AddErrorIf( - !Enum.IsDefined(request.Type), - "Type", - "Type must be a known LayerSourceType." - ) - .Build(); + public UpdateLayerSourceRequestValidator() + { + RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required."); + RuleFor(x => x.Url) + .NotEmpty() + .WithMessage("Url is required.") + .MaximumLength(2048) + .WithMessage("Url must be 2048 characters or fewer."); + RuleFor(x => x.Type) + .Must(t => Enum.IsDefined(t)) + .WithMessage("Type must be a known LayerSourceType."); + } } diff --git a/modules/Map/src/SimpleModule.Map/MapModule.cs b/modules/Map/src/SimpleModule.Map/MapModule.cs index 7c90f9f9..8f94383f 100644 --- a/modules/Map/src/SimpleModule.Map/MapModule.cs +++ b/modules/Map/src/SimpleModule.Map/MapModule.cs @@ -1,3 +1,4 @@ +using FluentValidation; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SimpleModule.Core; @@ -32,6 +33,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config enableSpatial: enableSpatial ); services.AddScoped(); + services.AddValidatorsFromAssemblyContaining(); } public void ConfigureMenu(IMenuBuilder menus) diff --git a/modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/CreateEndpoint.cs b/modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/CreateEndpoint.cs index 6e7f2846..7915d7a2 100644 --- a/modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/CreateEndpoint.cs +++ b/modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/CreateEndpoint.cs @@ -1,9 +1,10 @@ +using FluentValidation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; -using SimpleModule.Core.Exceptions; +using SimpleModule.Core.Validation; using SimpleModule.Orders.Contracts; using OrdersConstants = SimpleModule.Orders.Contracts.OrdersConstants; @@ -17,15 +18,21 @@ public class CreateEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPost( Route, - (CreateOrderRequest request, IOrderContracts orderContracts) => + async ( + CreateOrderRequest request, + IValidator validator, + IOrderContracts orderContracts + ) => { - var validation = CreateRequestValidator.Validate(request); + var validation = await validator.ValidateAsync(request); if (!validation.IsValid) { - throw new ValidationException(validation.Errors); + throw new Core.Exceptions.ValidationException( + validation.ToValidationErrors() + ); } - return CrudEndpoints.Create( + return await CrudEndpoints.Create( () => orderContracts.CreateOrderAsync(request), o => $"{OrdersConstants.RoutePrefix}/{o.Id}" ); diff --git a/modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/CreateRequestValidator.cs b/modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/CreateRequestValidator.cs index 16617b96..f4dd9e95 100644 --- a/modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/CreateRequestValidator.cs +++ b/modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/CreateRequestValidator.cs @@ -1,42 +1,54 @@ using System.Globalization; using System.Text; -using SimpleModule.Core.Validation; +using FluentValidation; +using FluentValidation.Results; using SimpleModule.Orders.Contracts; namespace SimpleModule.Orders.Endpoints.Orders; -public static class CreateRequestValidator +public sealed class CreateRequestValidator : AbstractValidator { private static readonly CompositeFormat QuantityMustBePositiveFormat = CompositeFormat.Parse( OrdersConstants.ValidationMessages.QuantityMustBePositiveFormat ); - public static ValidationResult Validate(CreateOrderRequest request) + public CreateRequestValidator() { - var builder = new ValidationBuilder() - .AddErrorIf( - string.IsNullOrWhiteSpace(request.UserId), - OrdersConstants.Fields.UserId, - OrdersConstants.ValidationMessages.UserIdRequired - ) - .AddErrorIf( - request.Items is null || request.Items.Count == 0, - OrdersConstants.Fields.Items, - OrdersConstants.ValidationMessages.AtLeastOneItemRequired - ); + RuleFor(x => x.UserId) + .NotEmpty() + .WithName(OrdersConstants.Fields.UserId) + .WithMessage(OrdersConstants.ValidationMessages.UserIdRequired); - if (request.Items is { Count: > 0 }) - { - for (var i = 0; i < request.Items.Count; i++) - { - builder.AddErrorIf( - request.Items[i].Quantity <= 0, - OrdersConstants.Fields.Items, - string.Format(CultureInfo.InvariantCulture, QuantityMustBePositiveFormat, i) - ); - } - } + RuleFor(x => x.Items) + .NotEmpty() + .WithName(OrdersConstants.Fields.Items) + .WithMessage(OrdersConstants.ValidationMessages.AtLeastOneItemRequired); - return builder.Build(); + // Items[i].Quantity > 0 — emitted under the "Items" field so callers see + // a single aggregated errors-by-field dictionary, matching the prior + // ValidationBuilder shape. + RuleFor(x => x.Items) + .Custom( + (items, context) => + { + for (var i = 0; i < items.Count; i++) + { + if (items[i].Quantity <= 0) + { + context.AddFailure( + new ValidationFailure( + OrdersConstants.Fields.Items, + string.Format( + CultureInfo.InvariantCulture, + QuantityMustBePositiveFormat, + i + ) + ) + ); + } + } + } + ) + .When(x => x.Items is { Count: > 0 }); } } diff --git a/modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/UpdateEndpoint.cs b/modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/UpdateEndpoint.cs index cb8569fc..27a99fba 100644 --- a/modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/UpdateEndpoint.cs +++ b/modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/UpdateEndpoint.cs @@ -1,9 +1,10 @@ +using FluentValidation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; -using SimpleModule.Core.Exceptions; +using SimpleModule.Core.Validation; using SimpleModule.Orders.Contracts; using OrdersConstants = SimpleModule.Orders.Contracts.OrdersConstants; @@ -17,20 +18,29 @@ public class UpdateEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPut( Route, - (OrderId id, UpdateOrderRequest request, IOrderContracts orderContracts) => + async ( + OrderId id, + UpdateOrderRequest request, + IValidator validator, + IOrderContracts orderContracts + ) => { var createRequest = new CreateOrderRequest { UserId = request.UserId, Items = request.Items, }; - var validation = CreateRequestValidator.Validate(createRequest); + var validation = await validator.ValidateAsync(createRequest); if (!validation.IsValid) { - throw new ValidationException(validation.Errors); + throw new Core.Exceptions.ValidationException( + validation.ToValidationErrors() + ); } - return CrudEndpoints.Update(() => orderContracts.UpdateOrderAsync(id, request)); + return await CrudEndpoints.Update(() => + orderContracts.UpdateOrderAsync(id, request) + ); } ) .RequirePermission(OrdersPermissions.Update); diff --git a/modules/Orders/src/SimpleModule.Orders/OrdersModule.cs b/modules/Orders/src/SimpleModule.Orders/OrdersModule.cs index 81fa41ca..2e1e38ab 100644 --- a/modules/Orders/src/SimpleModule.Orders/OrdersModule.cs +++ b/modules/Orders/src/SimpleModule.Orders/OrdersModule.cs @@ -1,3 +1,4 @@ +using FluentValidation; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SimpleModule.Core; @@ -17,6 +18,7 @@ public class OrdersModule : IModule public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { services.AddModuleDbContext(configuration, OrdersConstants.ModuleName); + services.AddValidatorsFromAssemblyContaining(); services.AddHostedService(); } diff --git a/modules/Orders/tests/SimpleModule.Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs b/modules/Orders/tests/SimpleModule.Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs index a01a546d..4b4a7b8f 100644 --- a/modules/Orders/tests/SimpleModule.Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs +++ b/modules/Orders/tests/SimpleModule.Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs @@ -6,6 +6,8 @@ namespace Orders.Tests.Unit; public sealed class CreateRequestValidatorTests { + private readonly CreateRequestValidator _validator = new(); + [Fact] public void Validate_WithValidRequest_ReturnsSuccess() { @@ -15,7 +17,7 @@ public void Validate_WithValidRequest_ReturnsSuccess() Items = [new OrderItem { ProductId = 1, Quantity = 2 }], }; - var result = CreateRequestValidator.Validate(request); + var result = _validator.Validate(request); result.IsValid.Should().BeTrue(); result.Errors.Should().BeEmpty(); @@ -29,10 +31,10 @@ public void Validate_WithDefaultUserId_ReturnsError() Items = [new OrderItem { ProductId = 1, Quantity = 1 }], }; - var result = CreateRequestValidator.Validate(request); + var result = _validator.Validate(request); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("UserId"); + result.Errors.Should().Contain(e => e.PropertyName == "UserId"); } [Fact] @@ -40,10 +42,10 @@ public void Validate_WithEmptyItems_ReturnsError() { var request = new CreateOrderRequest { UserId = "1", Items = [] }; - var result = CreateRequestValidator.Validate(request); + var result = _validator.Validate(request); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("Items"); + result.Errors.Should().Contain(e => e.PropertyName == "Items"); } [Fact] @@ -55,10 +57,10 @@ public void Validate_WithZeroQuantity_ReturnsError() Items = [new OrderItem { ProductId = 1, Quantity = 0 }], }; - var result = CreateRequestValidator.Validate(request); + var result = _validator.Validate(request); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("Items"); + result.Errors.Should().Contain(e => e.PropertyName == "Items"); } [Fact] @@ -66,10 +68,10 @@ public void Validate_WithMultipleErrors_ReturnsAllErrors() { var request = new CreateOrderRequest { Items = [] }; - var result = CreateRequestValidator.Validate(request); + var result = _validator.Validate(request); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("UserId"); - result.Errors.Should().ContainKey("Items"); + result.Errors.Should().Contain(e => e.PropertyName == "UserId"); + result.Errors.Should().Contain(e => e.PropertyName == "Items"); } } diff --git a/modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/CreateEndpoint.cs b/modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/CreateEndpoint.cs index d57191e1..d86e86cc 100644 --- a/modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/CreateEndpoint.cs +++ b/modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/CreateEndpoint.cs @@ -1,9 +1,9 @@ +using FluentValidation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; -using SimpleModule.Core.Exceptions; using SimpleModule.Core.Validation; using SimpleModule.PageBuilder.Contracts; @@ -17,22 +17,21 @@ public class CreateEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPost( Route, - (CreatePageRequest request, IPageBuilderContracts pageBuilder) => + async ( + CreatePageRequest request, + IValidator validator, + IPageBuilderContracts pageBuilder + ) => { - var validation = new ValidationBuilder() - .AddErrorIf( - string.IsNullOrWhiteSpace(request.Title), - "Title", - "Page title is required." - ) - .Build(); - + var validation = await validator.ValidateAsync(request); if (!validation.IsValid) { - throw new ValidationException(validation.Errors); + throw new Core.Exceptions.ValidationException( + validation.ToValidationErrors() + ); } - return CrudEndpoints.Create( + return await CrudEndpoints.Create( () => pageBuilder.CreatePageAsync(request), p => $"{PageBuilderConstants.RoutePrefix}/{p.Id}" ); diff --git a/modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/CreatePageRequestValidator.cs b/modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/CreatePageRequestValidator.cs new file mode 100644 index 00000000..f54242e6 --- /dev/null +++ b/modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/CreatePageRequestValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; +using SimpleModule.PageBuilder.Contracts; + +namespace SimpleModule.PageBuilder.Endpoints.Pages; + +public sealed class CreatePageRequestValidator : AbstractValidator +{ + public CreatePageRequestValidator() + { + RuleFor(x => x.Title).NotEmpty().WithMessage("Page title is required."); + } +} diff --git a/modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/UpdateEndpoint.cs b/modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/UpdateEndpoint.cs index 52ba290b..25d52f16 100644 --- a/modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/UpdateEndpoint.cs +++ b/modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/UpdateEndpoint.cs @@ -1,9 +1,9 @@ +using FluentValidation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; -using SimpleModule.Core.Exceptions; using SimpleModule.Core.Validation; using SimpleModule.PageBuilder.Contracts; @@ -17,27 +17,24 @@ public class UpdateEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPut( Route, - (PageId id, UpdatePageRequest request, IPageBuilderContracts pageBuilder) => + async ( + PageId id, + UpdatePageRequest request, + IValidator validator, + IPageBuilderContracts pageBuilder + ) => { - var validation = new ValidationBuilder() - .AddErrorIf( - string.IsNullOrWhiteSpace(request.Title), - "Title", - "Page title is required." - ) - .AddErrorIf( - string.IsNullOrWhiteSpace(request.Slug), - "Slug", - "Page slug is required." - ) - .Build(); - + var validation = await validator.ValidateAsync(request); if (!validation.IsValid) { - throw new ValidationException(validation.Errors); + throw new Core.Exceptions.ValidationException( + validation.ToValidationErrors() + ); } - return CrudEndpoints.Update(() => pageBuilder.UpdatePageAsync(id, request)); + return await CrudEndpoints.Update(() => + pageBuilder.UpdatePageAsync(id, request) + ); } ) .RequirePermission(PageBuilderPermissions.Update); diff --git a/modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/UpdatePageRequestValidator.cs b/modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/UpdatePageRequestValidator.cs new file mode 100644 index 00000000..681255bd --- /dev/null +++ b/modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/UpdatePageRequestValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using SimpleModule.PageBuilder.Contracts; + +namespace SimpleModule.PageBuilder.Endpoints.Pages; + +public sealed class UpdatePageRequestValidator : AbstractValidator +{ + public UpdatePageRequestValidator() + { + RuleFor(x => x.Title).NotEmpty().WithMessage("Page title is required."); + RuleFor(x => x.Slug).NotEmpty().WithMessage("Page slug is required."); + } +} diff --git a/modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderModule.cs b/modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderModule.cs index c08b06df..d56eec74 100644 --- a/modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderModule.cs +++ b/modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderModule.cs @@ -1,3 +1,4 @@ +using FluentValidation; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SimpleModule.Core; @@ -20,6 +21,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config configuration, PageBuilderConstants.ModuleName ); + services.AddValidatorsFromAssemblyContaining(); } public void ConfigureMenu(IMenuBuilder menus) diff --git a/modules/Products/src/SimpleModule.Products/Endpoints/Products/CreateEndpoint.cs b/modules/Products/src/SimpleModule.Products/Endpoints/Products/CreateEndpoint.cs index 8cf34220..04c06b32 100644 --- a/modules/Products/src/SimpleModule.Products/Endpoints/Products/CreateEndpoint.cs +++ b/modules/Products/src/SimpleModule.Products/Endpoints/Products/CreateEndpoint.cs @@ -1,9 +1,10 @@ +using FluentValidation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; -using SimpleModule.Core.Exceptions; +using SimpleModule.Core.Validation; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; @@ -16,15 +17,21 @@ public class CreateEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPost( Route, - (CreateProductRequest request, IProductContracts productContracts) => + async ( + CreateProductRequest request, + IValidator validator, + IProductContracts productContracts + ) => { - var validation = CreateRequestValidator.Validate(request); + var validation = await validator.ValidateAsync(request); if (!validation.IsValid) { - throw new ValidationException(validation.Errors); + throw new Core.Exceptions.ValidationException( + validation.ToValidationErrors() + ); } - return CrudEndpoints.Create( + return await CrudEndpoints.Create( () => productContracts.CreateProductAsync(request), p => $"{ProductsConstants.RoutePrefix}/{p.Id}" ); diff --git a/modules/Products/src/SimpleModule.Products/Endpoints/Products/CreateRequestValidator.cs b/modules/Products/src/SimpleModule.Products/Endpoints/Products/CreateRequestValidator.cs index bdf485e2..44340656 100644 --- a/modules/Products/src/SimpleModule.Products/Endpoints/Products/CreateRequestValidator.cs +++ b/modules/Products/src/SimpleModule.Products/Endpoints/Products/CreateRequestValidator.cs @@ -1,17 +1,13 @@ -using SimpleModule.Core.Validation; +using FluentValidation; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; -public static class CreateRequestValidator +public sealed class CreateRequestValidator : AbstractValidator { - public static ValidationResult Validate(CreateProductRequest request) => - new ValidationBuilder() - .AddErrorIf( - string.IsNullOrWhiteSpace(request.Name), - "Name", - "Product name is required." - ) - .AddErrorIf(request.Price <= 0, "Price", "Price must be greater than zero.") - .Build(); + public CreateRequestValidator() + { + RuleFor(x => x.Name).NotEmpty().WithMessage("Product name is required."); + RuleFor(x => x.Price).GreaterThan(0).WithMessage("Price must be greater than zero."); + } } diff --git a/modules/Products/src/SimpleModule.Products/Endpoints/Products/UpdateEndpoint.cs b/modules/Products/src/SimpleModule.Products/Endpoints/Products/UpdateEndpoint.cs index 8033ad62..222a1d3b 100644 --- a/modules/Products/src/SimpleModule.Products/Endpoints/Products/UpdateEndpoint.cs +++ b/modules/Products/src/SimpleModule.Products/Endpoints/Products/UpdateEndpoint.cs @@ -1,9 +1,10 @@ +using FluentValidation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; -using SimpleModule.Core.Exceptions; +using SimpleModule.Core.Validation; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; @@ -16,15 +17,22 @@ public class UpdateEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPut( Route, - (ProductId id, UpdateProductRequest request, IProductContracts productContracts) => + async ( + ProductId id, + UpdateProductRequest request, + IValidator validator, + IProductContracts productContracts + ) => { - var validation = UpdateRequestValidator.Validate(request); + var validation = await validator.ValidateAsync(request); if (!validation.IsValid) { - throw new ValidationException(validation.Errors); + throw new Core.Exceptions.ValidationException( + validation.ToValidationErrors() + ); } - return CrudEndpoints.Update(() => + return await CrudEndpoints.Update(() => productContracts.UpdateProductAsync(id, request) ); } diff --git a/modules/Products/src/SimpleModule.Products/Endpoints/Products/UpdateRequestValidator.cs b/modules/Products/src/SimpleModule.Products/Endpoints/Products/UpdateRequestValidator.cs index 45667d27..6ebd14a2 100644 --- a/modules/Products/src/SimpleModule.Products/Endpoints/Products/UpdateRequestValidator.cs +++ b/modules/Products/src/SimpleModule.Products/Endpoints/Products/UpdateRequestValidator.cs @@ -1,17 +1,13 @@ -using SimpleModule.Core.Validation; +using FluentValidation; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; -public static class UpdateRequestValidator +public sealed class UpdateRequestValidator : AbstractValidator { - public static ValidationResult Validate(UpdateProductRequest request) => - new ValidationBuilder() - .AddErrorIf( - string.IsNullOrWhiteSpace(request.Name), - "Name", - "Product name is required." - ) - .AddErrorIf(request.Price <= 0, "Price", "Price must be greater than zero.") - .Build(); + public UpdateRequestValidator() + { + RuleFor(x => x.Name).NotEmpty().WithMessage("Product name is required."); + RuleFor(x => x.Price).GreaterThan(0).WithMessage("Price must be greater than zero."); + } } diff --git a/modules/Products/src/SimpleModule.Products/ProductsModule.cs b/modules/Products/src/SimpleModule.Products/ProductsModule.cs index 85c55a00..f10c41bb 100644 --- a/modules/Products/src/SimpleModule.Products/ProductsModule.cs +++ b/modules/Products/src/SimpleModule.Products/ProductsModule.cs @@ -1,3 +1,4 @@ +using FluentValidation; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SimpleModule.Core; @@ -18,6 +19,7 @@ public class ProductsModule : IModule, IModuleServices, IModuleMenu public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { services.AddModuleDbContext(configuration, ProductsConstants.ModuleName); + services.AddValidatorsFromAssemblyContaining(); } public void ConfigureFeatureFlags(IFeatureFlagBuilder builder) diff --git a/modules/Products/tests/SimpleModule.Products.Tests/Unit/CreateRequestValidatorTests.cs b/modules/Products/tests/SimpleModule.Products.Tests/Unit/CreateRequestValidatorTests.cs index bb011e0a..3ca4b795 100644 --- a/modules/Products/tests/SimpleModule.Products.Tests/Unit/CreateRequestValidatorTests.cs +++ b/modules/Products/tests/SimpleModule.Products.Tests/Unit/CreateRequestValidatorTests.cs @@ -6,12 +6,14 @@ namespace Products.Tests.Unit; public class CreateRequestValidatorTests { + private readonly CreateRequestValidator _validator = new(); + [Fact] public void Validate_WithValidRequest_ReturnsSuccess() { var request = new CreateProductRequest { Name = "Widget", Price = 9.99m }; - var result = CreateRequestValidator.Validate(request); + var result = _validator.Validate(request); result.IsValid.Should().BeTrue(); } @@ -21,10 +23,10 @@ public void Validate_WithEmptyName_ReturnsError() { var request = new CreateProductRequest { Name = "", Price = 9.99m }; - var result = CreateRequestValidator.Validate(request); + var result = _validator.Validate(request); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("Name"); + result.Errors.Should().Contain(e => e.PropertyName == "Name"); } [Fact] @@ -32,9 +34,9 @@ public void Validate_WithZeroPrice_ReturnsError() { var request = new CreateProductRequest { Name = "Widget", Price = 0 }; - var result = CreateRequestValidator.Validate(request); + var result = _validator.Validate(request); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("Price"); + result.Errors.Should().Contain(e => e.PropertyName == "Price"); } } diff --git a/modules/Products/tests/SimpleModule.Products.Tests/Unit/UpdateRequestValidatorTests.cs b/modules/Products/tests/SimpleModule.Products.Tests/Unit/UpdateRequestValidatorTests.cs index 523bf5bc..fb76c6d5 100644 --- a/modules/Products/tests/SimpleModule.Products.Tests/Unit/UpdateRequestValidatorTests.cs +++ b/modules/Products/tests/SimpleModule.Products.Tests/Unit/UpdateRequestValidatorTests.cs @@ -6,12 +6,14 @@ namespace Products.Tests.Unit; public class UpdateRequestValidatorTests { + private readonly UpdateRequestValidator _validator = new(); + [Fact] public void Validate_WithValidRequest_ReturnsSuccess() { var request = new UpdateProductRequest { Name = "Widget", Price = 9.99m }; - var result = UpdateRequestValidator.Validate(request); + var result = _validator.Validate(request); result.IsValid.Should().BeTrue(); } @@ -21,10 +23,10 @@ public void Validate_WithEmptyName_ReturnsError() { var request = new UpdateProductRequest { Name = "", Price = 9.99m }; - var result = UpdateRequestValidator.Validate(request); + var result = _validator.Validate(request); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("Name"); + result.Errors.Should().Contain(e => e.PropertyName == "Name"); } [Fact] @@ -32,10 +34,10 @@ public void Validate_WithWhitespaceOnlyName_ReturnsError() { var request = new UpdateProductRequest { Name = " ", Price = 9.99m }; - var result = UpdateRequestValidator.Validate(request); + var result = _validator.Validate(request); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("Name"); + result.Errors.Should().Contain(e => e.PropertyName == "Name"); } [Fact] @@ -43,10 +45,10 @@ public void Validate_WithZeroPrice_ReturnsError() { var request = new UpdateProductRequest { Name = "Widget", Price = 0m }; - var result = UpdateRequestValidator.Validate(request); + var result = _validator.Validate(request); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("Price"); + result.Errors.Should().Contain(e => e.PropertyName == "Price"); } [Fact] @@ -54,9 +56,9 @@ public void Validate_WithNegativePrice_ReturnsError() { var request = new UpdateProductRequest { Name = "Widget", Price = -5.00m }; - var result = UpdateRequestValidator.Validate(request); + var result = _validator.Validate(request); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("Price"); + result.Errors.Should().Contain(e => e.PropertyName == "Price"); } } diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/CreateEndpoint.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/CreateEndpoint.cs index ce531145..dfcefacf 100644 --- a/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/CreateEndpoint.cs +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/CreateEndpoint.cs @@ -1,9 +1,10 @@ +using FluentValidation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; -using SimpleModule.Core.Exceptions; +using SimpleModule.Core.Validation; using SimpleModule.RateLimiting.Contracts; namespace SimpleModule.RateLimiting.Endpoints.Policies; @@ -16,15 +17,21 @@ public class CreateEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPost( Route, - (CreateRateLimitRuleRequest request, IRateLimitingContracts contracts) => + async ( + CreateRateLimitRuleRequest request, + IValidator validator, + IRateLimitingContracts contracts + ) => { - var validation = CreateRequestValidator.Validate(request); + var validation = await validator.ValidateAsync(request); if (!validation.IsValid) { - throw new ValidationException(validation.Errors); + throw new Core.Exceptions.ValidationException( + validation.ToValidationErrors() + ); } - return CrudEndpoints.Create( + return await CrudEndpoints.Create( () => contracts.CreateRuleAsync(request), r => $"{RateLimitingConstants.RoutePrefix}/{r.Id}" ); diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/CreateRequestValidator.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/CreateRequestValidator.cs index 834e16bc..6ebe68fd 100644 --- a/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/CreateRequestValidator.cs +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/CreateRequestValidator.cs @@ -1,21 +1,15 @@ -using SimpleModule.Core.Validation; +using FluentValidation; using SimpleModule.RateLimiting.Contracts; namespace SimpleModule.RateLimiting.Endpoints.Policies; -public static class CreateRequestValidator +public sealed class CreateRequestValidator : AbstractValidator { - public static ValidationResult Validate(CreateRateLimitRuleRequest request) => - new ValidationBuilder() - .AddErrorIf( - string.IsNullOrWhiteSpace(request.PolicyName), - "PolicyName", - "Policy name is required." - ) - .AddErrorIf( - request.PermitLimit <= 0, - "PermitLimit", - "Permit limit must be greater than zero." - ) - .Build(); + public CreateRequestValidator() + { + RuleFor(x => x.PolicyName).NotEmpty().WithMessage("Policy name is required."); + RuleFor(x => x.PermitLimit) + .GreaterThan(0) + .WithMessage("Permit limit must be greater than zero."); + } } diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingModule.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingModule.cs index 2044ccab..b9627df9 100644 --- a/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingModule.cs +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingModule.cs @@ -1,3 +1,4 @@ +using FluentValidation; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SimpleModule.Core; @@ -20,6 +21,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config configuration, RateLimitingConstants.ModuleName ); + services.AddValidatorsFromAssemblyContaining(); } public void ConfigureRateLimits(IRateLimitBuilder builder) diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/AddHostEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/AddHostEndpoint.cs index 3b7208d7..32490861 100644 --- a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/AddHostEndpoint.cs +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/AddHostEndpoint.cs @@ -1,9 +1,10 @@ +using FluentValidation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Authorization; -using SimpleModule.Core.Exceptions; +using SimpleModule.Core.Validation; using SimpleModule.Tenants.Contracts; namespace SimpleModule.Tenants.Endpoints.Tenants; @@ -16,12 +17,19 @@ public class AddHostEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPost( Route, - async (TenantId id, AddTenantHostRequest request, ITenantContracts contracts) => + async ( + TenantId id, + AddTenantHostRequest request, + IValidator validator, + ITenantContracts contracts + ) => { - var validation = AddHostRequestValidator.Validate(request); + var validation = await validator.ValidateAsync(request); if (!validation.IsValid) { - throw new ValidationException(validation.Errors); + throw new Core.Exceptions.ValidationException( + validation.ToValidationErrors() + ); } var host = await contracts.AddHostAsync(id, request); diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/AddHostRequestValidator.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/AddHostRequestValidator.cs index 6f949cef..dd194397 100644 --- a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/AddHostRequestValidator.cs +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/AddHostRequestValidator.cs @@ -1,21 +1,16 @@ -using SimpleModule.Core.Validation; +using FluentValidation; using SimpleModule.Tenants.Contracts; namespace SimpleModule.Tenants.Endpoints.Tenants; -public static class AddHostRequestValidator +public sealed class AddHostRequestValidator : AbstractValidator { - public static ValidationResult Validate(AddTenantHostRequest request) => - new ValidationBuilder() - .AddErrorIf( - string.IsNullOrWhiteSpace(request.HostName), - "HostName", - "Host name is required." - ) - .AddErrorIf( - !string.IsNullOrWhiteSpace(request.HostName) && request.HostName.Length > 512, - "HostName", - "Host name must not exceed 512 characters." - ) - .Build(); + public AddHostRequestValidator() + { + RuleFor(x => x.HostName) + .NotEmpty() + .WithMessage("Host name is required.") + .MaximumLength(512) + .WithMessage("Host name must not exceed 512 characters."); + } } diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/CreateEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/CreateEndpoint.cs index f0ffe211..e487dce6 100644 --- a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/CreateEndpoint.cs +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/CreateEndpoint.cs @@ -1,9 +1,10 @@ +using FluentValidation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; -using SimpleModule.Core.Exceptions; +using SimpleModule.Core.Validation; using SimpleModule.Tenants.Contracts; namespace SimpleModule.Tenants.Endpoints.Tenants; @@ -16,15 +17,21 @@ public class CreateEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPost( Route, - (CreateTenantRequest request, ITenantContracts contracts) => + async ( + CreateTenantRequest request, + IValidator validator, + ITenantContracts contracts + ) => { - var validation = CreateRequestValidator.Validate(request); + var validation = await validator.ValidateAsync(request); if (!validation.IsValid) { - throw new ValidationException(validation.Errors); + throw new Core.Exceptions.ValidationException( + validation.ToValidationErrors() + ); } - return CrudEndpoints.Create( + return await CrudEndpoints.Create( () => contracts.CreateTenantAsync(request), t => $"{TenantsConstants.RoutePrefix}/{t.Id}" ); diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/CreateRequestValidator.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/CreateRequestValidator.cs index 1819a55e..cb58bf8f 100644 --- a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/CreateRequestValidator.cs +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/CreateRequestValidator.cs @@ -1,27 +1,23 @@ using System.Text.RegularExpressions; -using SimpleModule.Core.Validation; +using FluentValidation; using SimpleModule.Tenants.Contracts; namespace SimpleModule.Tenants.Endpoints.Tenants; -public static partial class CreateRequestValidator +public sealed partial class CreateRequestValidator : AbstractValidator { - public static ValidationResult Validate(CreateTenantRequest request) => - new ValidationBuilder() - .AddErrorIf(string.IsNullOrWhiteSpace(request.Name), "Name", "Tenant name is required.") - .AddErrorIf(string.IsNullOrWhiteSpace(request.Slug), "Slug", "Tenant slug is required.") - .AddErrorIf( - !string.IsNullOrWhiteSpace(request.Slug) && !SlugPattern().IsMatch(request.Slug), - "Slug", - "Slug must contain only lowercase letters, numbers, and hyphens." - ) - .AddErrorIf( - !string.IsNullOrWhiteSpace(request.AdminEmail) - && !request.AdminEmail.Contains('@', StringComparison.Ordinal), - "AdminEmail", - "Invalid email format." - ) - .Build(); + public CreateRequestValidator() + { + RuleFor(x => x.Name).NotEmpty().WithMessage("Tenant name is required."); + RuleFor(x => x.Slug).NotEmpty().WithMessage("Tenant slug is required."); + RuleFor(x => x.Slug) + .Must(s => string.IsNullOrWhiteSpace(s) || SlugPattern().IsMatch(s)) + .WithMessage("Slug must contain only lowercase letters, numbers, and hyphens."); + RuleFor(x => x.AdminEmail) + .EmailAddress() + .When(x => !string.IsNullOrWhiteSpace(x.AdminEmail)) + .WithMessage("Invalid email format."); + } [GeneratedRegex(@"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$")] private static partial Regex SlugPattern(); diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/UpdateEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/UpdateEndpoint.cs index 40add254..a5fe47e6 100644 --- a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/UpdateEndpoint.cs +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/UpdateEndpoint.cs @@ -1,9 +1,10 @@ +using FluentValidation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; -using SimpleModule.Core.Exceptions; +using SimpleModule.Core.Validation; using SimpleModule.Tenants.Contracts; namespace SimpleModule.Tenants.Endpoints.Tenants; @@ -16,15 +17,24 @@ public class UpdateEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPut( Route, - (TenantId id, UpdateTenantRequest request, ITenantContracts contracts) => + async ( + TenantId id, + UpdateTenantRequest request, + IValidator validator, + ITenantContracts contracts + ) => { - var validation = UpdateRequestValidator.Validate(request); + var validation = await validator.ValidateAsync(request); if (!validation.IsValid) { - throw new ValidationException(validation.Errors); + throw new Core.Exceptions.ValidationException( + validation.ToValidationErrors() + ); } - return CrudEndpoints.Update(() => contracts.UpdateTenantAsync(id, request)); + return await CrudEndpoints.Update(() => + contracts.UpdateTenantAsync(id, request) + ); } ) .RequirePermission(TenantsPermissions.Update); diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/UpdateRequestValidator.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/UpdateRequestValidator.cs index 0dc54f74..964cb616 100644 --- a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/UpdateRequestValidator.cs +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/UpdateRequestValidator.cs @@ -1,18 +1,16 @@ -using SimpleModule.Core.Validation; +using FluentValidation; using SimpleModule.Tenants.Contracts; namespace SimpleModule.Tenants.Endpoints.Tenants; -public static class UpdateRequestValidator +public sealed class UpdateRequestValidator : AbstractValidator { - public static ValidationResult Validate(UpdateTenantRequest request) => - new ValidationBuilder() - .AddErrorIf(string.IsNullOrWhiteSpace(request.Name), "Name", "Tenant name is required.") - .AddErrorIf( - !string.IsNullOrWhiteSpace(request.AdminEmail) - && !request.AdminEmail.Contains('@', StringComparison.Ordinal), - "AdminEmail", - "Invalid email format." - ) - .Build(); + public UpdateRequestValidator() + { + RuleFor(x => x.Name).NotEmpty().WithMessage("Tenant name is required."); + RuleFor(x => x.AdminEmail) + .EmailAddress() + .When(x => !string.IsNullOrWhiteSpace(x.AdminEmail)) + .WithMessage("Invalid email format."); + } } diff --git a/modules/Tenants/src/SimpleModule.Tenants/TenantsModule.cs b/modules/Tenants/src/SimpleModule.Tenants/TenantsModule.cs index ab40eee6..8ff571f4 100644 --- a/modules/Tenants/src/SimpleModule.Tenants/TenantsModule.cs +++ b/modules/Tenants/src/SimpleModule.Tenants/TenantsModule.cs @@ -1,3 +1,4 @@ +using FluentValidation; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -18,6 +19,7 @@ public class TenantsModule : IModule public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { services.AddModuleDbContext(configuration, TenantsConstants.ModuleName); + services.AddValidatorsFromAssemblyContaining(); services.AddScoped(); services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); diff --git a/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/CreateRequestValidatorTests.cs b/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/CreateRequestValidatorTests.cs index 42b6433a..85ba88fc 100644 --- a/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/CreateRequestValidatorTests.cs +++ b/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/CreateRequestValidatorTests.cs @@ -6,11 +6,13 @@ namespace Tenants.Tests.Unit; public class CreateRequestValidatorTests { + private readonly CreateRequestValidator _validator = new(); + [Fact] public void Validate_WithValidRequest_ReturnsSuccess() { var request = new CreateTenantRequest { Name = "Test", Slug = "test" }; - var result = CreateRequestValidator.Validate(request); + var result = _validator.Validate(request); result.IsValid.Should().BeTrue(); } @@ -19,30 +21,30 @@ public void Validate_WithValidRequest_ReturnsSuccess() public void Validate_WithEmptyName_ReturnsError() { var request = new CreateTenantRequest { Name = "", Slug = "test" }; - var result = CreateRequestValidator.Validate(request); + var result = _validator.Validate(request); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("Name"); + result.Errors.Should().Contain(e => e.PropertyName == "Name"); } [Fact] public void Validate_WithEmptySlug_ReturnsError() { var request = new CreateTenantRequest { Name = "Test", Slug = "" }; - var result = CreateRequestValidator.Validate(request); + var result = _validator.Validate(request); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("Slug"); + result.Errors.Should().Contain(e => e.PropertyName == "Slug"); } [Fact] public void Validate_WithInvalidSlug_ReturnsError() { var request = new CreateTenantRequest { Name = "Test", Slug = "INVALID SLUG!" }; - var result = CreateRequestValidator.Validate(request); + var result = _validator.Validate(request); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("Slug"); + result.Errors.Should().Contain(e => e.PropertyName == "Slug"); } [Fact] @@ -54,9 +56,9 @@ public void Validate_WithInvalidEmail_ReturnsError() Slug = "test", AdminEmail = "not-an-email", }; - var result = CreateRequestValidator.Validate(request); + var result = _validator.Validate(request); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("AdminEmail"); + result.Errors.Should().Contain(e => e.PropertyName == "AdminEmail"); } } diff --git a/tests/SimpleModule.Core.Tests/Validation/ValidationBuilderTests.cs b/tests/SimpleModule.Core.Tests/Validation/ValidationBuilderTests.cs deleted file mode 100644 index 66bd5928..00000000 --- a/tests/SimpleModule.Core.Tests/Validation/ValidationBuilderTests.cs +++ /dev/null @@ -1,88 +0,0 @@ -using FluentAssertions; -using SimpleModule.Core.Validation; - -namespace SimpleModule.Core.Tests.Validation; - -public class ValidationBuilderTests -{ - [Fact] - public void Build_WithNoErrors_ReturnsSuccess() - { - var result = new ValidationBuilder().Build(); - - result.IsValid.Should().BeTrue(); - result.Errors.Should().BeEmpty(); - } - - [Fact] - public void AddErrorIf_WhenConditionTrue_AddsError() - { - var result = new ValidationBuilder().AddErrorIf(true, "Name", "Name is required.").Build(); - - result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("Name"); - result.Errors["Name"].Should().Contain("Name is required."); - } - - [Fact] - public void AddErrorIf_WhenConditionFalse_DoesNotAddError() - { - var result = new ValidationBuilder().AddErrorIf(false, "Name", "Name is required.").Build(); - - result.IsValid.Should().BeTrue(); - } - - [Fact] - public void AddErrorIf_MultipleSameField_AccumulatesErrors() - { - var result = new ValidationBuilder() - .AddErrorIf(true, "Name", "Name is required.") - .AddErrorIf(true, "Name", "Name must be at least 2 characters.") - .Build(); - - result.Errors["Name"].Should().HaveCount(2); - } - - [Fact] - public void AddErrorIf_MultipleFields_AccumulatesErrorsSeparately() - { - var result = new ValidationBuilder() - .AddErrorIf(true, "Name", "Name is required.") - .AddErrorIf(true, "Price", "Price must be positive.") - .Build(); - - result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("Name"); - result.Errors.Should().ContainKey("Price"); - result.Errors["Name"].Should().HaveCount(1); - result.Errors["Price"].Should().HaveCount(1); - } - - [Fact] - public void Build_CalledTwice_ReturnsSameResult() - { - var builder = new ValidationBuilder().AddErrorIf(true, "Name", "Name is required."); - - var result1 = builder.Build(); - var result2 = builder.Build(); - - result1.IsValid.Should().Be(result2.IsValid); - result1.Errors.Should().BeEquivalentTo(result2.Errors); - } - - [Fact] - public void AddErrorIf_MixedConditions_OnlyAddsForTrue() - { - var result = new ValidationBuilder() - .AddErrorIf(true, "Name", "Error one.") - .AddErrorIf(false, "Name", "Error two.") - .AddErrorIf(true, "Name", "Error three.") - .Build(); - - result.IsValid.Should().BeFalse(); - result.Errors["Name"].Should().HaveCount(2); - result.Errors["Name"].Should().Contain("Error one."); - result.Errors["Name"].Should().Contain("Error three."); - result.Errors["Name"].Should().NotContain("Error two."); - } -} diff --git a/tests/SimpleModule.Core.Tests/ValidationResultTests.cs b/tests/SimpleModule.Core.Tests/ValidationResultTests.cs deleted file mode 100644 index b12ec2dc..00000000 --- a/tests/SimpleModule.Core.Tests/ValidationResultTests.cs +++ /dev/null @@ -1,72 +0,0 @@ -using FluentAssertions; -using SimpleModule.Core.Validation; - -namespace SimpleModule.Core.Tests; - -public class ValidationResultTests -{ - [Fact] - public void Success_IsValid_WithNoErrors() - { - var result = ValidationResult.Success; - - result.IsValid.Should().BeTrue(); - result.Errors.Should().BeEmpty(); - } - - [Fact] - public void Success_IsSingleton() - { - var a = ValidationResult.Success; - var b = ValidationResult.Success; - - a.Should().BeSameAs(b); - } - - [Fact] - public void WithErrors_IsNotValid_ContainsErrors() - { - var errors = new Dictionary { ["Field"] = ["Error message"] }; - - var result = ValidationResult.WithErrors(errors); - - result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("Field"); - result.Errors["Field"].Should().ContainSingle("Error message"); - } - - [Fact] - public void WithErrors_MultipleFields_AllPresent() - { - var errors = new Dictionary - { - ["Name"] = ["Required"], - ["Age"] = ["Must be positive", "Must be under 200"], - }; - - var result = ValidationResult.WithErrors(errors); - - result.IsValid.Should().BeFalse(); - result.Errors.Should().HaveCount(2); - result.Errors["Age"].Should().HaveCount(2); - } - - [Fact] - public void WithErrors_EmptyDictionary_IsNotValid() - { - var result = ValidationResult.WithErrors(new Dictionary()); - - result.IsValid.Should().BeFalse(); - result.Errors.Should().BeEmpty(); - } - - [Fact] - public void RecordEquality_SameValues_AreEqual() - { - var errors = new Dictionary(); - var a = new ValidationResult(true, errors); - var b = new ValidationResult(true, errors); - - a.Should().Be(b); - } -}