262 lines
11 KiB
C#
262 lines
11 KiB
C#
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(new ROLAC.API.Services.Logging.CurrentUserAccessor(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, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
|
}
|
|
|
|
// Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier),
|
|
// mirroring the real JWT (NameClaimType="sub", MapInboundClaims=false).
|
|
private static ExpenseService SvcWithSubClaim(AppDbContext db, FakeStorage fs, string userId)
|
|
{
|
|
var http = new Mock<IHttpContextAccessor>();
|
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim("sub", userId) })) };
|
|
http.Setup(x => x.HttpContext).Returns(ctx);
|
|
return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
|
}
|
|
|
|
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_Reimbursement_ResolvesUserId_FromSubClaim()
|
|
{
|
|
// Regression: the real JWT exposes the user id as "sub", not ClaimTypes.NameIdentifier.
|
|
// SubmittedBy must be the sub value (not "system"), or the self-ownership guard breaks.
|
|
var db = BuildDb("ignored");
|
|
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" });
|
|
await db.SaveChangesAsync();
|
|
var svc = SvcWithSubClaim(db, new FakeStorage(), "user-guid-123");
|
|
|
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
|
|
|
var e = await db.Expenses.FindAsync(id);
|
|
Assert.Equal("user-guid-123", e!.SubmittedBy);
|
|
}
|
|
|
|
[Fact]
|
|
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("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_Reimbursement_AsFinance_OnBehalf_IsPendingApproval_AndLinksPickedMember()
|
|
{
|
|
// Finance entering on behalf of a member (member explicitly picked) goes straight to the
|
|
// approval queue and links the picked member.
|
|
var (svc, db, _) = Build();
|
|
db.Members.Add(new Member { Id = 9, FirstName_en = "Pat", LastName_en = "Vendor" });
|
|
await db.SaveChangesAsync();
|
|
var r = Reimb(); r.MemberId = 9;
|
|
|
|
var id = await svc.CreateAsync(r, isFinance: true);
|
|
|
|
var e = await db.Expenses.FindAsync(id);
|
|
Assert.Equal("PendingApproval", e!.Status);
|
|
Assert.Equal(9, e.MemberId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_Reimbursement_AsFinance_SelfService_LinksCallerMember_AndIsDraft()
|
|
{
|
|
// Regression: a finance/super_admin user filing their OWN reimbursement via "My Reimbursements"
|
|
// sends no MemberId. The entry must link to the caller's own member (so the Payee shows their
|
|
// legal name) and stay a Draft until they explicitly Submit — not jump to PendingApproval with
|
|
// a null member.
|
|
var (svc, db, _) = Build("u1");
|
|
db.Members.Add(new Member { Id = 7, FirstName_en = "Grace", LastName_en = "Lee" });
|
|
db.Users.Add(new AppUser { Id = "u1", MemberId = 7 });
|
|
await db.SaveChangesAsync();
|
|
|
|
var id = await svc.CreateAsync(Reimb(), isFinance: true); // no MemberId on the request
|
|
|
|
var e = await db.Expenses.FindAsync(id);
|
|
Assert.Equal(7, e!.MemberId);
|
|
Assert.Equal("Draft", e.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_Reimbursement_AsMember_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 Update_OwnPendingApproval_AsNonFinance_Succeeds()
|
|
{
|
|
// After Submit a reimbursement sits in PendingApproval; the owner may still correct it.
|
|
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);
|
|
|
|
var edit = CloneToUpdate(Reimb());
|
|
edit.Amount = 99.99m;
|
|
await svc.UpdateAsync(id, edit, isFinance: false);
|
|
|
|
var e = await db.Expenses.FindAsync(id);
|
|
Assert.Equal(99.99m, e!.Amount);
|
|
Assert.Equal("PendingApproval", e.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Update_OwnApproved_AsNonFinance_Throws()
|
|
{
|
|
// Once approved, the owner can no longer edit.
|
|
var (svc, db, fs) = Build("alice");
|
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
|
await svc.SubmitAsync(id);
|
|
await SvcAs(db, fs, "finance").ApproveAsync(id);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
svc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SaveReceipt_OwnPendingApproval_AsNonFinance_Succeeds()
|
|
{
|
|
var (svc, db, _) = Build("alice");
|
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
|
await svc.SubmitAsync(id);
|
|
using var input = new MemoryStream(Encoding.UTF8.GetBytes("img"));
|
|
await svc.SaveReceiptAsync(id, input, "r.jpg", isFinance: false);
|
|
Assert.NotNull(await svc.OpenReceiptAsync(id, isFinance: true));
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|