diff --git a/src/TaxBaik.Application.Tests/BlogServiceTests.cs b/src/TaxBaik.Application.Tests/BlogServiceTests.cs index 4a1a6c8..579b58c 100644 --- a/src/TaxBaik.Application.Tests/BlogServiceTests.cs +++ b/src/TaxBaik.Application.Tests/BlogServiceTests.cs @@ -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()); - await Assert.ThrowsAsync(() => service.CreateAsync(new CreateBlogPostDto + await Assert.ThrowsAsync(() => 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()); 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()); 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 : IValidator + { + public FluentValidation.Results.ValidationResult Validate(T instance) => new(); + public Task ValidateAsync(T instance, CancellationToken cancellation = default) => Task.FromResult(new FluentValidation.Results.ValidationResult()); + public FluentValidation.Results.ValidationResult Validate(IValidationContext context) => new(); + public Task ValidateAsync(IValidationContext context, CancellationToken cancellation = default) => Task.FromResult(new FluentValidation.Results.ValidationResult()); + public IValidatorDescriptor CreateDescriptor() => throw new NotImplementedException(); + public bool CanValidateInstancesOfType(Type type) => true; + } } diff --git a/src/TaxBaik.Application.Tests/InquiryServiceTests.cs b/src/TaxBaik.Application.Tests/InquiryServiceTests.cs index 8bbe66a..be49688 100644 --- a/src/TaxBaik.Application.Tests/InquiryServiceTests.cs +++ b/src/TaxBaik.Application.Tests/InquiryServiceTests.cs @@ -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(), new PassThroughValidator()); - await Assert.ThrowsAsync(() => service.UpdateStatusAsync(1, "invalid")); + await Assert.ThrowsAsync(() => 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(), new PassThroughValidator()); 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 : IValidator + { + public FluentValidation.Results.ValidationResult Validate(T instance) => new(); + public Task ValidateAsync(T instance, CancellationToken cancellation = default) => Task.FromResult(new FluentValidation.Results.ValidationResult()); + public FluentValidation.Results.ValidationResult Validate(IValidationContext context) => new(); + public Task ValidateAsync(IValidationContext context, CancellationToken cancellation = default) => Task.FromResult(new FluentValidation.Results.ValidationResult()); + public IValidatorDescriptor CreateDescriptor() => throw new NotImplementedException(); + public bool CanValidateInstancesOfType(Type type) => true; + } } diff --git a/src/TaxBaik.Application/DTOs/Validators.cs b/src/TaxBaik.Application/DTOs/Validators.cs new file mode 100644 index 0000000..bcaac74 --- /dev/null +++ b/src/TaxBaik.Application/DTOs/Validators.cs @@ -0,0 +1,55 @@ +namespace TaxBaik.Application.DTOs; + +using FluentValidation; + +public sealed class CreateBlogPostDtoValidator : AbstractValidator +{ + 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 +{ + public SubmitInquiryDtoValidator() + { + ApplyInquiryRules(this); + } + + internal static void ApplyInquiryRules(AbstractValidator validator) { } +} + +public sealed class UpdateInquiryDtoValidator : AbstractValidator +{ + 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 +{ + 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); + } +} diff --git a/src/TaxBaik.Application/Services/BlogService.cs b/src/TaxBaik.Application/Services/BlogService.cs index e95a170..a202cc7 100644 --- a/src/TaxBaik.Application/Services/BlogService.cs +++ b/src/TaxBaik.Application/Services/BlogService.cs @@ -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 validator) { public async Task 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 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 UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default) { + validator.ValidateAndThrow(dto); var post = await repository.GetByIdAsync(id, ct); if (post == null) return null; diff --git a/src/TaxBaik.Application/Services/ClientService.cs b/src/TaxBaik.Application/Services/ClientService.cs index c41679a..e189cd0 100644 --- a/src/TaxBaik.Application/Services/ClientService.cs +++ b/src/TaxBaik.Application/Services/ClientService.cs @@ -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 validator) { public async Task<(IEnumerable 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 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}를 찾을 수 없습니다."); diff --git a/src/TaxBaik.Application/Services/InquiryService.cs b/src/TaxBaik.Application/Services/InquiryService.cs index 637045b..222ab93 100644 --- a/src/TaxBaik.Application/Services/InquiryService.cs +++ b/src/TaxBaik.Application/Services/InquiryService.cs @@ -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 submitValidator, + IValidator 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 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("지원하지 않는 문의 상태입니다."); diff --git a/src/TaxBaik.Application/TaxBaik.Application.csproj b/src/TaxBaik.Application/TaxBaik.Application.csproj index 1716f38..4b0c950 100644 --- a/src/TaxBaik.Application/TaxBaik.Application.csproj +++ b/src/TaxBaik.Application/TaxBaik.Application.csproj @@ -6,6 +6,7 @@ + diff --git a/src/TaxBaik.Web/Program.cs b/src/TaxBaik.Web/Program.cs index 9f9f0a2..eade602 100644 --- a/src/TaxBaik.Web/Program.cs +++ b/src/TaxBaik.Web/Program.cs @@ -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(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/TaxBaik.Web/TaxBaik.Web.csproj b/src/TaxBaik.Web/TaxBaik.Web.csproj index 5d0c4ad..fb5aa25 100644 --- a/src/TaxBaik.Web/TaxBaik.Web.csproj +++ b/src/TaxBaik.Web/TaxBaik.Web.csproj @@ -33,6 +33,7 @@ +