refactor: move buildable .NET source into src/, update CI/doc paths
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m7s
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m7s
Groups the repo root into src (buildable source), docs (already existed), and everything else (db/, scripts/, tests/, deploy/ - deployment/ops/test assets that aren't compiled, already organized as their own folders). CI now only needs src/ to build: dotnet restore/build/test/publish all point at src/TaxBaik.sln, src/TaxBaik.Web/, src/TaxBaik.Proxy/. - git mv every project (Domain, Infrastructure, Application, Application.Tests, Web, Web.Client, Proxy) and TaxBaik.sln into src/ as a unit, so relative ProjectReference/.sln paths stay valid unchanged. - .gitea/workflows/deploy.yml: 6 dotnet restore/clean/build/test/publish invocations now point at src/. db/migrations and scripts/ stay at root (deploy_gb.sh and browser-e2e.yml only touch published output and the deployed URL, not source paths - verified, no changes needed there). - scripts/validate_admin_render.sh: admin render-mode file paths now src/TaxBaik.Web.Client/... - scripts/validate_kst_timestamps.sh: dropped deploy.sh from its target list - that script was removed in the prior cleanup commit (dead, no CI workflow referenced it) but this validator still expected it to exist. - CLAUDE.md, docs/ENGINEERING_HARNESS.md, docs/ADMIN_PATTERN_CRITIQUE_WBS.md: updated project-structure diagram, dotnet run/build commands, and grep targets to the new src/ paths (also fixed a pre-existing stale path in ADMIN_PATTERN_CRITIQUE_WBS.md that still said TaxBaik.Web/Components/Admin from before that ever moved to TaxBaik.Web.Client). - Added a Repo Root harness rule + Architecture Guardrail entries: new files belong under src/docs/tests/scripts/db/deploy, not loose at root; temp work stays outside the repo (or under a gitignored .scratch/) and is never committed. Verified locally: dotnet build/test src/TaxBaik.sln (26/26 tests), and all three scripts/validate_*.sh pass against the new layout. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
namespace TaxBaik.Application.Tests;
|
||||
|
||||
using TaxBaik.Application.DTOs;
|
||||
using TaxBaik.Application.Services;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Xunit;
|
||||
|
||||
public class BlogServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateAsync_WhenPublishedWithoutSeoTitle_ThrowsValidationException()
|
||||
{
|
||||
var service = new BlogService(new FakeBlogPostRepository(), new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
await Assert.ThrowsAsync<ValidationException>(() => service.CreateAsync(new CreateBlogPostDto
|
||||
{
|
||||
Title = "테스트 포스트",
|
||||
Content = "본문",
|
||||
SeoDescription = "설명",
|
||||
IsPublished = true
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WhenTitleDuplicates_GeneratesUniqueSlug()
|
||||
{
|
||||
var repository = new FakeBlogPostRepository
|
||||
{
|
||||
Posts =
|
||||
[
|
||||
new BlogPost { Id = 1, Title = "같은 제목", Content = "본문", Slug = "같은-제목" }
|
||||
]
|
||||
};
|
||||
var service = new BlogService(repository, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
var post = await service.CreateAsync(new CreateBlogPostDto
|
||||
{
|
||||
Title = "같은 제목",
|
||||
Content = "본문"
|
||||
});
|
||||
|
||||
Assert.Equal("같은-제목-2", post.Slug);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_SoftDeletesPost_AndExcludesFromSlugLookup()
|
||||
{
|
||||
var repository = new FakeBlogPostRepository
|
||||
{
|
||||
Posts =
|
||||
[
|
||||
new BlogPost { Id = 1, Title = "삭제 대상", Content = "본문", Slug = "delete-me", IsPublished = true }
|
||||
]
|
||||
};
|
||||
var service = new BlogService(repository, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
await service.DeleteAsync(1);
|
||||
|
||||
Assert.NotNull(repository.Posts.Single().DeletedAt);
|
||||
Assert.Null(await service.GetBySlugAsync("delete-me"));
|
||||
Assert.Null(await service.GetByIdAsync(1));
|
||||
}
|
||||
|
||||
private sealed class FakeBlogPostRepository : IBlogPostRepository
|
||||
{
|
||||
public List<BlogPost> Posts { get; init; } = [];
|
||||
|
||||
public Task<BlogPost?> GetByIdAsync(int id, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Posts.FirstOrDefault(x => x.Id == id && x.DeletedAt == null));
|
||||
|
||||
public Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Posts.FirstOrDefault(x => x.Slug == slug && x.IsPublished && x.DeletedAt == null));
|
||||
|
||||
public Task<(IEnumerable<BlogPost> Items, int Total)> GetPublishedPagedAsync(
|
||||
int page, int pageSize, int? categoryId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = Posts.Where(x => x.IsPublished).ToList();
|
||||
return Task.FromResult<(IEnumerable<BlogPost>, int)>((items, items.Count));
|
||||
}
|
||||
|
||||
public Task<IEnumerable<BlogPost>> GetByCategorySlugAsync(string categorySlug, int limit, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IEnumerable<BlogPost>>(Posts.Where(x => x.IsPublished).Take(limit).ToList());
|
||||
|
||||
public Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IEnumerable<BlogPost>>(Posts);
|
||||
|
||||
public Task<(IEnumerable<BlogPost> Items, int Total)> GetAdminPagedAsync(
|
||||
int page, int pageSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = Posts.ToList();
|
||||
return Task.FromResult<(IEnumerable<BlogPost>, int)>((items, items.Count));
|
||||
}
|
||||
|
||||
public Task<(IEnumerable<BlogPost> Items, int Total)> GetArchivedPagedAsync(
|
||||
int page, int pageSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = Posts.Where(x => x.DeletedAt != null).ToList();
|
||||
return Task.FromResult<(IEnumerable<BlogPost>, int)>((items, items.Count));
|
||||
}
|
||||
|
||||
public Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default)
|
||||
{
|
||||
post.Id = Posts.Count + 1;
|
||||
Posts.Add(post);
|
||||
return Task.FromResult(post.Id);
|
||||
}
|
||||
|
||||
public Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var post = Posts.FirstOrDefault(x => x.Id == id);
|
||||
if (post != null)
|
||||
post.DeletedAt = DateTime.UtcNow;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ArchiveAsync(int id, CancellationToken cancellationToken = default) => DeleteAsync(id, cancellationToken);
|
||||
|
||||
public Task RestoreAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var post = Posts.FirstOrDefault(x => x.Id == id);
|
||||
if (post != null)
|
||||
post.DeletedAt = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace TaxBaik.Application.Tests;
|
||||
|
||||
using TaxBaik.Application.Utils;
|
||||
using Xunit;
|
||||
|
||||
public class BusinessDayCalculatorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(2026, 2, 14, 2026, 2, 19)]
|
||||
[InlineData(2026, 8, 15, 2026, 8, 18)]
|
||||
[InlineData(2026, 10, 3, 2026, 10, 6)]
|
||||
[InlineData(2026, 9, 24, 2026, 9, 28)]
|
||||
[InlineData(2027, 2, 6, 2027, 2, 10)]
|
||||
[InlineData(2027, 10, 9, 2027, 10, 12)]
|
||||
public void GetEffectiveDueDate_SkipsWeekendHolidayAndSubstituteHoliday(
|
||||
int dueYear, int dueMonth, int dueDay,
|
||||
int expectedYear, int expectedMonth, int expectedDay)
|
||||
{
|
||||
var effective = BusinessDayCalculator.GetEffectiveDueDate(new DateOnly(dueYear, dueMonth, dueDay));
|
||||
|
||||
Assert.Equal(new DateOnly(expectedYear, expectedMonth, expectedDay), effective);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2026, 2, 19, 0)]
|
||||
[InlineData(2026, 2, 20, -1)]
|
||||
[InlineData(2026, 2, 18, 1)]
|
||||
public void GetDday_UsesEffectiveDueDate(
|
||||
int refYear, int refMonth, int refDay,
|
||||
int expectedDays)
|
||||
{
|
||||
var dday = BusinessDayCalculator.GetDday(new DateOnly(2026, 2, 14), new DateOnly(refYear, refMonth, refDay));
|
||||
|
||||
Assert.Equal(expectedDays, dday);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
namespace TaxBaik.Application.Tests;
|
||||
|
||||
using TaxBaik.Application.Services;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
public class CommonCodeServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UpsertAsync_TrimsAndRejectsWhitespaceInCodeValue()
|
||||
{
|
||||
var repository = new FakeCommonCodeRepository();
|
||||
var service = new CommonCodeService(repository);
|
||||
|
||||
await Assert.ThrowsAsync<ValidationException>(() => service.UpsertAsync(new CommonCode
|
||||
{
|
||||
CodeGroup = " CLIENT_STATUS ",
|
||||
CodeValue = "active code",
|
||||
CodeName = " 활성 "
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_TrimsAndPersistsNormalizedValues()
|
||||
{
|
||||
var repository = new FakeCommonCodeRepository();
|
||||
var service = new CommonCodeService(repository);
|
||||
|
||||
await service.UpsertAsync(new CommonCode
|
||||
{
|
||||
CodeGroup = " CLIENT_STATUS ",
|
||||
CodeValue = "active",
|
||||
CodeName = " 활성 ",
|
||||
SortOrder = 10
|
||||
});
|
||||
|
||||
var saved = Assert.Single(repository.SavedCodes);
|
||||
Assert.Equal("CLIENT_STATUS", saved.CodeGroup);
|
||||
Assert.Equal("active", saved.CodeValue);
|
||||
Assert.Equal("활성", saved.CodeName);
|
||||
}
|
||||
|
||||
private sealed class FakeCommonCodeRepository : ICommonCodeRepository
|
||||
{
|
||||
public List<CommonCode> SavedCodes { get; } = [];
|
||||
|
||||
public Task<IEnumerable<string>> GetAllGroupsAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<IEnumerable<string>>([]);
|
||||
|
||||
public Task<IEnumerable<CommonCode>> GetByGroupAsync(string codeGroup, CancellationToken ct = default) =>
|
||||
Task.FromResult<IEnumerable<CommonCode>>([]);
|
||||
|
||||
public Task<IEnumerable<CommonCode>> GetAllActiveAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<IEnumerable<CommonCode>>([]);
|
||||
|
||||
public Task<CommonCode?> GetAsync(string codeGroup, string codeValue, CancellationToken ct = default) =>
|
||||
Task.FromResult<CommonCode?>(null);
|
||||
|
||||
public Task UpsertAsync(CommonCode code, CancellationToken ct = default)
|
||||
{
|
||||
SavedCodes.Add(new CommonCode
|
||||
{
|
||||
CodeGroup = code.CodeGroup,
|
||||
CodeValue = code.CodeValue,
|
||||
CodeName = code.CodeName,
|
||||
SortOrder = code.SortOrder,
|
||||
IsActive = code.IsActive
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string codeGroup, string codeValue, CancellationToken ct = default) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
namespace TaxBaik.Application.Tests;
|
||||
|
||||
using TaxBaik.Application.Services;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Xunit;
|
||||
|
||||
public class InquiryServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UpdateStatusAsync_WhenStatusIsInvalid_ThrowsValidationException()
|
||||
{
|
||||
var service = new InquiryService(new FakeInquiryRepository(), new FakeInquiryNotificationService(), new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
await Assert.ThrowsAsync<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()));
|
||||
|
||||
await service.SubmitAsync("홍길동", "010-1234-5678", "기장", "문의합니다.", "user@example.com");
|
||||
|
||||
Assert.Equal("user@example.com", repository.Inquiries.Single().Email);
|
||||
Assert.Equal("new", repository.Inquiries.Single().Status);
|
||||
}
|
||||
|
||||
private sealed class FakeInquiryRepository : IInquiryRepository
|
||||
{
|
||||
public List<Inquiry> Inquiries { get; } = [];
|
||||
|
||||
public Task<int> CreateAsync(Inquiry inquiry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
inquiry.Id = Inquiries.Count + 1;
|
||||
Inquiries.Add(inquiry);
|
||||
return Task.FromResult(inquiry.Id);
|
||||
}
|
||||
|
||||
public Task<Inquiry?> GetByIdAsync(int id, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Inquiries.FirstOrDefault(x => x.Id == id));
|
||||
|
||||
public Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(
|
||||
int page, int pageSize, string? status = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = status == null ? Inquiries : Inquiries.Where(x => x.Status == status).ToList();
|
||||
return Task.FromResult<(IEnumerable<Inquiry>, int)>((items, items.Count()));
|
||||
}
|
||||
|
||||
public Task<int> CountAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Inquiries.Count);
|
||||
|
||||
public Task<int> CountThisMonthAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Inquiries.Count);
|
||||
|
||||
public Task<int> CountByStatusAsync(string status, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Inquiries.Count(x => x.Status == status));
|
||||
|
||||
public Task<int> CountByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Inquiries.Count(x => x.CreatedAt >= startDate && x.CreatedAt <= endDate));
|
||||
|
||||
public Task<int> CountByStatusAndDateAsync(string status, DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Inquiries.Count(x => x.Status == status && x.CreatedAt >= startDate && x.CreatedAt <= endDate));
|
||||
|
||||
public Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var inquiry = Inquiries.FirstOrDefault(x => x.Id == id);
|
||||
if (inquiry != null)
|
||||
inquiry.Status = status;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var inquiry = Inquiries.FirstOrDefault(x => x.Id == id);
|
||||
if (inquiry != null)
|
||||
inquiry.AdminMemo = adminMemo;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAsync(Inquiry inquiry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = Inquiries.FirstOrDefault(x => x.Id == inquiry.Id);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Name = inquiry.Name;
|
||||
existing.Phone = inquiry.Phone;
|
||||
existing.Email = inquiry.Email;
|
||||
existing.ServiceType = inquiry.ServiceType;
|
||||
existing.Message = inquiry.Message;
|
||||
existing.Status = inquiry.Status;
|
||||
existing.AdminMemo = inquiry.AdminMemo;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var inquiry = Inquiries.FirstOrDefault(x => x.Id == inquiryId);
|
||||
if (inquiry != null)
|
||||
inquiry.ClientId = clientId;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var inquiry = Inquiries.FirstOrDefault(x => x.Id == id);
|
||||
if (inquiry != null)
|
||||
Inquiries.Remove(inquiry);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeInquiryNotificationService : IInquiryNotificationService
|
||||
{
|
||||
public Task NotifyCreatedAsync(int inquiryId, string name, string phone, string serviceType, string message, string? ipAddress, DateTime createdAtUtc, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task NotifyStatusChangedAsync(int inquiryId, string name, string phone, string serviceType, string previousStatus, string newStatus, string? changedBy = null, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace TaxBaik.Application.Tests;
|
||||
|
||||
using TaxBaik.Application.Seasonal;
|
||||
using TaxBaik.Application.Services;
|
||||
using Xunit;
|
||||
|
||||
public class SeasonalMarketingServiceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(2026, 1, 25, 2026, 1, 26)]
|
||||
[InlineData(2026, 2, 28, 2026, 3, 3)]
|
||||
[InlineData(2026, 3, 31, 2026, 3, 31)]
|
||||
[InlineData(2026, 5, 31, 2026, 6, 1)]
|
||||
[InlineData(2026, 7, 25, 2026, 7, 27)]
|
||||
[InlineData(2026, 11, 30, 2026, 11, 30)]
|
||||
[InlineData(2026, 12, 31, 2026, 12, 31)]
|
||||
public void SeasonalDeadlines_ApplyBusinessDayRollForward(
|
||||
int year, int month, int day,
|
||||
int expectedYear, int expectedMonth, int expectedDay)
|
||||
{
|
||||
var deadline = new DateOnly(year, month, day);
|
||||
var effective = BusinessDayCalculator.GetEffectiveBusinessDate(deadline);
|
||||
|
||||
Assert.Equal(new DateOnly(expectedYear, expectedMonth, expectedDay), effective);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2026, 7, 24, 2026, 7, 27, 3)]
|
||||
[InlineData(2026, 7, 25, 2026, 7, 27, 2)]
|
||||
[InlineData(2026, 7, 26, 2026, 7, 27, 1)]
|
||||
public void SeasonalDeadlines_UseBusinessDayDiff(
|
||||
int refYear, int refMonth, int refDay,
|
||||
int expectedYear, int expectedMonth, int expectedDay,
|
||||
int expectedDays)
|
||||
{
|
||||
var deadline = new DateOnly(2026, 7, 25);
|
||||
var reference = new DateOnly(refYear, refMonth, refDay);
|
||||
var days = BusinessDayCalculator.GetBusinessDayDiff(deadline, reference);
|
||||
|
||||
Assert.Equal(expectedDays, days);
|
||||
Assert.Equal(new DateOnly(expectedYear, expectedMonth, expectedDay), BusinessDayCalculator.GetEffectiveBusinessDate(deadline));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TaxBaik.Application\TaxBaik.Application.csproj" />
|
||||
<ProjectReference Include="..\TaxBaik.Web\TaxBaik.Web.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user