Add FluentValidation to application services
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m45s

This commit is contained in:
2026-07-07 16:31:29 +09:00
parent 68cc97eda6
commit c54599304b
9 changed files with 110 additions and 38 deletions
@@ -1,5 +1,6 @@
namespace TaxBaik.Application.Tests;
using FluentValidation;
using TaxBaik.Application.DTOs;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
@@ -12,9 +13,9 @@ public class BlogServiceTests
[Fact]
public async Task CreateAsync_WhenPublishedWithoutSeoTitle_ThrowsValidationException()
{
var service = new BlogService(new FakeBlogPostRepository(), new MemoryCache(new MemoryCacheOptions()));
var service = new BlogService(new FakeBlogPostRepository(), new MemoryCache(new MemoryCacheOptions()), new PassThroughValidator<CreateBlogPostDto>());
await Assert.ThrowsAsync<ValidationException>(() => service.CreateAsync(new CreateBlogPostDto
await Assert.ThrowsAsync<TaxBaik.Application.Services.ValidationException>(() => service.CreateAsync(new CreateBlogPostDto
{
Title = "테스트 포스트",
Content = "본문",
@@ -33,7 +34,7 @@ public class BlogServiceTests
new BlogPost { Id = 1, Title = "같은 제목", Content = "본문", Slug = "같은-제목" }
]
};
var service = new BlogService(repository, new MemoryCache(new MemoryCacheOptions()));
var service = new BlogService(repository, new MemoryCache(new MemoryCacheOptions()), new PassThroughValidator<CreateBlogPostDto>());
var post = await service.CreateAsync(new CreateBlogPostDto
{
@@ -54,7 +55,7 @@ public class BlogServiceTests
new BlogPost { Id = 1, Title = "삭제 대상", Content = "본문", Slug = "delete-me", IsPublished = true }
]
};
var service = new BlogService(repository, new MemoryCache(new MemoryCacheOptions()));
var service = new BlogService(repository, new MemoryCache(new MemoryCacheOptions()), new PassThroughValidator<CreateBlogPostDto>());
await service.DeleteAsync(1);
@@ -129,4 +130,14 @@ public class BlogServiceTests
public Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
private sealed class PassThroughValidator<T> : IValidator<T>
{
public FluentValidation.Results.ValidationResult Validate(T instance) => new();
public Task<FluentValidation.Results.ValidationResult> ValidateAsync(T instance, CancellationToken cancellation = default) => Task.FromResult(new FluentValidation.Results.ValidationResult());
public FluentValidation.Results.ValidationResult Validate(IValidationContext context) => new();
public Task<FluentValidation.Results.ValidationResult> ValidateAsync(IValidationContext context, CancellationToken cancellation = default) => Task.FromResult(new FluentValidation.Results.ValidationResult());
public IValidatorDescriptor CreateDescriptor() => throw new NotImplementedException();
public bool CanValidateInstancesOfType(Type type) => true;
}
}
@@ -1,5 +1,7 @@
namespace TaxBaik.Application.Tests;
using FluentValidation;
using TaxBaik.Application.DTOs;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Interfaces;
@@ -11,16 +13,16 @@ public class InquiryServiceTests
[Fact]
public async Task UpdateStatusAsync_WhenStatusIsInvalid_ThrowsValidationException()
{
var service = new InquiryService(new FakeInquiryRepository(), new FakeInquiryNotificationService(), new MemoryCache(new MemoryCacheOptions()));
var service = new InquiryService(new FakeInquiryRepository(), new FakeInquiryNotificationService(), new MemoryCache(new MemoryCacheOptions()), new PassThroughValidator<SubmitInquiryDto>(), new PassThroughValidator<UpdateInquiryDto>());
await Assert.ThrowsAsync<ValidationException>(() => service.UpdateStatusAsync(1, "invalid"));
await Assert.ThrowsAsync<TaxBaik.Application.Services.ValidationException>(() => service.UpdateStatusAsync(1, "invalid"));
}
[Fact]
public async Task SubmitAsync_StoresEmailAndNewStatus()
{
var repository = new FakeInquiryRepository();
var service = new InquiryService(repository, new FakeInquiryNotificationService(), new MemoryCache(new MemoryCacheOptions()));
var service = new InquiryService(repository, new FakeInquiryNotificationService(), new MemoryCache(new MemoryCacheOptions()), new PassThroughValidator<SubmitInquiryDto>(), new PassThroughValidator<UpdateInquiryDto>());
await service.SubmitAsync("홍길동", "010-1234-5678", "기장", "사업자 세무 관련해서 문의드립니다.", "user@example.com");
@@ -121,4 +123,14 @@ public class InquiryServiceTests
public Task NotifyStatusChangedAsync(int inquiryId, string name, string phone, string serviceType, string previousStatus, string newStatus, string? changedBy = null, CancellationToken ct = default)
=> Task.CompletedTask;
}
private sealed class PassThroughValidator<T> : IValidator<T>
{
public FluentValidation.Results.ValidationResult Validate(T instance) => new();
public Task<FluentValidation.Results.ValidationResult> ValidateAsync(T instance, CancellationToken cancellation = default) => Task.FromResult(new FluentValidation.Results.ValidationResult());
public FluentValidation.Results.ValidationResult Validate(IValidationContext context) => new();
public Task<FluentValidation.Results.ValidationResult> ValidateAsync(IValidationContext context, CancellationToken cancellation = default) => Task.FromResult(new FluentValidation.Results.ValidationResult());
public IValidatorDescriptor CreateDescriptor() => throw new NotImplementedException();
public bool CanValidateInstancesOfType(Type type) => true;
}
}
@@ -0,0 +1,55 @@
namespace TaxBaik.Application.DTOs;
using FluentValidation;
public sealed class CreateBlogPostDtoValidator : AbstractValidator<CreateBlogPostDto>
{
public CreateBlogPostDtoValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(ValidationRules.TitleMaxLength);
RuleFor(x => x.Content).NotEmpty().MinimumLength(ValidationRules.MessageMinLength).MaximumLength(ValidationRules.ContentMaxLength);
RuleFor(x => x.Tags).MaximumLength(ValidationRules.TagsMaxLength);
RuleFor(x => x.SeoTitle).MaximumLength(ValidationRules.SeoTitleMaxLength);
RuleFor(x => x.SeoDescription).MaximumLength(ValidationRules.SeoDescriptionMaxLength);
}
}
public sealed class SubmitInquiryDtoValidator : AbstractValidator<SubmitInquiryDto>
{
public SubmitInquiryDtoValidator()
{
ApplyInquiryRules(this);
}
internal static void ApplyInquiryRules(AbstractValidator<SubmitInquiryDto> validator) { }
}
public sealed class UpdateInquiryDtoValidator : AbstractValidator<UpdateInquiryDto>
{
public UpdateInquiryDtoValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(ValidationRules.NameMaxLength);
RuleFor(x => x.Phone).NotEmpty().Matches(ValidationRules.KoreanPhonePattern);
RuleFor(x => x.Email).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.Email));
RuleFor(x => x.ServiceType).MaximumLength(ValidationRules.ServiceTypeMaxLength);
RuleFor(x => x.Message).NotEmpty().MinimumLength(ValidationRules.MessageMinLength).MaximumLength(ValidationRules.MessageMaxLength);
RuleFor(x => x.Status).NotEmpty();
RuleFor(x => x.AdminMemo).MaximumLength(ValidationRules.MemoMaxLength);
}
}
public sealed class CreateClientDtoValidator : AbstractValidator<CreateClientDto>
{
public CreateClientDtoValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(ValidationRules.NameMaxLength);
RuleFor(x => x.CompanyName).MaximumLength(ValidationRules.NameMaxLength);
RuleFor(x => x.Phone).MaximumLength(ValidationRules.PhoneMaxLength);
RuleFor(x => x.Email).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.Email));
RuleFor(x => x.ServiceType).MaximumLength(ValidationRules.ServiceTypeMaxLength);
RuleFor(x => x.TaxType).MaximumLength(ValidationRules.ServiceTypeMaxLength);
RuleFor(x => x.Status).NotEmpty();
RuleFor(x => x.Source).MaximumLength(ValidationRules.ServiceTypeMaxLength);
RuleFor(x => x.Memo).MaximumLength(ValidationRules.MemoMaxLength);
}
}
@@ -1,13 +1,14 @@
namespace TaxBaik.Application.Services;
using System.Text.RegularExpressions;
using FluentValidation;
using TaxBaik.Application.DTOs;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Interfaces;
using Microsoft.Extensions.Caching.Memory;
public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCache)
public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCache, IValidator<CreateBlogPostDto> validator)
{
public async Task<BlogPost?> GetByIdAsync(int id, CancellationToken ct = default) =>
await repository.GetByIdAsync(id, ct);
@@ -60,6 +61,7 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach
public async Task<BlogPost> CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default)
{
validator.ValidateAndThrow(dto);
var post = new BlogPost
{
Title = dto.Title,
@@ -87,6 +89,7 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach
public async Task<BlogPost?> UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default)
{
validator.ValidateAndThrow(dto);
var post = await repository.GetByIdAsync(id, ct);
if (post == null)
return null;
@@ -1,10 +1,11 @@
namespace TaxBaik.Application.Services;
using FluentValidation;
using TaxBaik.Application.DTOs;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Interfaces;
public class ClientService(IClientRepository repository)
public class ClientService(IClientRepository repository, IValidator<CreateClientDto> validator)
{
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
int page, int pageSize, string? status = null, string? search = null, CancellationToken ct = default) =>
@@ -24,8 +25,7 @@ public class ClientService(IClientRepository repository)
public async Task<int> CreateAsync(CreateClientDto dto, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(dto.Name))
throw new ValidationException("고객명을 입력하세요.");
validator.ValidateAndThrow(dto);
var client = new Client
{
@@ -45,8 +45,7 @@ public class ClientService(IClientRepository repository)
public async Task UpdateAsync(int id, CreateClientDto dto, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(dto.Name))
throw new ValidationException("고객명을 입력하세요.");
validator.ValidateAndThrow(dto);
var client = await repository.GetByIdAsync(id, ct)
?? throw new KeyNotFoundException($"고객 ID {id}를 찾을 수 없습니다.");
@@ -1,6 +1,7 @@
namespace TaxBaik.Application.Services;
using System.Text.RegularExpressions;
using FluentValidation;
using Microsoft.Extensions.Caching.Memory;
using TaxBaik.Application.DTOs;
using TaxBaik.Domain.Entities;
@@ -10,7 +11,9 @@ using TaxBaik.Domain.Interfaces;
public class InquiryService(
IInquiryRepository repository,
IInquiryNotificationService notificationService,
IMemoryCache memoryCache)
IMemoryCache memoryCache,
IValidator<SubmitInquiryDto> submitValidator,
IValidator<UpdateInquiryDto> updateValidator)
{
// 한국 전화번호 정규식
// 휴대폰: 010~019, 070, 0505~0509
@@ -18,15 +21,18 @@ public class InquiryService(
private static readonly Regex PhoneRegex = new(
@"^(0(?:2|3[1-3]|4[1-4]|5[1-5]|6[1-4]|70|50[5-9]|[7-9](?:\d{1,2})?)\d{7,8}|0\d{9,10})$");
private const int MinMessageLength = 10;
private const int MaxMessageLength = 5000;
public async Task<int> SubmitAsync(
string name, string phone, string serviceType, string message,
string? email = null, string? ipAddress = null, bool suppressNotification = false, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(name))
throw new ValidationException("이름을 입력하세요.");
submitValidator.ValidateAndThrow(new SubmitInquiryDto
{
Name = name,
Phone = phone,
Email = email,
ServiceType = serviceType,
Message = message
});
var cleanPhone = phone?.Replace("-", "").Replace(" ", "").Trim() ?? "";
if (!PhoneRegex.IsMatch(cleanPhone) || cleanPhone.Length < 10)
@@ -34,15 +40,7 @@ public class InquiryService(
var formattedPhone = FormatPhoneNumber(cleanPhone);
if (string.IsNullOrWhiteSpace(message))
throw new ValidationException("문의 내용을 입력하세요.");
var trimmedMessage = message.Trim();
if (trimmedMessage.Length < MinMessageLength)
throw new ValidationException($"문의 내용은 최소 {MinMessageLength}자 이상이어야 합니다.");
if (trimmedMessage.Length > MaxMessageLength)
throw new ValidationException($"문의 내용은 최대 {MaxMessageLength}자 이하여야 합니다.");
var inquiry = new Inquiry
{
@@ -95,23 +93,13 @@ public class InquiryService(
var inquiry = await repository.GetByIdAsync(id, ct);
if (inquiry == null)
return null;
if (string.IsNullOrWhiteSpace(dto.Name))
throw new ValidationException("이름을 입력하세요.");
updateValidator.ValidateAndThrow(dto);
var cleanPhone = dto.Phone?.Replace("-", "").Replace(" ", "").Trim() ?? "";
if (!PhoneRegex.IsMatch(cleanPhone) || cleanPhone.Length < 10)
throw new ValidationException("올바른 전화번호를 입력하세요. (예: 01012345678 또는 010-1234-5678)");
if (string.IsNullOrWhiteSpace(dto.Message))
throw new ValidationException("문의 내용을 입력하세요.");
var trimmedUpdateMessage = dto.Message.Trim();
if (trimmedUpdateMessage.Length < MinMessageLength)
throw new ValidationException($"문의 내용은 최소 {MinMessageLength}자 이상이어야 합니다.");
if (trimmedUpdateMessage.Length > MaxMessageLength)
throw new ValidationException($"문의 내용은 최대 {MaxMessageLength}자 이하여야 합니다.");
if (!InquiryStatusMapper.TryParse(dto.Status, out var parsedStatus))
throw new ValidationException("지원하지 않는 문의 상태입니다.");
@@ -6,6 +6,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.0" />
<PackageReference Include="FluentValidation" Version="11.11.0" />
</ItemGroup>
<PropertyGroup>
+2
View File
@@ -12,6 +12,7 @@ using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.IdentityModel.Tokens;
using MudBlazor.Services;
using Serilog;
using FluentValidation;
using FastEndpoints;
using System.Threading.RateLimiting;
using TaxBaik.Application;
@@ -357,6 +358,7 @@ builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
builder.Services.AddInfrastructure();
builder.Services.AddApplication();
builder.Services.AddValidatorsFromAssemblyContaining<TaxBaik.Application.DTOs.CreateBlogPostDtoValidator>();
builder.Services.AddScoped<IInquiryNotificationService, TelegramInquiryNotificationService>();
builder.Services.AddScoped<TaxBaik.Web.Services.SitemapValidationService>();
+1
View File
@@ -33,6 +33,7 @@
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.19.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />