refactor: move buildable .NET source into src/, update CI/doc paths
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:
2026-07-03 10:37:37 +09:00
parent c00d002972
commit ea447495d3
277 changed files with 36 additions and 29 deletions
@@ -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>