272 lines
11 KiB
C#
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(new ROLAC.API.Services.Logging.CurrentUserAccessor(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(), 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<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, 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<InvalidOperationException>(
|
|
() => svc.AcknowledgeReceiptAsync(checkId, img, "sig.png", "X"));
|
|
}
|
|
}
|