add approve.
ci-cd-vm / ci-cd (push) Successful in 2m24s

This commit is contained in:
Chris Chen
2026-06-25 10:22:01 -07:00
parent 8bdb942a49
commit fa3e75a333
15 changed files with 506 additions and 46 deletions
@@ -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);
}
}
@@ -0,0 +1,21 @@
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Tests.TestSupport;
/// <summary>Records every audit Write so tests can assert on the emitted actions/summaries.</summary>
public sealed class CapturingAuditLogger : IAuditLogger
{
public readonly record struct Entry(string Action, string Category, string? EntityName, string? EntityId, string? Summary);
public readonly List<Entry> Entries = new();
public void Write(
string action, string category, LogLevelEnum level = LogLevelEnum.Information,
string? entityName = null, string? entityId = null, string? summary = null,
object? before = null, object? after = null,
string? userId = null, string? userEmail = null, string? ipAddress = null)
{
Entries.Add(new Entry(action, category, entityName, entityId, summary));
}
}