WIP
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
using ROLAC.API.Services.Disbursement;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class AmountToWordsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0, "Zero and 00/100 Dollars")]
|
||||
[InlineData(0.05, "Zero and 05/100 Dollars")]
|
||||
[InlineData(1, "One and 00/100 Dollars")]
|
||||
[InlineData(19, "Nineteen and 00/100 Dollars")]
|
||||
[InlineData(20, "Twenty and 00/100 Dollars")]
|
||||
[InlineData(21, "Twenty-One and 00/100 Dollars")]
|
||||
[InlineData(100, "One Hundred and 00/100 Dollars")]
|
||||
[InlineData(115, "One Hundred Fifteen and 00/100 Dollars")]
|
||||
[InlineData(1234.56, "One Thousand Two Hundred Thirty-Four and 56/100 Dollars")]
|
||||
[InlineData(1000000, "One Million and 00/100 Dollars")]
|
||||
public void Convert_FormatsExpectedWords(double amount, string expected)
|
||||
{
|
||||
Assert.Equal(expected, AmountToWords.Convert((decimal)amount));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_RoundsCentsHalfUp()
|
||||
{
|
||||
Assert.Equal("One and 00/100 Dollars", AmountToWords.Convert(0.999m));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -98,16 +98,24 @@ public class ExpenseServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Vendor_AsFinance_IsImmediatelyPaid()
|
||||
public async Task Create_Vendor_AsFinance_IsPendingApproval()
|
||||
{
|
||||
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);
|
||||
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Reimbursement_IsDraft_WithSubmitter()
|
||||
public async Task Create_Reimbursement_AsFinance_IsPendingApproval()
|
||||
{
|
||||
var (svc, db, _) = Build();
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: true);
|
||||
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Reimbursement_AsMember_IsDraft_WithSubmitter()
|
||||
{
|
||||
var (svc, db, _) = Build("alice");
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
|
||||
Reference in New Issue
Block a user