Add unit tests for LedgerService, AccountBalanceService, DepositsController, and GiftCertificatesController
181 tests total passing. Covers all 9 LedgerService transaction sources, debit/credit balance mechanics, AR/AP movements, date range filtering, running balance computation, and future-dated opening balance exclusion. Also covers deposit recording/deletion, gift certificate lifecycle (issue, void, lazy expiry), and account balance recalculation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using PowderCoating.Application.DTOs.Accounting;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Infrastructure.Repositories;
|
||||
using PowderCoating.Infrastructure.Services;
|
||||
|
||||
namespace PowderCoating.UnitTests;
|
||||
|
||||
public class AccountBalanceServiceTests
|
||||
{
|
||||
// ── DebitAsync ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DebitAsync_WhenAccountIdIsNull_LeavesBalanceUnchanged()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var account = SeedAccount(context, 1, AccountSubType.Checking, 100m);
|
||||
await context.SaveChangesAsync();
|
||||
var (service, _) = CreateService(context);
|
||||
|
||||
await service.DebitAsync(null, 50m);
|
||||
|
||||
Assert.Equal(100m, account.CurrentBalance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DebitAsync_WhenAmountIsZero_LeavesBalanceUnchanged()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var account = SeedAccount(context, 1, AccountSubType.Checking, 100m);
|
||||
await context.SaveChangesAsync();
|
||||
var (service, _) = CreateService(context);
|
||||
|
||||
await service.DebitAsync(1, 0m);
|
||||
|
||||
Assert.Equal(100m, account.CurrentBalance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DebitAsync_WhenAccountNotFound_DoesNotThrow()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var (service, _) = CreateService(context);
|
||||
|
||||
// Account 999 does not exist — service must silently no-op
|
||||
await service.DebitAsync(999, 50m);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AccountSubType.Checking, 100, 50, 150)] // Asset — debit-normal
|
||||
[InlineData(AccountSubType.Savings, 200, 75, 275)] // Asset — debit-normal
|
||||
[InlineData(AccountSubType.AccountsReceivable, 0, 100, 100)] // Asset — debit-normal
|
||||
[InlineData(AccountSubType.CostOfGoodsSold, 50, 25, 75)] // COGS — debit-normal
|
||||
[InlineData(AccountSubType.Advertising, 0, 100, 100)] // Expense (≥50) — debit-normal
|
||||
[InlineData(AccountSubType.Payroll, 40, 60, 100)] // Expense (≥50) — debit-normal
|
||||
public async Task DebitAsync_OnDebitNormalAccount_IncreasesBalance(
|
||||
AccountSubType subType, decimal start, decimal amount, decimal expected)
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var account = SeedAccount(context, 1, subType, start);
|
||||
await context.SaveChangesAsync();
|
||||
var (service, _) = CreateService(context);
|
||||
|
||||
await service.DebitAsync(1, amount);
|
||||
|
||||
Assert.Equal(expected, account.CurrentBalance);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AccountSubType.AccountsPayable, 100, 50, 50)] // Liability — credit-normal
|
||||
[InlineData(AccountSubType.Sales, 200, 75, 125)] // Revenue — credit-normal
|
||||
[InlineData(AccountSubType.OwnersEquity, 500, 100, 400)] // Equity — credit-normal
|
||||
[InlineData(AccountSubType.RetainedEarnings, 300, 50, 250)] // Equity — credit-normal
|
||||
public async Task DebitAsync_OnCreditNormalAccount_DecreasesBalance(
|
||||
AccountSubType subType, decimal start, decimal amount, decimal expected)
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var account = SeedAccount(context, 1, subType, start);
|
||||
await context.SaveChangesAsync();
|
||||
var (service, _) = CreateService(context);
|
||||
|
||||
await service.DebitAsync(1, amount);
|
||||
|
||||
Assert.Equal(expected, account.CurrentBalance);
|
||||
}
|
||||
|
||||
// ── CreditAsync ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreditAsync_WhenAccountIdIsNull_LeavesBalanceUnchanged()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var account = SeedAccount(context, 1, AccountSubType.Checking, 100m);
|
||||
await context.SaveChangesAsync();
|
||||
var (service, _) = CreateService(context);
|
||||
|
||||
await service.CreditAsync(null, 50m);
|
||||
|
||||
Assert.Equal(100m, account.CurrentBalance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreditAsync_WhenAmountIsZero_LeavesBalanceUnchanged()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var account = SeedAccount(context, 1, AccountSubType.Checking, 100m);
|
||||
await context.SaveChangesAsync();
|
||||
var (service, _) = CreateService(context);
|
||||
|
||||
await service.CreditAsync(1, 0m);
|
||||
|
||||
Assert.Equal(100m, account.CurrentBalance);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AccountSubType.Checking, 200, 50, 150)] // Asset — debit-normal → credit decreases
|
||||
[InlineData(AccountSubType.CostOfGoodsSold, 100, 30, 70)] // COGS — debit-normal → credit decreases
|
||||
[InlineData(AccountSubType.SuppliesMaterials, 80, 20, 60)] // Expense (≥50) — debit-normal → credit decreases
|
||||
public async Task CreditAsync_OnDebitNormalAccount_DecreasesBalance(
|
||||
AccountSubType subType, decimal start, decimal amount, decimal expected)
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var account = SeedAccount(context, 1, subType, start);
|
||||
await context.SaveChangesAsync();
|
||||
var (service, _) = CreateService(context);
|
||||
|
||||
await service.CreditAsync(1, amount);
|
||||
|
||||
Assert.Equal(expected, account.CurrentBalance);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AccountSubType.AccountsPayable, 100, 50, 150)] // Liability — credit-normal → credit increases
|
||||
[InlineData(AccountSubType.Sales, 0, 200, 200)] // Revenue — credit-normal → credit increases
|
||||
[InlineData(AccountSubType.RetainedEarnings, 300, 100, 400)] // Equity — credit-normal → credit increases
|
||||
public async Task CreditAsync_OnCreditNormalAccount_IncreasesBalance(
|
||||
AccountSubType subType, decimal start, decimal amount, decimal expected)
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var account = SeedAccount(context, 1, subType, start);
|
||||
await context.SaveChangesAsync();
|
||||
var (service, _) = CreateService(context);
|
||||
|
||||
await service.CreditAsync(1, amount);
|
||||
|
||||
Assert.Equal(expected, account.CurrentBalance);
|
||||
}
|
||||
|
||||
// ── RecalculateAllAsync ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RecalculateAllAsync_UpdatesEachActiveAccountFromLedgerClosingBalance()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedAccount(context, 1, AccountSubType.Checking, 0m, companyId: 5);
|
||||
SeedAccount(context, 2, AccountSubType.Sales, 0m, companyId: 5);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ledger = new Mock<ILedgerService>();
|
||||
ledger.Setup(l => l.GetAccountLedgerAsync(1, It.IsAny<DateTime>(), It.IsAny<DateTime>()))
|
||||
.ReturnsAsync(new AccountLedgerDto { ClosingBalance = 500m });
|
||||
ledger.Setup(l => l.GetAccountLedgerAsync(2, It.IsAny<DateTime>(), It.IsAny<DateTime>()))
|
||||
.ReturnsAsync(new AccountLedgerDto { ClosingBalance = 1200m });
|
||||
|
||||
var (service, _) = CreateService(context, ledger);
|
||||
await service.RecalculateAllAsync(5);
|
||||
|
||||
var accounts = await context.Accounts.IgnoreQueryFilters()
|
||||
.Where(a => a.CompanyId == 5).ToListAsync();
|
||||
Assert.Equal(500m, accounts.Single(a => a.Id == 1).CurrentBalance);
|
||||
Assert.Equal(1200m, accounts.Single(a => a.Id == 2).CurrentBalance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecalculateAllAsync_WhenLedgerReturnsNull_SkipsAccountBalance()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedAccount(context, 10, AccountSubType.Checking, 999m, companyId: 6);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ledger = new Mock<ILedgerService>();
|
||||
ledger.Setup(l => l.GetAccountLedgerAsync(10, It.IsAny<DateTime>(), It.IsAny<DateTime>()))
|
||||
.ReturnsAsync((AccountLedgerDto?)null);
|
||||
|
||||
var (service, _) = CreateService(context, ledger);
|
||||
await service.RecalculateAllAsync(6);
|
||||
|
||||
var account = await context.Accounts.IgnoreQueryFilters().SingleAsync(a => a.Id == 10);
|
||||
Assert.Equal(999m, account.CurrentBalance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecalculateAllAsync_WhenOneAccountThrows_StillUpdatesRemainingAccounts()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedAccount(context, 20, AccountSubType.Checking, 0m, companyId: 7);
|
||||
SeedAccount(context, 21, AccountSubType.Sales, 0m, companyId: 7);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ledger = new Mock<ILedgerService>();
|
||||
ledger.Setup(l => l.GetAccountLedgerAsync(20, It.IsAny<DateTime>(), It.IsAny<DateTime>()))
|
||||
.ThrowsAsync(new InvalidOperationException("simulated ledger error"));
|
||||
ledger.Setup(l => l.GetAccountLedgerAsync(21, It.IsAny<DateTime>(), It.IsAny<DateTime>()))
|
||||
.ReturnsAsync(new AccountLedgerDto { ClosingBalance = 750m });
|
||||
|
||||
var (service, _) = CreateService(context, ledger);
|
||||
await service.RecalculateAllAsync(7);
|
||||
|
||||
var account21 = await context.Accounts.IgnoreQueryFilters().SingleAsync(a => a.Id == 21);
|
||||
Assert.Equal(750m, account21.CurrentBalance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecalculateAllAsync_ExcludesInactiveAccounts()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedAccount(context, 30, AccountSubType.Checking, 0m, companyId: 8, isActive: false);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ledger = new Mock<ILedgerService>();
|
||||
var (service, _) = CreateService(context, ledger);
|
||||
|
||||
await service.RecalculateAllAsync(8);
|
||||
|
||||
ledger.Verify(
|
||||
l => l.GetAccountLedgerAsync(It.IsAny<int>(), It.IsAny<DateTime>(), It.IsAny<DateTime>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private static (AccountBalanceService service, UnitOfWork unitOfWork) CreateService(
|
||||
ApplicationDbContext context,
|
||||
Mock<ILedgerService>? ledger = null)
|
||||
{
|
||||
var unitOfWork = new UnitOfWork(context);
|
||||
var service = new AccountBalanceService(
|
||||
unitOfWork,
|
||||
(ledger ?? new Mock<ILedgerService>()).Object,
|
||||
Mock.Of<ILogger<AccountBalanceService>>());
|
||||
return (service, unitOfWork);
|
||||
}
|
||||
|
||||
private static Account SeedAccount(
|
||||
ApplicationDbContext context,
|
||||
int id,
|
||||
AccountSubType subType,
|
||||
decimal balance,
|
||||
int companyId = 1,
|
||||
bool isActive = true)
|
||||
{
|
||||
var account = new Account
|
||||
{
|
||||
Id = id,
|
||||
CompanyId = companyId,
|
||||
AccountNumber = $"ACCT-{id}",
|
||||
Name = $"Account {id}",
|
||||
AccountSubType = subType,
|
||||
CurrentBalance = balance,
|
||||
IsActive = isActive
|
||||
};
|
||||
context.Accounts.Add(account);
|
||||
return account;
|
||||
}
|
||||
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
// Use a SuperAdmin user (no CompanyId claim) so that IsPlatformAdmin = true and the
|
||||
// global query filter evaluates to !IsDeleted only — not !IsDeleted && CompanyId == null,
|
||||
// which would filter out every row. DefaultHttpContext.Session throws, so we mock
|
||||
// HttpContext itself and stub Session.TryGetValue to return false (no impersonation).
|
||||
var identity = new ClaimsIdentity(
|
||||
new[] { new Claim(ClaimTypes.Role, "SuperAdmin") }, "Test");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
byte[]? noBytes = null;
|
||||
var sessionMock = new Mock<ISession>();
|
||||
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
|
||||
|
||||
var httpContextMock = new Mock<HttpContext>();
|
||||
httpContextMock.SetupGet(c => c.User).Returns(principal);
|
||||
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
|
||||
|
||||
var accessor = new Mock<IHttpContextAccessor>();
|
||||
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
|
||||
|
||||
return new ApplicationDbContext(options, accessor.Object, null!);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user