404ab3c45d
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>
356 lines
14 KiB
C#
356 lines
14 KiB
C#
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!);
|
|
}
|
|
}
|