feat(expense): add ExpenseCategoryService + tests
TDD cycle: wrote 3 xUnit tests first (red), then implemented IExpenseCategoryService + ExpenseCategoryService (green).
This commit is contained in:
@@ -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<IHttpContextAccessor>();
|
||||||
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.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<KeyNotFoundException>(() =>
|
||||||
|
svc.UpdateGroupAsync(999, new UpdateExpenseGroupRequest { Name_en = "X" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<ExpenseCategoryGroupDto>> 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<int> 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<int> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using ROLAC.API.DTOs.Expense;
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public interface IExpenseCategoryService
|
||||||
|
{
|
||||||
|
Task<List<ExpenseCategoryGroupDto>> GetAllAsync(bool includeInactive);
|
||||||
|
Task<int> CreateGroupAsync(CreateExpenseGroupRequest r);
|
||||||
|
Task UpdateGroupAsync(int id, UpdateExpenseGroupRequest r);
|
||||||
|
Task DeactivateGroupAsync(int id);
|
||||||
|
Task<int> CreateSubCategoryAsync(CreateExpenseSubCategoryRequest r);
|
||||||
|
Task UpdateSubCategoryAsync(int id, UpdateExpenseSubCategoryRequest r);
|
||||||
|
Task DeactivateSubCategoryAsync(int id);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user