feat(expense): add ExpenseService with state machine + receipt storage + tests

TDD: wrote 8 tests first (red), then implemented IExpenseService + ExpenseService
covering CRUD, Draft→PendingApproval→Approved→Paid state machine, soft-delete,
per-owner access guards, and receipt blob round-trip via IFileStorage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-05-29 18:28:38 -07:00
parent 015f689d9b
commit d9289008f6
3 changed files with 416 additions and 0 deletions
@@ -0,0 +1,155 @@
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Http;
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.Storage;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class ExpenseServiceTests
{
private sealed class FakeStorage : IFileStorage
{
public Dictionary<string, byte[]> Files = new();
public Task<string> SaveAsync(Stream c, string p, CancellationToken ct = default)
{ using var ms = new MemoryStream(); c.CopyTo(ms); Files[p] = ms.ToArray(); return Task.FromResult(p); }
public Task<Stream?> OpenReadAsync(string p, CancellationToken ct = default)
=> Task.FromResult<Stream?>(Files.TryGetValue(p, out var b) ? new MemoryStream(b) : null);
public Task DeleteAsync(string p, CancellationToken ct = default) { Files.Remove(p); return Task.CompletedTask; }
}
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(mock.Object)).Options);
}
private static (ExpenseService svc, AppDbContext db, FakeStorage fs) Build(string userId = "u1")
{
var db = BuildDb(userId);
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Worship" });
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Equipment" });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Purchase" });
db.SaveChanges();
var fs = new FakeStorage();
return (SvcAs(db, fs, userId), db, fs);
}
private static ExpenseService SvcAs(AppDbContext db, FakeStorage fs, string userId)
{
var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx);
return new ExpenseService(db, http.Object, fs);
}
private static CreateExpenseRequest Reimb() => new()
{
Type = "StaffReimbursement", MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1,
Amount = 45.50m, Description = "Batteries", ExpenseDate = new DateOnly(2026, 5, 28),
};
private static UpdateExpenseRequest CloneToUpdate(CreateExpenseRequest r) => new()
{
Type = r.Type, MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId,
SubCategoryId = r.SubCategoryId, Amount = r.Amount, Description = r.Description,
VendorName = r.VendorName, MemberId = r.MemberId, CheckNumber = r.CheckNumber,
ExpenseDate = r.ExpenseDate, Notes = r.Notes,
};
[Fact]
public async Task Create_Vendor_AsFinance_IsImmediatelyPaid()
{
var (svc, db, _) = Build();
var r = Reimb(); r.Type = "VendorPayment"; r.VendorName = "ABC"; r.CheckNumber = "2051";
var id = await svc.CreateAsync(r, isFinance: true);
Assert.Equal("Paid", (await db.Expenses.FindAsync(id))!.Status);
}
[Fact]
public async Task Create_Reimbursement_IsDraft_WithSubmitter()
{
var (svc, db, _) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
var e = await db.Expenses.FindAsync(id);
Assert.Equal("Draft", e!.Status);
Assert.Equal("alice", e.SubmittedBy);
}
[Fact]
public async Task StateMachine_HappyPath_Submit_Approve_Pay()
{
var (svc, db, _) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
await svc.ApproveAsync(id);
Assert.Equal("Approved", (await db.Expenses.FindAsync(id))!.Status);
await svc.PayAsync(id, "3001", new DateOnly(2026, 6, 1));
var paid = await db.Expenses.FindAsync(id);
Assert.Equal("Paid", paid!.Status);
Assert.Equal("3001", paid.CheckNumber);
}
[Fact]
public async Task Approve_FromDraft_Throws()
{
var (svc, _, _) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.ApproveAsync(id));
}
[Fact]
public async Task Reject_RecordsNotes_AndStatus()
{
var (svc, db, _) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
await svc.RejectAsync(id, "Missing receipt");
var e = await db.Expenses.FindAsync(id);
Assert.Equal("Rejected", e!.Status);
Assert.Equal("Missing receipt", e.ReviewNotes);
}
[Fact]
public async Task Update_OthersDraft_AsNonFinance_Throws()
{
var (aliceSvc, db, fs) = Build("alice");
var id = await aliceSvc.CreateAsync(Reimb(), isFinance: false);
var bobSvc = SvcAs(db, fs, "bob");
await Assert.ThrowsAsync<InvalidOperationException>(() =>
bobSvc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false));
}
[Fact]
public async Task SoftDelete_HidesFromQueries()
{
var (svc, db, _) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.DeleteAsync(id, isFinance: true);
Assert.Null(await db.Expenses.FirstOrDefaultAsync(e => e.Id == id));
}
[Fact]
public async Task Receipt_SaveThenOpen_RoundTrips()
{
var (svc, _, _) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
using var input = new MemoryStream(Encoding.UTF8.GetBytes("img"));
await svc.SaveReceiptAsync(id, input, "r.jpg", isFinance: false);
var got = await svc.OpenReceiptAsync(id, isFinance: true);
Assert.NotNull(got);
}
}