feat(giving): offering-session batch service with server-side totals + locking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.Data.Interceptors;
|
||||
using ROLAC.API.DTOs.Giving;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class OfferingSessionServiceTests
|
||||
{
|
||||
private static IHttpContextAccessor BuildAccessor(string userId = "test-user")
|
||||
{
|
||||
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 mock.Object;
|
||||
}
|
||||
|
||||
private static AppDbContext BuildDb(string userId = "test-user")
|
||||
{
|
||||
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId));
|
||||
return new AppDbContext(
|
||||
new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.AddInterceptors(interceptor)
|
||||
.Options);
|
||||
}
|
||||
|
||||
private static async Task<int> SeedCategoryAsync(AppDbContext db)
|
||||
{
|
||||
var c = new GivingCategory { Name_en = "Tithe", IsActive = true };
|
||||
db.GivingCategories.Add(c);
|
||||
await db.SaveChangesAsync();
|
||||
return c.Id;
|
||||
}
|
||||
|
||||
private static CreateOfferingSessionRequest BuildRequest(int catId, DateOnly date) => new()
|
||||
{
|
||||
SessionDate = date, CashTotal = 150m, CheckTotal = 0m,
|
||||
Givings =
|
||||
[
|
||||
new() { GivingCategoryId = catId, Amount = 100m, PaymentMethod = "Cash" },
|
||||
new() { GivingCategoryId = catId, Amount = 50m, PaymentMethod = "Cash", IsAnonymous = true },
|
||||
],
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_RecomputesSystemTotalAndDifference_ServerSide()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var svc = new OfferingSessionService(db, BuildAccessor());
|
||||
|
||||
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||
|
||||
var saved = await db.OfferingSessions.FindAsync(id);
|
||||
Assert.Equal("Submitted", saved!.Status);
|
||||
Assert.Equal(150m, saved.SystemTotal);
|
||||
Assert.Equal(0m, saved.Difference);
|
||||
Assert.NotNull(saved.SubmittedAt);
|
||||
Assert.Equal(2, await db.Givings.CountAsync(g => g.OfferingSessionId == id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_LinesGetSessionDateAndAnonymousNullsMember()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var svc = new OfferingSessionService(db, BuildAccessor());
|
||||
|
||||
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||
|
||||
var lines = await db.Givings.Where(g => g.OfferingSessionId == id).ToListAsync();
|
||||
Assert.All(lines, l => Assert.Equal(new DateOnly(2026,5,31), l.GivingDate));
|
||||
Assert.Contains(lines, l => l.IsAnonymous && l.MemberId == null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_Throws_OnDuplicateSessionDate()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var svc = new OfferingSessionService(db, BuildAccessor());
|
||||
await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaceAsync_Throws_WhenSessionIsSubmitted()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var svc = new OfferingSessionService(db, BuildAccessor());
|
||||
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
svc.ReplaceAsync(id, BuildRequest(catId, new DateOnly(2026, 5, 31))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReopenThenReplace_SwapsLinesAndResubmits()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var svc = new OfferingSessionService(db, BuildAccessor());
|
||||
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||
|
||||
await svc.ReopenAsync(id);
|
||||
var reopened = await db.OfferingSessions.FindAsync(id);
|
||||
Assert.Equal("Draft", reopened!.Status);
|
||||
|
||||
var newReq = new CreateOfferingSessionRequest
|
||||
{
|
||||
SessionDate = new DateOnly(2026,5,31), CashTotal = 200m, CheckTotal = 0m,
|
||||
Givings = [ new() { GivingCategoryId = catId, Amount = 200m, PaymentMethod = "Cash" } ],
|
||||
};
|
||||
await svc.ReplaceAsync(id, newReq);
|
||||
|
||||
var after = await db.OfferingSessions.FindAsync(id);
|
||||
Assert.Equal("Submitted", after!.Status);
|
||||
Assert.Equal(200m, after.SystemTotal);
|
||||
Assert.Equal(1, await db.Givings.CountAsync(g => g.OfferingSessionId == id));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user