@@ -7,8 +7,11 @@ 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;
|
||||
@@ -55,6 +58,14 @@ public class ExpenseServiceTests
|
||||
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<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, 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)
|
||||
@@ -342,4 +353,93 @@ public class ExpenseServiceTests
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user