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, g => g.Name_en == "Professional Services"); } [Fact] public async Task SeedMinistries_SetsAdministrationToManagementGeneral_OthersProgram() { using var db = BuildDb(); await DbSeeder.SeedMinistriesAsync(db); var admin = await db.Ministries.FirstAsync(m => m.Name_en == "Administration"); var worship = await db.Ministries.FirstAsync(m => m.Name_en == "Worship"); Assert.Equal("ManagementGeneral", admin.DefaultFunctionalClass); Assert.Equal("Program", worship.DefaultFunctionalClass); // Activity/shepherding ministries are an attribution axis only; they default to Program // so adding them never distorts the 990 functional columns. var cellGroups = await db.Ministries.FirstAsync(m => m.Name_en == "Cell Groups"); var specialEvents = await db.Ministries.FirstAsync(m => m.Name_en == "Special Events"); Assert.Equal("Program", cellGroups.DefaultFunctionalClass); Assert.Equal("Program", specialEvents.DefaultFunctionalClass); } [Fact] public async Task SeedForm990Lines_CreatesCatalog_AndMapsKnownSubcategories() { using var db = BuildDb(); await DbSeeder.SeedExpenseCategoriesAsync(db); await DbSeeder.SeedForm990ExpenseLinesAsync(db); await DbSeeder.SeedForm990ExpenseLinesAsync(db); // idempotent Assert.Equal(1, await db.Form990ExpenseLines.CountAsync(l => l.LineCode == "7")); Assert.True(await db.Form990ExpenseLines.AnyAsync(l => l.LineCode == "24")); var salary = await db.ExpenseSubCategories.Include(s => s.Form990Line) .FirstAsync(s => s.Name_en == "Salary & Wages"); Assert.Equal("7", salary.Form990Line!.LineCode); var audit = await db.ExpenseSubCategories.Include(s => s.Form990Line) .FirstAsync(s => s.Name_en == "Accounting & Audit"); Assert.Equal("11c", audit.Form990Line!.LineCode); } [Fact] public async Task SeedForm990Lines_MapsAuditCorrectedSubcategories_OffTheLine24CatchAll() { using var db = BuildDb(); await DbSeeder.SeedExpenseCategoriesAsync(db); await DbSeeder.SeedForm990ExpenseLinesAsync(db); async Task CodeOf(string subEn) => (await db.ExpenseSubCategories.Include(s => s.Form990Line) .FirstAsync(s => s.Name_en == subEn)).Form990Line!.LineCode; // Newly mapped subcategories that previously fell through to line 24. Assert.Equal("13", await CodeOf("Bank & Processing Fees")); Assert.Equal("13", await CodeOf("Rental")); Assert.Equal("13", await CodeOf("Maintenance & Repair")); Assert.Equal("13", await CodeOf("Cleaning Supplies")); Assert.Equal("13", await CodeOf("Craft Supplies")); // Building repairs & maintenance are part of Occupancy (line 16), not equipment (line 13). Assert.Equal("16", await CodeOf("Repairs & Maintenance")); // Appreciation/outreach gifts are deliberately mapped to Other (line 24), not left unmapped. Assert.Equal("24", await CodeOf("Gifts")); // Visitation is a travel/program cost, not a grant to an individual. Assert.Equal("17", await CodeOf("Visit Expenses")); // Missions support paid to individual missionaries → line 2, not line 1 (organizations). Assert.Equal("2", await CodeOf("Missionary Support")); } [Fact] public async Task SeedForm990Lines_RemapsExistingBadMapping_ButNotAdminOverride() { using var db = BuildDb(); await DbSeeder.SeedExpenseCategoriesAsync(db); await DbSeeder.SeedForm990ExpenseLinesAsync(db); // Simulate a database seeded by the OLD code: Visit Expenses on line 2, Missionary // Support on line 1. Also simulate an admin who deliberately moved one elsewhere. var lineByCode = await db.Form990ExpenseLines.ToDictionaryAsync(l => l.LineCode, l => l.Id); var visit = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Visit Expenses"); var missionary = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Missionary Support"); var transfer = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Offering Transfer"); visit.Form990LineId = lineByCode["2"]; // old (wrong) value → should be corrected missionary.Form990LineId = lineByCode["1"]; // old (wrong) value → should be corrected transfer.Form990LineId = lineByCode["24"]; // admin override → must be left alone await db.SaveChangesAsync(); await DbSeeder.SeedForm990ExpenseLinesAsync(db); await db.Entry(visit).ReloadAsync(); await db.Entry(missionary).ReloadAsync(); await db.Entry(transfer).ReloadAsync(); Assert.Equal(lineByCode["17"], visit.Form990LineId); // corrected 2 → 17 Assert.Equal(lineByCode["2"], missionary.Form990LineId); // corrected 1 → 2 Assert.Equal(lineByCode["24"], transfer.Form990LineId); // admin edit preserved } }