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,355 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Infrastructure.Repositories;
|
||||
using PowderCoating.Web.Controllers;
|
||||
|
||||
namespace PowderCoating.UnitTests;
|
||||
|
||||
public class DepositsControllerTests
|
||||
{
|
||||
// ── Record — input validation (no user resolution needed) ─────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Record_WhenAmountIsZero_ReturnsJsonError()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var controller = CreateController(context, currentUser: CreateUser(1));
|
||||
|
||||
var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1,
|
||||
amount: 0m, paymentMethod: "Cash",
|
||||
receivedDate: DateTime.UtcNow, reference: null, notes: null);
|
||||
|
||||
using var doc = ParseJson(result);
|
||||
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
Assert.Contains("greater than zero", doc.RootElement.GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Record_WhenAmountIsNegative_ReturnsJsonError()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var controller = CreateController(context, currentUser: CreateUser(1));
|
||||
|
||||
var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1,
|
||||
amount: -50m, paymentMethod: "Cash",
|
||||
receivedDate: DateTime.UtcNow, reference: null, notes: null);
|
||||
|
||||
using var doc = ParseJson(result);
|
||||
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Record_WhenNeitherJobNorQuoteProvided_ReturnsJsonError()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var controller = CreateController(context, currentUser: CreateUser(1));
|
||||
|
||||
var result = await controller.Record(jobId: null, quoteId: null, customerId: 1,
|
||||
amount: 100m, paymentMethod: "Cash",
|
||||
receivedDate: DateTime.UtcNow, reference: null, notes: null);
|
||||
|
||||
using var doc = ParseJson(result);
|
||||
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
Assert.Contains("Job or Quote must be specified", doc.RootElement.GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Record_WhenInvalidPaymentMethod_ReturnsJsonError()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var controller = CreateController(context, currentUser: CreateUser(1));
|
||||
|
||||
var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1,
|
||||
amount: 100m, paymentMethod: "BitcoinPizza",
|
||||
receivedDate: DateTime.UtcNow, reference: null, notes: null);
|
||||
|
||||
using var doc = ParseJson(result);
|
||||
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
Assert.Contains("Invalid payment method", doc.RootElement.GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Record_WhenUserNotFound_ReturnsUnauthorized()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var controller = CreateController(context, currentUser: null);
|
||||
|
||||
var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1,
|
||||
amount: 100m, paymentMethod: "Cash",
|
||||
receivedDate: DateTime.UtcNow, reference: null, notes: null);
|
||||
|
||||
Assert.IsType<UnauthorizedResult>(result);
|
||||
}
|
||||
|
||||
// ── Record — success paths ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Record_WithJobId_CreatesDepositAndReturnsSuccessJson()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var controller = CreateController(context, currentUser: CreateUser(companyId: 1));
|
||||
var receivedDate = new DateTime(2026, 4, 15);
|
||||
|
||||
var result = await controller.Record(jobId: 42, quoteId: null, customerId: 7,
|
||||
amount: 250m, paymentMethod: "Check",
|
||||
receivedDate: receivedDate, reference: "REF-001", notes: "Half up front");
|
||||
|
||||
using var doc = ParseJson(result);
|
||||
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
Assert.Equal(250m, doc.RootElement.GetProperty("amount").GetDecimal());
|
||||
Assert.Equal("Check", doc.RootElement.GetProperty("paymentMethod").GetString());
|
||||
Assert.Equal("04/15/2026", doc.RootElement.GetProperty("receivedDate").GetString());
|
||||
Assert.Equal("REF-001", doc.RootElement.GetProperty("reference").GetString());
|
||||
Assert.Equal("Half up front", doc.RootElement.GetProperty("notes").GetString());
|
||||
|
||||
var deposit = await context.Deposits.IgnoreQueryFilters().SingleAsync();
|
||||
Assert.Equal(42, deposit.JobId);
|
||||
Assert.Null(deposit.QuoteId);
|
||||
Assert.Equal(7, deposit.CustomerId);
|
||||
Assert.Equal(250m, deposit.Amount);
|
||||
Assert.Equal(PaymentMethod.Check, deposit.PaymentMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Record_WithQuoteId_CreatesDepositLinkedToQuote()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var controller = CreateController(context, currentUser: CreateUser(companyId: 1));
|
||||
|
||||
var result = await controller.Record(jobId: null, quoteId: 99, customerId: 5,
|
||||
amount: 500m, paymentMethod: "CreditDebitCard",
|
||||
receivedDate: DateTime.UtcNow, reference: null, notes: null);
|
||||
|
||||
using var doc = ParseJson(result);
|
||||
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
Assert.Equal("Credit / Debit Card", doc.RootElement.GetProperty("paymentMethod").GetString());
|
||||
|
||||
var deposit = await context.Deposits.IgnoreQueryFilters().SingleAsync();
|
||||
Assert.Equal(99, deposit.QuoteId);
|
||||
Assert.Null(deposit.JobId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Record_GeneratesReceiptNumberInExpectedFormat()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var controller = CreateController(context, currentUser: CreateUser(companyId: 1));
|
||||
|
||||
var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1,
|
||||
amount: 100m, paymentMethod: "Cash",
|
||||
receivedDate: DateTime.UtcNow, reference: null, notes: null);
|
||||
|
||||
using var doc = ParseJson(result);
|
||||
var receiptNumber = doc.RootElement.GetProperty("receiptNumber").GetString()!;
|
||||
|
||||
// Format: DEP-YYMM-#### (e.g. DEP-2604-0001)
|
||||
Assert.Matches(@"^DEP-\d{4}-\d{4}$", receiptNumber);
|
||||
Assert.EndsWith("-0001", receiptNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Record_SecondDepositInSameMonth_IncrementsSequenceNumber()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var user = CreateUser(companyId: 1);
|
||||
var controller = CreateController(context, currentUser: user);
|
||||
|
||||
// First deposit
|
||||
await controller.Record(jobId: 1, quoteId: null, customerId: 1,
|
||||
amount: 100m, paymentMethod: "Cash",
|
||||
receivedDate: DateTime.UtcNow, reference: null, notes: null);
|
||||
|
||||
// Second deposit
|
||||
var result = await controller.Record(jobId: 2, quoteId: null, customerId: 1,
|
||||
amount: 200m, paymentMethod: "Cash",
|
||||
receivedDate: DateTime.UtcNow, reference: null, notes: null);
|
||||
|
||||
using var doc = ParseJson(result);
|
||||
var receiptNumber = doc.RootElement.GetProperty("receiptNumber").GetString()!;
|
||||
Assert.EndsWith("-0002", receiptNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Record_PaymentMethodDisplayStrings_AreHumanReadable()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var user = CreateUser(companyId: 1);
|
||||
|
||||
var cases = new[]
|
||||
{
|
||||
("Cash", "Cash"),
|
||||
("Check", "Check"),
|
||||
("CreditDebitCard", "Credit / Debit Card"),
|
||||
("BankTransferACH", "Bank Transfer / ACH"),
|
||||
("DigitalPayment", "Digital Payment"),
|
||||
};
|
||||
|
||||
foreach (var (method, expected) in cases)
|
||||
{
|
||||
var controller = CreateController(context, currentUser: user);
|
||||
var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1,
|
||||
amount: 10m, paymentMethod: method,
|
||||
receivedDate: DateTime.UtcNow, reference: null, notes: null);
|
||||
|
||||
using var doc = ParseJson(result);
|
||||
if (doc.RootElement.GetProperty("success").GetBoolean())
|
||||
Assert.Equal(expected, doc.RootElement.GetProperty("paymentMethod").GetString());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_WhenDepositNotFound_ReturnsJsonError()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var controller = CreateController(context, currentUser: CreateUser(1));
|
||||
|
||||
var result = await controller.Delete(id: 999, returnUrl: null);
|
||||
|
||||
using var doc = ParseJson(result);
|
||||
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
Assert.Contains("not found", doc.RootElement.GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_WhenDepositAlreadyAppliedToInvoice_ReturnsJsonError()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.Deposits.Add(new Deposit
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 1,
|
||||
ReceiptNumber = "DEP-2604-0001",
|
||||
CustomerId = 1,
|
||||
JobId = 1,
|
||||
Amount = 100m,
|
||||
PaymentMethod = PaymentMethod.Cash,
|
||||
ReceivedDate = DateTime.UtcNow,
|
||||
AppliedToInvoiceId = 55 // already applied
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var controller = CreateController(context, currentUser: CreateUser(1));
|
||||
|
||||
var result = await controller.Delete(id: 1, returnUrl: null);
|
||||
|
||||
using var doc = ParseJson(result);
|
||||
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
Assert.Contains("already been applied", doc.RootElement.GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_WhenUnappliedDeposit_SoftDeletesAndReturnsSuccess()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.Deposits.Add(new Deposit
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 1,
|
||||
ReceiptNumber = "DEP-2604-0001",
|
||||
CustomerId = 1,
|
||||
JobId = 1,
|
||||
Amount = 75m,
|
||||
PaymentMethod = PaymentMethod.Cash,
|
||||
ReceivedDate = DateTime.UtcNow,
|
||||
AppliedToInvoiceId = null // not yet applied
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var controller = CreateController(context, currentUser: CreateUser(1));
|
||||
|
||||
var result = await controller.Delete(id: 2, returnUrl: null);
|
||||
|
||||
using var doc = ParseJson(result);
|
||||
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
|
||||
var deposit = await context.Deposits.IgnoreQueryFilters().SingleAsync(d => d.Id == 2);
|
||||
Assert.True(deposit.IsDeleted);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private static DepositsController CreateController(
|
||||
ApplicationDbContext context,
|
||||
ApplicationUser? currentUser)
|
||||
{
|
||||
var uow = new UnitOfWork(context);
|
||||
var userManager = CreateUserManagerMock();
|
||||
userManager
|
||||
.Setup(m => m.GetUserAsync(It.IsAny<ClaimsPrincipal>()))
|
||||
.ReturnsAsync(currentUser);
|
||||
|
||||
var controller = new DepositsController(
|
||||
uow,
|
||||
userManager.Object,
|
||||
Mock.Of<ILogger<DepositsController>>(),
|
||||
context);
|
||||
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext()
|
||||
};
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static ApplicationUser CreateUser(int companyId) => new()
|
||||
{
|
||||
Id = "test-user",
|
||||
CompanyId = companyId,
|
||||
UserName = "test@example.com",
|
||||
Email = "test@example.com",
|
||||
FirstName = "Test",
|
||||
LastName = "User"
|
||||
};
|
||||
|
||||
private static Mock<UserManager<ApplicationUser>> CreateUserManagerMock()
|
||||
{
|
||||
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||
return new Mock<UserManager<ApplicationUser>>(
|
||||
store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
|
||||
}
|
||||
|
||||
private static JsonDocument ParseJson(IActionResult result)
|
||||
{
|
||||
var json = Assert.IsType<JsonResult>(result);
|
||||
var serialized = JsonSerializer.Serialize(json.Value);
|
||||
return JsonDocument.Parse(serialized);
|
||||
}
|
||||
|
||||
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