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 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(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); } // 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); } 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_IsImmediatelyPaid() { 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); } [Fact] public async Task Create_Reimbursement_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 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); } }