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 ROLAC.API.Services.Storage; using Xunit; namespace ROLAC.API.Tests.Services; public class OfferingSessionServiceTests { // Proof storage is not exercised by these tests; a no-op keeps the service constructible. private sealed class NoOpFileStorage : IFileStorage { public Task SaveAsync(Stream content, string relativePath, CancellationToken ct = default) => Task.FromResult(relativePath); public Task OpenReadAsync(string relativePath, CancellationToken ct = default) => Task.FromResult(null); public Task DeleteAsync(string relativePath, CancellationToken ct = default) => Task.CompletedTask; } 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(); 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() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .AddInterceptors(interceptor) .Options); } private static async Task 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(), new NoOpFileStorage()); 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(), new NoOpFileStorage()); 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(), new NoOpFileStorage()); await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))); await Assert.ThrowsAsync(() => 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(), new NoOpFileStorage()); var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))); await Assert.ThrowsAsync(() => 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(), new NoOpFileStorage()); 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)); } [Fact] public async Task GetByIdAsync_ReturnsCheckZelleAndPayPalRefs() { using var db = BuildDb(); var catId = await SeedCategoryAsync(db); var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage()); var req = new CreateOfferingSessionRequest { SessionDate = new DateOnly(2026, 6, 7), CashTotal = 0m, CheckTotal = 100m, Givings = [ new() { GivingCategoryId = catId, Amount = 100m, PaymentMethod = "Zelle", ZelleReferenceCode = "Z-123", PayPalTransactionId = "PP-456", CheckNumber = "C-789" } ], }; var id = await svc.CreateAsync(req); var dto = await svc.GetByIdAsync(id); Assert.NotNull(dto); var line = Assert.Single(dto!.Givings); Assert.Equal("Z-123", line.ZelleReferenceCode); Assert.Equal("PP-456", line.PayPalTransactionId); Assert.Equal("C-789", line.CheckNumber); } }