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 Files = new(); public Task 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 OpenReadAsync(string p, CancellationToken ct = default) => Task.FromResult(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 RenderPdfAsync(CheckPrintModel model) => Task.FromResult(new MemoryStream(Encoding.UTF8.GetBytes("pdf"))); public Task RenderReceiptPdfAsync(CheckPrintModel model) { LastReceiptModel = model; return Task.FromResult(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(); mock.Setup(x => x.HttpContext).Returns(ctx); return new AppDbContext(new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options); } private static DisbursementService SvcAs(AppDbContext db, FakeStorage fs, string userId) { var http = new Mock(); 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(), ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance); } 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(() => 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(); 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, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance), 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( () => svc.AcknowledgeReceiptAsync(checkId, img, "sig.png", "X")); } }