Files
ROLAC/API/ROLAC.API.Tests/Services/DisbursementServiceTests.cs
T
Chris Chen 3558c67fd7 WIP
2026-06-20 17:51:33 -07:00

272 lines
11 KiB
C#

using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using ROLAC.API.Services.Disbursement;
using ROLAC.API.Services.Storage;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class DisbursementServiceTests
{
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 sealed class FakePrint : ICheckPrintService
{
public CheckPrintModel? LastReceiptModel;
public Task<Stream> RenderPdfAsync(CheckPrintModel model)
=> Task.FromResult<Stream>(new MemoryStream(Encoding.UTF8.GetBytes("pdf")));
public Task<Stream> RenderReceiptPdfAsync(CheckPrintModel model)
{
LastReceiptModel = model;
return Task.FromResult<Stream>(new MemoryStream(Encoding.UTF8.GetBytes("receipt-pdf")));
}
}
private static AppDbContext BuildDb(string userId)
{
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options);
}
private static DisbursementService 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 DisbursementService(db, http.Object, fs, new FakePrint());
}
private static (DisbursementService svc, AppDbContext db, FakeStorage fs) Build(string userId = "fin")
{
var db = BuildDb(userId);
db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 });
db.Members.Add(new Member { Id = 1, FirstName_en = "John", LastName_en = "Doe", Address = "1 Main St", City = "Arcadia", State = "CA", ZipCode = "91006" });
db.SaveChanges();
var fs = new FakeStorage();
return (SvcAs(db, fs, userId), db, fs);
}
private static Expense Approved(string type, decimal amount, int? memberId = null, string? vendor = null) => new()
{
Type = type, Status = "Approved", Amount = amount, Description = $"{type} {amount}",
MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, ExpenseDate = new DateOnly(2026, 6, 1),
MemberId = memberId, VendorName = vendor,
};
[Fact]
public async Task GroupedWorklist_BundlesSamePayee()
{
var (svc, db, _) = Build();
db.Expenses.AddRange(
Approved("StaffReimbursement", 10m, memberId: 1),
Approved("StaffReimbursement", 15m, memberId: 1),
Approved("VendorPayment", 30m, vendor: "Acme"));
await db.SaveChangesAsync();
var groups = await svc.GetApprovedUnpaidGroupedAsync();
Assert.Equal(2, groups.Count);
var member = groups.Single(g => g.PayeeType == "Member");
Assert.Equal(25m, member.TotalAmount);
Assert.Equal(2, member.Lines.Count);
Assert.Equal("John Doe", member.PayeeName);
Assert.Equal("1 Main St", member.Address);
}
[Fact]
public async Task Issue_CreatesOneCheckPerPayee_MarksPaid_SequentialNumbers()
{
var (svc, db, _) = Build();
var e1 = Approved("StaffReimbursement", 10m, memberId: 1);
var e2 = Approved("StaffReimbursement", 15m, memberId: 1);
var e3 = Approved("VendorPayment", 30m, vendor: "Acme");
db.Expenses.AddRange(e1, e2, e3);
await db.SaveChangesAsync();
var req = new IssueChecksRequest
{
CheckDate = new DateOnly(2026, 6, 20),
Payees =
[
new() { PayeeType = "Member", MemberId = 1, PayeeName = "John Doe", ExpenseIds = [e1.Id, e2.Id] },
new() { PayeeType = "Vendor", VendorKey = "acme", PayeeName = "Acme", ExpenseIds = [e3.Id] },
],
};
var result = await svc.IssueChecksAsync(req);
Assert.Equal(2, result.Created.Count);
Assert.Equal(new[] { "1001", "1002" }, result.Created.Select(c => c.CheckNumber).ToArray());
Assert.All(await db.Expenses.ToListAsync(), e => Assert.Equal("Paid", e.Status));
var memberCheck = await db.Checks.FirstAsync(c => c.PayeeType == "Member");
Assert.Equal(25m, memberCheck.Amount);
Assert.Equal(1003, (await db.ChurchProfiles.FirstAsync()).NextCheckNumber);
}
[Fact]
public async Task Issue_RejectsNonApprovedExpense()
{
var (svc, db, _) = Build();
var e = Approved("VendorPayment", 30m, vendor: "Acme");
e.Status = "Draft";
db.Expenses.Add(e);
await db.SaveChangesAsync();
var req = new IssueChecksRequest
{
CheckDate = new DateOnly(2026, 6, 20),
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
};
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.IssueChecksAsync(req));
}
[Fact]
public async Task Void_RevertsExpensesToApproved()
{
var (svc, db, _) = Build();
var e = Approved("VendorPayment", 30m, vendor: "Acme");
db.Expenses.Add(e);
await db.SaveChangesAsync();
var result = await svc.IssueChecksAsync(new IssueChecksRequest
{
CheckDate = new DateOnly(2026, 6, 20),
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
});
var checkId = result.Created[0].CheckId;
await svc.VoidAsync(checkId, "wrong amount");
var check = await db.Checks.FirstAsync(c => c.Id == checkId);
Assert.Equal("Voided", check.Status);
var reverted = await db.Expenses.FirstAsync(x => x.Id == e.Id);
Assert.Equal("Approved", reverted.Status);
Assert.Null(reverted.CheckNumber);
Assert.Null(reverted.PaidAt);
}
[Fact]
public async Task Acknowledge_StoresSignatureAndTimestamp()
{
var (svc, db, fs) = Build();
var e = Approved("VendorPayment", 30m, vendor: "Acme");
db.Expenses.Add(e);
await db.SaveChangesAsync();
var result = await svc.IssueChecksAsync(new IssueChecksRequest
{
CheckDate = new DateOnly(2026, 6, 20),
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
});
var checkId = result.Created[0].CheckId;
using var img = new MemoryStream(Encoding.UTF8.GetBytes("png-bytes"));
await svc.AcknowledgeReceiptAsync(checkId, img, "sig.png", "Acme Rep");
var check = await db.Checks.FirstAsync(c => c.Id == checkId);
Assert.NotNull(check.ReceiptSignedAt);
Assert.Equal("Acme Rep", check.ReceiptSignedName);
Assert.NotNull(check.ReceiptSignatureBlobPath);
Assert.Single(fs.Files);
}
private static (DisbursementService svc, AppDbContext db, FakeStorage fs, FakePrint print) BuildWithPrint(string userId = "fin")
{
var db = BuildDb(userId);
db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 });
db.SaveChanges();
var fs = new FakeStorage();
var print = new FakePrint();
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 DisbursementService(db, http.Object, fs, print), db, fs, print);
}
[Fact]
public async Task ReceiptPdf_NullWhenNotSigned()
{
var (svc, db, _, _) = BuildWithPrint();
var e = Approved("VendorPayment", 30m, vendor: "Acme");
db.Expenses.Add(e);
await db.SaveChangesAsync();
var result = await svc.IssueChecksAsync(new IssueChecksRequest
{
CheckDate = new DateOnly(2026, 6, 20),
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
});
var receipt = await svc.RenderReceiptPdfAsync(result.Created[0].CheckId);
Assert.Null(receipt);
}
[Fact]
public async Task ReceiptPdf_AfterSigning_RendersWithSignatureBytes()
{
var (svc, db, _, print) = BuildWithPrint();
var e = Approved("VendorPayment", 30m, vendor: "Acme");
db.Expenses.Add(e);
await db.SaveChangesAsync();
var result = await svc.IssueChecksAsync(new IssueChecksRequest
{
CheckDate = new DateOnly(2026, 6, 20),
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
});
var checkId = result.Created[0].CheckId;
using var img = new MemoryStream(Encoding.UTF8.GetBytes("png-bytes"));
await svc.AcknowledgeReceiptAsync(checkId, img, "sig.png", "Acme Rep");
var receipt = await svc.RenderReceiptPdfAsync(checkId);
Assert.NotNull(receipt);
Assert.Equal("receipt-1001.pdf", receipt!.Value.fileName);
Assert.NotNull(print.LastReceiptModel);
Assert.NotNull(print.LastReceiptModel!.SignatureImage);
Assert.Equal("Acme Rep", print.LastReceiptModel.Check.ReceiptSignedName);
}
[Fact]
public async Task Acknowledge_VoidedCheck_Throws()
{
var (svc, db, _) = Build();
var e = Approved("VendorPayment", 30m, vendor: "Acme");
db.Expenses.Add(e);
await db.SaveChangesAsync();
var result = await svc.IssueChecksAsync(new IssueChecksRequest
{
CheckDate = new DateOnly(2026, 6, 20),
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
});
var checkId = result.Created[0].CheckId;
await svc.VoidAsync(checkId, null);
using var img = new MemoryStream(Encoding.UTF8.GetBytes("png"));
await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.AcknowledgeReceiptAsync(checkId, img, "sig.png", "X"));
}
}