diff --git a/API/ROLAC.API.Tests/Services/ExpenseCategoryServiceTests.cs b/API/ROLAC.API.Tests/Services/ExpenseCategoryServiceTests.cs new file mode 100644 index 0000000..a77025c --- /dev/null +++ b/API/ROLAC.API.Tests/Services/ExpenseCategoryServiceTests.cs @@ -0,0 +1,61 @@ +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.DTOs.Expense; +using ROLAC.API.Services; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class ExpenseCategoryServiceTests +{ + private static AppDbContext BuildDb() + { + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") }; + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; + var mock = new Mock(); + mock.Setup(x => x.HttpContext).Returns(ctx); + return new AppDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); + } + + [Fact] + public async Task GetAll_NestsSubcategories_AndExcludesInactiveByDefault() + { + using var db = BuildDb(); + var svc = new ExpenseCategoryService(db); + var gid = await svc.CreateGroupAsync(new CreateExpenseGroupRequest { Name_en = "Equipment" }); + var sid = await svc.CreateSubCategoryAsync(new CreateExpenseSubCategoryRequest { GroupId = gid, Name_en = "Purchase" }); + await svc.DeactivateSubCategoryAsync(sid); + + var active = await svc.GetAllAsync(includeInactive: false); + var all = await svc.GetAllAsync(includeInactive: true); + + Assert.Single(active); + Assert.Empty(active[0].SubCategories); + Assert.Single(all[0].SubCategories); + } + + [Fact] + public async Task DeactivateGroup_SetsInactive() + { + using var db = BuildDb(); + var svc = new ExpenseCategoryService(db); + var gid = await svc.CreateGroupAsync(new CreateExpenseGroupRequest { Name_en = "Other" }); + await svc.DeactivateGroupAsync(gid); + Assert.Empty(await svc.GetAllAsync(includeInactive: false)); + } + + [Fact] + public async Task UpdateGroup_Throws_WhenMissing() + { + using var db = BuildDb(); + var svc = new ExpenseCategoryService(db); + await Assert.ThrowsAsync(() => + svc.UpdateGroupAsync(999, new UpdateExpenseGroupRequest { Name_en = "X" })); + } +} diff --git a/API/ROLAC.API/Services/ExpenseCategoryService.cs b/API/ROLAC.API/Services/ExpenseCategoryService.cs new file mode 100644 index 0000000..cd19f18 --- /dev/null +++ b/API/ROLAC.API/Services/ExpenseCategoryService.cs @@ -0,0 +1,86 @@ +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Expense; +using ROLAC.API.Entities; + +namespace ROLAC.API.Services; + +public class ExpenseCategoryService : IExpenseCategoryService +{ + private readonly AppDbContext _db; + public ExpenseCategoryService(AppDbContext db) => _db = db; + + public async Task> GetAllAsync(bool includeInactive) + { + var groups = await _db.ExpenseCategoryGroups.AsNoTracking() + .Where(g => includeInactive || g.IsActive) + .OrderBy(g => g.SortOrder).ThenBy(g => g.Name_en) + .ToListAsync(); + + var subs = await _db.ExpenseSubCategories.AsNoTracking() + .Where(s => includeInactive || s.IsActive) + .OrderBy(s => s.SortOrder).ThenBy(s => s.Name_en) + .ToListAsync(); + + return groups.Select(g => new ExpenseCategoryGroupDto + { + Id = g.Id, Name_en = g.Name_en, Name_zh = g.Name_zh, + SortOrder = g.SortOrder, IsActive = g.IsActive, + SubCategories = subs.Where(s => s.GroupId == g.Id).Select(s => new ExpenseSubCategoryDto + { + Id = s.Id, GroupId = s.GroupId, Name_en = s.Name_en, Name_zh = s.Name_zh, + SortOrder = s.SortOrder, IsActive = s.IsActive, + }).ToList(), + }).ToList(); + } + + public async Task CreateGroupAsync(CreateExpenseGroupRequest r) + { + var g = new ExpenseCategoryGroup { Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true }; + _db.ExpenseCategoryGroups.Add(g); + await _db.SaveChangesAsync(); + return g.Id; + } + + public async Task UpdateGroupAsync(int id, UpdateExpenseGroupRequest r) + { + var g = await _db.ExpenseCategoryGroups.FindAsync(id) + ?? throw new KeyNotFoundException($"ExpenseCategoryGroup {id} not found."); + g.Name_en = r.Name_en; g.Name_zh = r.Name_zh; g.SortOrder = r.SortOrder; g.IsActive = r.IsActive; + await _db.SaveChangesAsync(); + } + + public async Task DeactivateGroupAsync(int id) + { + var g = await _db.ExpenseCategoryGroups.FindAsync(id) + ?? throw new KeyNotFoundException($"ExpenseCategoryGroup {id} not found."); + g.IsActive = false; + await _db.SaveChangesAsync(); + } + + public async Task CreateSubCategoryAsync(CreateExpenseSubCategoryRequest r) + { + var exists = await _db.ExpenseCategoryGroups.AnyAsync(g => g.Id == r.GroupId); + if (!exists) throw new KeyNotFoundException($"ExpenseCategoryGroup {r.GroupId} not found."); + var s = new ExpenseSubCategory { GroupId = r.GroupId, Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true }; + _db.ExpenseSubCategories.Add(s); + await _db.SaveChangesAsync(); + return s.Id; + } + + public async Task UpdateSubCategoryAsync(int id, UpdateExpenseSubCategoryRequest r) + { + var s = await _db.ExpenseSubCategories.FindAsync(id) + ?? throw new KeyNotFoundException($"ExpenseSubCategory {id} not found."); + s.GroupId = r.GroupId; s.Name_en = r.Name_en; s.Name_zh = r.Name_zh; s.SortOrder = r.SortOrder; s.IsActive = r.IsActive; + await _db.SaveChangesAsync(); + } + + public async Task DeactivateSubCategoryAsync(int id) + { + var s = await _db.ExpenseSubCategories.FindAsync(id) + ?? throw new KeyNotFoundException($"ExpenseSubCategory {id} not found."); + s.IsActive = false; + await _db.SaveChangesAsync(); + } +} diff --git a/API/ROLAC.API/Services/IExpenseCategoryService.cs b/API/ROLAC.API/Services/IExpenseCategoryService.cs new file mode 100644 index 0000000..ae4eb89 --- /dev/null +++ b/API/ROLAC.API/Services/IExpenseCategoryService.cs @@ -0,0 +1,13 @@ +using ROLAC.API.DTOs.Expense; +namespace ROLAC.API.Services; + +public interface IExpenseCategoryService +{ + Task> GetAllAsync(bool includeInactive); + Task CreateGroupAsync(CreateExpenseGroupRequest r); + Task UpdateGroupAsync(int id, UpdateExpenseGroupRequest r); + Task DeactivateGroupAsync(int id); + Task CreateSubCategoryAsync(CreateExpenseSubCategoryRequest r); + Task UpdateSubCategoryAsync(int id, UpdateExpenseSubCategoryRequest r); + Task DeactivateSubCategoryAsync(int id); +}