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.Entities.Logging; using ROLAC.API.Services; using ROLAC.API.Services.Logging; using ROLAC.API.Services.Storage; using ROLAC.API.Tests.TestSupport; using Xunit; namespace ROLAC.API.Tests.Services; public class ExpenseServiceTests { 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 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(); mock.Setup(x => x.HttpContext).Returns(ctx); return new AppDbContext(new DbContextOptionsBuilder() .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(); 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); } private static ExpenseService SvcAs(AppDbContext db, FakeStorage fs, string userId, IAuditLogger audit) { 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 ExpenseService(db, http.Object, fs, audit); } // 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(); 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, Lines = { new ExpenseLineInput { 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, Lines = r.Lines.Select(l => new ExpenseLineInput { CategoryGroupId = l.CategoryGroupId, SubCategoryId = l.SubCategoryId, Amount = l.Amount, FunctionalClass = l.FunctionalClass, Description = l.Description, }).ToList(), 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(() => 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(() => 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.Lines[0].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(() => 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 Create_PersistsFunctionalClass_AndGetReturnsIt() { var db = BuildDb("u1"); db.Ministries.Add(new ROLAC.API.Entities.Ministry { Id = 1, Name_en = "Admin" }); db.ExpenseCategoryGroups.Add(new ROLAC.API.Entities.ExpenseCategoryGroup { Id = 1, Name_en = "Other" }); db.ExpenseSubCategories.Add(new ROLAC.API.Entities.ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Misc" }); await db.SaveChangesAsync(); var svc = SvcAs(db, new FakeStorage(), "u1"); var id = await svc.CreateAsync(new CreateExpenseRequest { Type = "VendorPayment", MinistryId = 1, Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 50m, FunctionalClass = "ManagementGeneral" } }, Description = "x", ExpenseDate = new DateOnly(2026, 5, 1), }, isFinance: true); var dto = await svc.GetByIdAsync(id); Assert.Equal("ManagementGeneral", dto!.Lines.Single().FunctionalClass); } [Fact] public async Task Create_MultiLine_SetsHeaderTotal_AndRoundTripsLines() { var (svc, db, _) = Build("u1"); db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Food & Beverage" }); db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Snacks" }); await db.SaveChangesAsync(); var r = new CreateExpenseRequest { Type = "VendorPayment", MinistryId = 1, VendorName = "Costco", Description = "Mixed invoice", ExpenseDate = new DateOnly(2026, 5, 1), Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 30m }, new ExpenseLineInput { CategoryGroupId = 2, SubCategoryId = 2, Amount = 12.50m }, }, }; var id = await svc.CreateAsync(r, isFinance: true); Assert.Equal(42.50m, (await db.Expenses.FindAsync(id))!.Amount); var dto = await svc.GetByIdAsync(id); Assert.Equal(2, dto!.Lines.Count); Assert.Equal(42.50m, dto.Amount); Assert.Equal(2, dto.LineCount); } [Fact] public async Task Create_WithNoLines_Throws() { var (svc, _, _) = Build("u1"); var r = Reimb(); r.Lines.Clear(); await Assert.ThrowsAsync(() => svc.CreateAsync(r, isFinance: false)); } [Fact] public async Task Update_ReplacesLines_AndRecomputesTotal() { var (svc, db, _) = Build("alice"); db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Food & Beverage" }); db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Snacks" }); await db.SaveChangesAsync(); var id = await svc.CreateAsync(Reimb(), isFinance: false); var edit = CloneToUpdate(Reimb()); edit.Lines = new() { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 10m }, new ExpenseLineInput { CategoryGroupId = 2, SubCategoryId = 2, Amount = 5m }, }; await svc.UpdateAsync(id, edit, isFinance: false); Assert.Equal(15m, (await db.Expenses.FindAsync(id))!.Amount); Assert.Equal(2, await db.ExpenseLines.CountAsync(l => l.ExpenseId == 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); } [Fact] public async Task Reject_WritesAuditEntry_WithReason() { var (svc, db, fs) = Build("alice"); var id = await svc.CreateAsync(Reimb(), isFinance: false); await svc.SubmitAsync(id); var audit = new CapturingAuditLogger(); await SvcAs(db, fs, "finance", audit).RejectAsync(id, "Receipt unclear, please retake"); var entry = Assert.Single(audit.Entries); Assert.Equal(AuditActions.ExpenseRejected, entry.Action); Assert.Equal(AuditCategories.Business, entry.Category); Assert.Equal(nameof(ROLAC.API.Entities.Expense), entry.EntityName); Assert.Equal(id.ToString(), entry.EntityId); Assert.Contains("Receipt unclear", entry.Summary); } [Fact] public async Task Resubmit_FromRejected_ReturnsToPending_AndClearsReview() { var (svc, db, fs) = Build("alice"); var id = await svc.CreateAsync(Reimb(), isFinance: false); await svc.SubmitAsync(id); await SvcAs(db, fs, "finance").RejectAsync(id, "Receipt missing"); // Owner fixes the issue and re-submits. await svc.SubmitAsync(id); var e = await db.Expenses.FindAsync(id); Assert.Equal("PendingApproval", e!.Status); Assert.Null(e.ReviewedBy); Assert.Null(e.ReviewedAt); Assert.Null(e.ReviewNotes); } [Fact] public async Task Update_OwnRejected_AsNonFinance_Succeeds() { // A rejected reimbursement can be corrected by its owner before re-submitting. var (svc, db, fs) = Build("alice"); var id = await svc.CreateAsync(Reimb(), isFinance: false); await svc.SubmitAsync(id); await SvcAs(db, fs, "finance").RejectAsync(id, "Amount does not match receipt"); var edit = CloneToUpdate(Reimb()); edit.Lines[0].Amount = 77.77m; await svc.UpdateAsync(id, edit, isFinance: false); var e = await db.Expenses.FindAsync(id); Assert.Equal(77.77m, e!.Amount); Assert.Equal("Rejected", e.Status); } [Fact] public async Task SaveReceipt_OwnRejected_AsNonFinance_Succeeds() { var (svc, db, fs) = Build("alice"); var id = await svc.CreateAsync(Reimb(), isFinance: false); await svc.SubmitAsync(id); await SvcAs(db, fs, "finance").RejectAsync(id, "Receipt unclear, please retake"); using var input = new MemoryStream(Encoding.UTF8.GetBytes("img")); await svc.SaveReceiptAsync(id, input, "retake.jpg", isFinance: false); Assert.NotNull(await svc.OpenReceiptAsync(id, isFinance: true)); } [Fact] public async Task GetById_ResolvesReviewerName_MemberFullName_EmailFallback() { var (svc, db, fs) = Build("alice"); // Reviewer linked to a member → shows the member's full name. db.Members.Add(new Member { Id = 5, FirstName_en = "Sam", LastName_en = "Approver" }); db.Users.Add(new AppUser { Id = "reviewer-with-member", MemberId = 5 }); // Reviewer with no member → falls back to email. db.Users.Add(new AppUser { Id = "reviewer-no-member", Email = "nomember@church.org" }); await db.SaveChangesAsync(); var withMember = await svc.CreateAsync(Reimb(), isFinance: false); await svc.SubmitAsync(withMember); await SvcAs(db, fs, "reviewer-with-member").ApproveAsync(withMember); Assert.Equal("Sam Approver", (await svc.GetByIdAsync(withMember))!.ReviewedByName); var noMember = await svc.CreateAsync(Reimb(), isFinance: false); await svc.SubmitAsync(noMember); await SvcAs(db, fs, "reviewer-no-member").RejectAsync(noMember, "Duplicate submission"); Assert.Equal("nomember@church.org", (await svc.GetByIdAsync(noMember))!.ReviewedByName); } }