diff --git a/API/ROLAC.API.Tests/Services/DbSeederForm990Tests.cs b/API/ROLAC.API.Tests/Services/DbSeederForm990Tests.cs new file mode 100644 index 0000000..02a596b --- /dev/null +++ b/API/ROLAC.API.Tests/Services/DbSeederForm990Tests.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Moq; +using ROLAC.API.Data; +using ROLAC.API.Data.Interceptors; +using ROLAC.API.Entities; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class DbSeederForm990Tests +{ + private static AppDbContext BuildDb() + { + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "seed") })) }; + var mock = new Mock(); + mock.Setup(x => x.HttpContext).Returns(ctx); + return new AppDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options); + } + + [Fact] + public async Task SeedExpenseCategories_AddsNewGroups_RenamesDuplicates_AndIsIdempotent() + { + using var db = BuildDb(); + var fnb = new ExpenseCategoryGroup { Name_en = "Food & Beverage", Name_zh = "餐飲", SortOrder = 3 }; + db.ExpenseCategoryGroups.Add(fnb); + await db.SaveChangesAsync(); + db.ExpenseSubCategories.Add(new ExpenseSubCategory { GroupId = fnb.Id, Name_en = "Consumables", Name_zh = "消耗品" }); + await db.SaveChangesAsync(); + + await DbSeeder.SeedExpenseCategoriesAsync(db); + await DbSeeder.SeedExpenseCategoriesAsync(db); // idempotent second run + + var groups = await db.ExpenseCategoryGroups.ToListAsync(); + Assert.Contains(groups, g => g.Name_en == "Professional Services"); + Assert.Contains(groups, g => g.Name_en == "Information Technology"); + Assert.Contains(groups, g => g.Name_en == "Finance & Banking"); + + var fnbSubs = await db.ExpenseSubCategories.Where(s => s.GroupId == fnb.Id).ToListAsync(); + Assert.DoesNotContain(fnbSubs, s => s.Name_en == "Consumables"); + Assert.Contains(fnbSubs, s => s.Name_en == "Disposable Tableware"); + + Assert.Single(groups.Where(g => g.Name_en == "Professional Services")); + } +} diff --git a/API/ROLAC.API/Data/DbSeeder.cs b/API/ROLAC.API/Data/DbSeeder.cs index 96b1234..73b12f8 100644 --- a/API/ROLAC.API/Data/DbSeeder.cs +++ b/API/ROLAC.API/Data/DbSeeder.cs @@ -35,15 +35,18 @@ public static class DbSeeder [ ("Equipment", "設備", 1, [("Purchase","購置"),("Rental","租借"),("Maintenance & Repair","維修")]), ("Consumables", "消耗品", 2, [("Batteries","電池"),("Accessories","配件"),("Cleaning Supplies","清潔用品"),("Office Supplies","文具")]), - ("Food & Beverage", "餐飲", 3, [("Catering","出餐費用"),("Food Ingredients","食材採購"),("Utensils","器具"),("Consumables","消耗品")]), + ("Food & Beverage", "餐飲", 3, [("Catering","出餐費用"),("Food Ingredients","食材採購"),("Utensils","器具"),("Disposable Tableware","一次性餐具")]), ("Training", "培訓", 4, [("Course Fees","課程費用"),("Books","書籍"),("Conference","研討會"),("Travel","差旅")]), - ("Materials", "教材", 5, [("Printing","印刷費用"),("Craft Supplies","手工材料"),("Copyright & Licensing","版權購買")]), + ("Materials", "教材", 5, [("Curriculum Printing","教材印刷"),("Craft Supplies","手工材料"),("Copyright & Licensing","版權購買")]), ("Facility", "場地", 6, [("Rent","場地租金"),("Utilities","水電"),("Property Insurance","財產保險"),("Decoration","裝飾")]), - ("Printing", "印刷", 7, [("Bulletins","週報"),("Order of Service","程序單"),("Posters","海報")]), - ("Missions", "宣教", 8, [("Offering Transfer","奉獻轉帳"),("Missionary Support","宣教士支援"),("Travel","差旅")]), + ("Printing", "印刷", 7, [("Bulletins","週報"),("Order of Service","程序單"),("Posters","海報"),("Advertising & Promotion","廣告推廣")]), + ("Missions", "宣教", 8, [("Offering Transfer","奉獻轉帳"),("Missionary Support","宣教士支援"),("Foreign Missions Support","國外宣教支援"),("Travel","差旅")]), ("Benevolence", "關懷救助", 9, [("Emergency Aid","急難救助"),("Condolence Gifts","慰問禮品"),("Visit Expenses","探訪費用")]), ("Other", "其他", 10, [("Miscellaneous","雜支")]), - ("Personnel", "人事", 11, [("Salary & Wages","薪資"),("Payroll Taxes","薪資稅費"),("Employee Benefits","員工福利"),("Workers Compensation","勞工保險"),("Honorarium","酬庸"),("Staff Training","同工進修"),("Contract Labor","外包勞務")]), + ("Personnel", "人事", 11, [("Officer / Key Employee Compensation","主要職員薪酬"),("Salary & Wages","薪資"),("Payroll Taxes","薪資稅費"),("Employee Benefits","員工福利"),("Retirement / Pension","退休金"),("Workers Compensation","勞工保險"),("Honorarium","酬庸"),("Staff Training","同工進修"),("Contract Labor","外包勞務")]), + ("Professional Services", "專業服務", 12, [("Legal","法律服務"),("Accounting & Audit","會計與審計"),("Other Professional","其他專業服務")]), + ("Information Technology", "資訊科技", 13, [("Software & Subscriptions","軟體與訂閱"),("Website & Hosting","網站與主機"),("Internet & Telecom","網路與電信")]), + ("Finance & Banking", "財務與銀行", 14, [("Interest","利息支出"),("Bank & Processing Fees","銀行/金流手續費")]), ]; private static readonly (string Name, string Description)[] Roles = @@ -186,6 +189,22 @@ public static class DbSeeder public static async Task SeedExpenseCategoriesAsync(AppDbContext db) { + // One-time renames to remove same-name-different-parent ambiguity. Idempotent: + // only fires while the old name still exists. (New installs never hit this.) + var renames = new (string GroupEn, string OldSub, string NewEn, string NewZh)[] + { + ("Food & Beverage", "Consumables", "Disposable Tableware", "一次性餐具"), + ("Materials", "Printing", "Curriculum Printing", "教材印刷"), + }; + foreach (var (groupEn, oldSub, newEn, newZh) in renames) + { + var grp = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == groupEn); + if (grp is null) continue; + var sub = await db.ExpenseSubCategories.FirstOrDefaultAsync(s => s.GroupId == grp.Id && s.Name_en == oldSub); + if (sub is not null) { sub.Name_en = newEn; sub.Name_zh = newZh; } + } + await db.SaveChangesAsync(); + foreach (var (gEn, gZh, gSort, subs) in ExpenseCategorySeed) { var group = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == gEn);