feat(expense-snapshot): snapshot service with creator-name resolution + tests

This commit is contained in:
Chris Chen
2026-06-25 15:00:36 -07:00
parent f1de8d7ab7
commit 73c52ded88
3 changed files with 303 additions and 0 deletions
@@ -0,0 +1,141 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using ROLAC.API.Services.Logging;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class ExpenseSnapshotServiceTests
{
private static AppDbContext BuildDb(string userId)
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
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(new CurrentUserAccessor(mock.Object))).Options);
}
private static (ExpenseSnapshotService svc, AppDbContext db) Build(string userId = "u1")
{
var db = BuildDb(userId);
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Worship", Name_zh = "敬拜" });
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Facilities" });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Rent" });
db.SaveChanges();
return (new ExpenseSnapshotService(db), db);
}
private static CreateExpenseSnapshotRequest Rent() => new()
{
Name = "Monthly Rent", MinistryId = 1, Description = "Office rent", VendorName = "Landlord X",
CheckNumber = "1001",
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 1200m } },
};
[Fact]
public async Task Create_PersistsHeaderAndLines_StampsCreator()
{
var (svc, db) = Build("creator-1");
var id = await svc.CreateAsync(Rent());
var saved = await db.ExpenseSnapshots.FindAsync(id);
Assert.Equal("Monthly Rent", saved!.Name);
Assert.Equal("creator-1", saved.CreatedBy);
Assert.Equal(1, await db.ExpenseSnapshotLines.CountAsync(l => l.SnapshotId == id));
}
[Fact]
public async Task Create_WithNoLines_Throws()
{
var (svc, _) = Build();
var r = Rent(); r.Lines.Clear();
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.CreateAsync(r));
}
[Fact]
public async Task GetById_ReturnsLines_TotalsAndCreatorName()
{
var (svc, db) = Build("creator-1");
db.Members.Add(new Member { Id = 5, FirstName_en = "Joy", LastName_en = "Wong" });
db.Users.Add(new AppUser { Id = "creator-1", MemberId = 5 });
await db.SaveChangesAsync();
var id = await svc.CreateAsync(Rent());
var dto = await svc.GetByIdAsync(id);
Assert.NotNull(dto);
Assert.Equal(1200m, dto!.TotalAmount);
Assert.Equal(1, dto.LineCount);
Assert.Equal("Rent", dto.Lines.Single().SubCategoryName);
Assert.Equal("Joy Wong", dto.CreatedByName);
}
[Fact]
public async Task GetAll_ReturnsNewestFirst()
{
var (svc, _) = Build();
var first = await svc.CreateAsync(Rent());
var second = await svc.CreateAsync(Rent());
var all = await svc.GetAllAsync();
Assert.Equal(2, all.Count);
Assert.Equal(second, all[0].Id);
Assert.Equal(first, all[1].Id);
}
[Fact]
public async Task Update_RenamesAndReplacesLines()
{
var (svc, db) = Build();
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Utilities" });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Internet" });
await db.SaveChangesAsync();
var id = await svc.CreateAsync(Rent());
await svc.UpdateAsync(id, new UpdateExpenseSnapshotRequest
{
Name = "Monthly Internet", MinistryId = 1, Description = "ISP",
Lines = { new ExpenseLineInput { CategoryGroupId = 2, SubCategoryId = 2, Amount = 80m } },
});
var dto = await svc.GetByIdAsync(id);
Assert.Equal("Monthly Internet", dto!.Name);
Assert.Equal(80m, dto.TotalAmount);
Assert.Equal("Internet", dto.Lines.Single().SubCategoryName);
Assert.Equal(1, await db.ExpenseSnapshotLines.CountAsync(l => l.SnapshotId == id));
}
[Fact]
public async Task Update_MissingId_Throws()
{
var (svc, _) = Build();
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.UpdateAsync(999, new UpdateExpenseSnapshotRequest
{
Name = "x", MinistryId = 1, Description = "x",
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 1m } },
}));
}
[Fact]
public async Task Delete_SoftDeletes_HidesFromQueries()
{
var (svc, db) = Build();
var id = await svc.CreateAsync(Rent());
await svc.DeleteAsync(id);
Assert.Empty(await svc.GetAllAsync());
Assert.Null(await db.ExpenseSnapshots.FirstOrDefaultAsync(s => s.Id == id));
}
}
@@ -0,0 +1,151 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
public class ExpenseSnapshotService : IExpenseSnapshotService
{
private readonly AppDbContext _db;
public ExpenseSnapshotService(AppDbContext db) => _db = db;
public async Task<List<ExpenseSnapshotDto>> GetAllAsync()
{
var snaps = await _db.ExpenseSnapshots.AsNoTracking()
.OrderByDescending(s => s.CreatedAt).ThenByDescending(s => s.Id)
.ToListAsync();
if (snaps.Count == 0) return new();
var ids = snaps.Select(s => s.Id).ToList();
var lines = await _db.ExpenseSnapshotLines.AsNoTracking()
.Where(l => ids.Contains(l.SnapshotId)).ToListAsync();
var linesBySnapshot = lines.GroupBy(l => l.SnapshotId).ToDictionary(g => g.Key, g => g.ToList());
var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => $"{m.Name_en} / {m.Name_zh}");
var creatorNames = await ResolveUserNamesAsync(snaps.Select(s => s.CreatedBy));
return snaps.Select(s =>
{
linesBySnapshot.TryGetValue(s.Id, out var ls);
return new ExpenseSnapshotDto
{
Id = s.Id, Name = s.Name, MinistryId = s.MinistryId,
MinistryName = minNames.GetValueOrDefault(s.MinistryId, ""),
Description = s.Description, VendorName = s.VendorName,
CheckNumber = s.CheckNumber, Notes = s.Notes,
TotalAmount = ls?.Sum(l => l.Amount) ?? 0,
LineCount = ls?.Count ?? 0,
CreatedByName = creatorNames.GetValueOrDefault(s.CreatedBy),
CreatedAt = s.CreatedAt,
};
}).ToList();
}
public async Task<ExpenseSnapshotDto?> GetByIdAsync(int id)
{
var s = await _db.ExpenseSnapshots.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
if (s is null) return null;
var lines = await _db.ExpenseSnapshotLines.AsNoTracking()
.Where(l => l.SnapshotId == id).OrderBy(l => l.Id).ToListAsync();
var minName = await _db.Ministries.Where(m => m.Id == s.MinistryId).Select(m => m.Name_en).FirstOrDefaultAsync() ?? "";
var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => g.Name_en);
var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(x => x.Id, x => x.Name_en);
var creatorName = (await ResolveUserNamesAsync(new[] { s.CreatedBy })).GetValueOrDefault(s.CreatedBy);
return new ExpenseSnapshotDto
{
Id = s.Id, Name = s.Name, MinistryId = s.MinistryId, MinistryName = minName,
Description = s.Description, VendorName = s.VendorName, CheckNumber = s.CheckNumber, Notes = s.Notes,
TotalAmount = lines.Sum(l => l.Amount), LineCount = lines.Count,
CreatedByName = creatorName, CreatedAt = s.CreatedAt,
Lines = lines.Select(l => new ExpenseSnapshotLineDto
{
CategoryGroupId = l.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(l.CategoryGroupId, ""),
SubCategoryId = l.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(l.SubCategoryId, ""),
FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
}).ToList(),
};
}
public async Task<int> CreateAsync(CreateExpenseSnapshotRequest r)
{
ValidateLines(r.Lines);
var s = new ExpenseSnapshot
{
Name = r.Name.Trim(), MinistryId = r.MinistryId, Description = r.Description,
VendorName = r.VendorName, CheckNumber = r.CheckNumber, Notes = r.Notes,
Lines = BuildLines(r.Lines),
};
_db.ExpenseSnapshots.Add(s);
await _db.SaveChangesAsync();
return s.Id;
}
public async Task UpdateAsync(int id, UpdateExpenseSnapshotRequest r)
{
ValidateLines(r.Lines);
var s = await _db.ExpenseSnapshots.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Snapshot {id} not found.");
s.Name = r.Name.Trim(); s.MinistryId = r.MinistryId; s.Description = r.Description;
s.VendorName = r.VendorName; s.CheckNumber = r.CheckNumber; s.Notes = r.Notes;
_db.ExpenseSnapshotLines.RemoveRange(s.Lines);
s.Lines = BuildLines(r.Lines);
await _db.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var s = await _db.ExpenseSnapshots.FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Snapshot {id} not found.");
s.IsDeleted = true; s.DeletedAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync();
}
private static void ValidateLines(List<ExpenseLineInput> lines)
{
if (lines is null || lines.Count == 0)
throw new InvalidOperationException("A snapshot must have at least one line.");
foreach (var l in lines)
{
if (l.CategoryGroupId <= 0 || l.SubCategoryId <= 0)
throw new InvalidOperationException("Each snapshot line needs a category group and subcategory.");
if (l.Amount <= 0)
throw new InvalidOperationException("Each snapshot line amount must be greater than zero.");
}
}
private static List<ExpenseSnapshotLine> BuildLines(List<ExpenseLineInput> inputs) =>
inputs.Select(l => new ExpenseSnapshotLine
{
CategoryGroupId = l.CategoryGroupId, SubCategoryId = l.SubCategoryId,
FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
}).ToList();
// Resolve actor user ids (AppUser.Id, stored in CreatedBy) to a display name: the linked
// Member's full name when present, otherwise the account email. Mirrors ExpenseService.
private async Task<Dictionary<string, string>> ResolveUserNamesAsync(IEnumerable<string?> userIds)
{
var ids = userIds.Where(id => !string.IsNullOrEmpty(id)).Select(id => id!).Distinct().ToList();
if (ids.Count == 0) return new();
var users = await _db.Users.AsNoTracking()
.Where(u => ids.Contains(u.Id))
.Select(u => new { u.Id, u.Email, u.MemberId })
.ToListAsync();
var memberIds = users.Where(u => u.MemberId != null).Select(u => u.MemberId!.Value).ToHashSet();
var memberNames = await _db.Members.AsNoTracking()
.Where(m => memberIds.Contains(m.Id))
.ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}".Trim());
return users.ToDictionary(
u => u.Id,
u => u.MemberId != null && memberNames.TryGetValue(u.MemberId.Value, out var name) && name.Length > 0
? name
: (u.Email ?? u.Id));
}
}
@@ -0,0 +1,11 @@
using ROLAC.API.DTOs.Expense;
namespace ROLAC.API.Services;
public interface IExpenseSnapshotService
{
Task<List<ExpenseSnapshotDto>> GetAllAsync();
Task<ExpenseSnapshotDto?> GetByIdAsync(int id);
Task<int> CreateAsync(CreateExpenseSnapshotRequest r);
Task UpdateAsync(int id, UpdateExpenseSnapshotRequest r);
Task DeleteAsync(int id);
}