Files
PowderCoatingLogix/tests/PowderCoating.UnitTests/QuoteApprovalControllerTests.cs
T
spouliot 972123c7a2 Fix incoming powder inventory: defer creation to approval, deduplicate, fix category
Three bugs fixed:

1. Wrong timing — inventory items with IsIncoming=true were auto-created during
   quote save (in QuotePricingAssemblyService). Now deferred to quote approval so
   inventory only reflects powders the shop is actually going to process.

2. Duplicate records — same powder on multiple items in one quote created multiple
   inventory records. Now grouped by PowderCatalogItemId: one record per unique
   catalog powder, all matching coats linked to the same record.

3. Wrong category — category resolution used first IsCoating=true by DisplayOrder,
   which could land items in Cerakote or other unintended categories. Now prefers
   CategoryCode==POWDER explicitly, with DisplayOrder fallback.

Changes:
- QuoteItemCoat: add PowderCatalogItemId int? — persists catalog reference at quote
  save time so the approval path knows what to create
- QuotePricingAssemblyService.BuildQuoteItemCoatsAsync: store PowderCatalogItemId
  on coat instead of calling CreateIncomingInventoryItemAsync immediately
- QuotePricingAssemblyService.CreateIncomingInventoryItemAsync: signature changed
  from (coatDto, companyId) to (catalogItemId, companyId); category lookup prefers
  POWDER code; no longer clears PowderCostPerLb on the DTO
- QuotePricingAssemblyService.EnsureIncomingInventoryForApprovedQuoteAsync: new
  public method called at approval — loads pending coats, groups by catalog ID,
  creates one inventory item per group, links all coats in each group
- IQuotePricingAssemblyService: exposes EnsureIncomingInventoryForApprovedQuoteAsync
- QuotesController.ApproveQuote: calls EnsureIncomingInventory after save
- QuotesController.ChangeQuoteStatus: calls EnsureIncomingInventory on Approved
- QuoteApprovalController: injects IQuotePricingAssemblyService; calls
  EnsureIncomingInventory in ApproveInternal (customer-facing portal path)
- InventoryController.CreateIncomingFromCatalog: same category fix (prefers POWDER)
- Migration: AddPowderCatalogItemIdToCoat (nullable int on QuoteItemCoats)
- Tests: updated AddAsIncoming test to verify deferred behavior; new deduplication test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 10:12:24 -04:00

452 lines
17 KiB
C#

using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using PowderCoating.Web.Controllers;
using PowderCoating.Web.Hubs;
using PowderCoating.Web.ViewModels;
namespace PowderCoating.UnitTests;
public class QuoteApprovalControllerTests
{
[Fact]
public async Task ShowApprovalPage_WhenTokenExpired_ReturnsTokenExpiredView()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: 10, token: "expired-token", expiresAt: DateTime.UtcNow.AddMinutes(-1)));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.ShowApprovalPage("expired-token");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("TokenExpired", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.Equal("expired-token", model.Token);
}
[Fact]
public async Task ShowApprovalPage_WhenQuoteAlreadyInTerminalStatus_ReturnsAlreadyActedView()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: 10, token: "approved-token", statusId: 2, declineReason: "Old decline"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.ShowApprovalPage("approved-token");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("AlreadyActed", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.Equal("Approved", model.CurrentStatus);
Assert.Equal("Old decline", model.DeclineReason);
}
[Fact]
public async Task Approve_WhenQuoteIsProspect_ReturnsConfirmDetailsView()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: null, token: "prospect-token"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.Approve("prospect-token");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("ConfirmDetails", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.True(model.IsProspect);
}
[Fact]
public async Task SubmitDetails_WhenRequiredFieldsMissing_ReturnsConfirmDetailsWithError()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: null, token: "missing-details"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.SubmitDetails(
"missing-details",
contactName: " ",
email: " prospect@example.com ",
phone: null,
companyName: " Prospect Co ",
address: " 123 Main ",
city: " Akron ",
state: " OH ",
zipCode: " 44301 ");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("ConfirmDetails", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.Equal("Please enter your name and at least one contact method (email or phone).", model.DeclineError);
Assert.Equal(" prospect@example.com ", model.ProspectEmail);
Assert.Equal(" Prospect Co ", model.ProspectCompanyName);
}
[Fact]
public async Task SubmitDetails_WhenValidProspect_ApprovesQuoteAndTrimsFields()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: null, token: "prospect-approve"));
await context.SaveChangesAsync();
var notifications = new Mock<INotificationService>();
notifications
.Setup(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny<Quote>(), true, null))
.Returns(Task.CompletedTask);
var inApp = new Mock<IInAppNotificationService>();
inApp.Setup(x => x.CreateAsync(1, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<int?>()))
.Returns(Task.CompletedTask);
var clientProxy = new Mock<IClientProxy>();
clientProxy
.Setup(x => x.SendCoreAsync(It.IsAny<string>(), It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var controller = CreateController(
context,
notifications: notifications,
inApp: inApp,
clientProxy: clientProxy);
var result = await controller.SubmitDetails(
"prospect-approve",
contactName: " Pat Prospect ",
email: " prospect@example.com ",
phone: " 555-0100 ",
companyName: " Prospect Co ",
address: " 123 Main ",
city: " Akron ",
state: " OH ",
zipCode: " 44301 ");
var redirect = Assert.IsType<RedirectResult>(result);
Assert.Equal("/quote-approval/prospect-approve/confirmation?action=approved", redirect.Url);
var quote = await context.Quotes.IgnoreQueryFilters().SingleAsync();
Assert.Equal(2, quote.QuoteStatusId);
Assert.Equal("Pat Prospect", quote.ProspectContactName);
Assert.Equal("prospect@example.com", quote.ProspectEmail);
Assert.Equal("555-0100", quote.ProspectPhone);
Assert.Equal("Prospect Co", quote.ProspectCompanyName);
Assert.NotNull(quote.ApprovalTokenUsedAt);
Assert.Single(await context.QuoteChangeHistories.IgnoreQueryFilters().ToListAsync());
notifications.Verify(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny<Quote>(), true, null), Times.Once);
inApp.Verify(x => x.CreateAsync(1, "Quote Approved", It.IsAny<string>(), "QuoteApproved", "/Quotes/Details/1", 1, null, null), Times.Once);
clientProxy.Verify(x => x.SendCoreAsync("QuoteActedByCustomer", It.IsAny<object[]>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Approve_WhenCustomerQuoteRequiresDeposit_GeneratesDepositLinkAndClearsPriorDecline()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1, stripeStatus: StripeConnectStatus.Active);
context.Customers.Add(new Customer
{
Id = 10,
CompanyId = 1,
CompanyName = "Acme Customer"
});
context.Quotes.Add(CreateQuote(
1,
customerId: 10,
token: "deposit-token",
requiresDeposit: true,
depositPercent: 50m,
declineReason: "Need more time"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.Approve("deposit-token");
var redirect = Assert.IsType<RedirectResult>(result);
Assert.Equal("/quote-approval/deposit-token/confirmation?action=approved", redirect.Url);
var quote = await context.Quotes.IgnoreQueryFilters().SingleAsync();
Assert.Equal(2, quote.QuoteStatusId);
Assert.Null(quote.DeclineReason);
Assert.NotNull(quote.DepositPaymentLinkToken);
Assert.True(quote.DepositPaymentLinkExpiresAt > DateTime.UtcNow.AddDays(6));
var history = await context.QuoteChangeHistories.IgnoreQueryFilters().SingleAsync();
Assert.Contains("previously declined", history.ChangeDescription);
}
[Fact]
public async Task Approve_WhenTokenAlreadyUsed_ReturnsAlreadyActedView()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(
1,
customerId: 10,
token: "used-token",
approvalUsedAt: DateTime.UtcNow.AddMinutes(-5)));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.Approve("used-token");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("AlreadyActed", view.ViewName);
}
[Fact]
public async Task Decline_WhenReasonBlank_ReturnsApprovalPageWithError()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: 10, token: "blank-decline"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.Decline("blank-decline", " ");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("ApprovalPage", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.Equal("Please enter a reason for declining.", model.DeclineError);
}
[Fact]
public async Task Decline_UsesRejectedStatusCodeFallbackAndTruncatesStoredReason()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1, useRejectedFlag: false);
context.Quotes.Add(CreateQuote(1, customerId: 10, token: "decline-token"));
await context.SaveChangesAsync();
var notifications = new Mock<INotificationService>();
notifications
.Setup(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny<Quote>(), false, It.IsAny<string>()))
.Returns(Task.CompletedTask);
var reason = $" {new string('x', 1005)} ";
var controller = CreateController(
context,
notifications: notifications,
remoteIpAddress: IPAddress.Parse("203.0.113.9"));
var result = await controller.Decline("decline-token", reason);
var redirect = Assert.IsType<RedirectResult>(result);
Assert.Equal("/quote-approval/decline-token/confirmation?action=declined", redirect.Url);
var quote = await context.Quotes.IgnoreQueryFilters().SingleAsync();
Assert.Equal(3, quote.QuoteStatusId);
Assert.Equal(1000, quote.DeclineReason!.Length);
Assert.Equal("203.0.113.9", quote.DeclinedByIp);
Assert.NotNull(quote.ApprovalTokenUsedAt);
Assert.Single(await context.QuoteChangeHistories.IgnoreQueryFilters().ToListAsync());
notifications.Verify(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny<Quote>(), false, It.IsAny<string>()), Times.Once);
}
[Fact]
public async Task Confirmation_HidesExpiredDepositLink()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(
1,
customerId: 10,
token: "confirm-token",
requiresDeposit: true,
depositPercent: 25m,
depositLinkToken: "expired-link",
depositLinkExpiresAt: DateTime.UtcNow.AddMinutes(-2),
total: 120m));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.Confirmation("confirm-token", "APPROVED");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("Confirmation", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.Null(model.DepositPaymentLinkToken);
Assert.Equal(30m, model.DepositAmount);
Assert.Equal("approved", controller.ViewBag.Action);
}
private static QuoteApprovalController CreateController(
ApplicationDbContext context,
Mock<INotificationService>? notifications = null,
Mock<IInAppNotificationService>? inApp = null,
Mock<IClientProxy>? clientProxy = null,
IPAddress? remoteIpAddress = null)
{
notifications ??= new Mock<INotificationService>();
notifications.Setup(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny<Quote>(), It.IsAny<bool>(), It.IsAny<string?>()))
.Returns(Task.CompletedTask);
inApp ??= new Mock<IInAppNotificationService>();
inApp.Setup(x => x.CreateAsync(It.IsAny<int>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<int?>()))
.Returns(Task.CompletedTask);
clientProxy ??= new Mock<IClientProxy>();
clientProxy.Setup(x => x.SendCoreAsync(It.IsAny<string>(), It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var hubClients = new Mock<IHubClients>();
hubClients.Setup(x => x.Group(It.IsAny<string>())).Returns(clientProxy.Object);
var hubContext = new Mock<IHubContext<NotificationHub>>();
hubContext.SetupGet(x => x.Clients).Returns(hubClients.Object);
var controller = new QuoteApprovalController(
new UnitOfWork(context),
notifications.Object,
inApp.Object,
Mock.Of<IStripeConnectService>(),
Mock.Of<ILogger<QuoteApprovalController>>(),
new ConfigurationBuilder().Build(),
hubContext.Object,
Mock.Of<IQuotePricingAssemblyService>());
var httpContext = new DefaultHttpContext();
if (remoteIpAddress != null)
{
httpContext.Connection.RemoteIpAddress = remoteIpAddress;
}
controller.ControllerContext = new ControllerContext
{
HttpContext = httpContext
};
return controller;
}
private static void SeedCompanyAndStatuses(
ApplicationDbContext context,
int companyId,
StripeConnectStatus stripeStatus = StripeConnectStatus.NotConnected,
bool useRejectedFlag = true)
{
context.Companies.Add(new Company
{
Id = companyId,
CompanyId = companyId,
CompanyName = $"Company {companyId}",
Phone = "555-0100",
PrimaryContactName = "Owner",
PrimaryContactEmail = $"owner{companyId}@example.com",
StripeConnectStatus = stripeStatus
});
context.CompanyPreferences.Add(new CompanyPreferences
{
Id = companyId,
CompanyId = companyId,
EmailFromAddress = $"quotes{companyId}@example.com"
});
context.QuoteStatusLookups.AddRange(
new QuoteStatusLookup
{
Id = 1,
CompanyId = companyId,
StatusCode = "PENDING",
DisplayName = "Pending",
DisplayOrder = 1
},
new QuoteStatusLookup
{
Id = 2,
CompanyId = companyId,
StatusCode = "APPROVED",
DisplayName = "Approved",
DisplayOrder = 2,
IsApprovedStatus = true
},
new QuoteStatusLookup
{
Id = 3,
CompanyId = companyId,
StatusCode = "REJECTED",
DisplayName = "Rejected",
DisplayOrder = 3,
IsRejectedStatus = useRejectedFlag
},
new QuoteStatusLookup
{
Id = 4,
CompanyId = companyId,
StatusCode = "CONVERTED",
DisplayName = "Converted",
DisplayOrder = 4,
IsConvertedStatus = true
});
}
private static Quote CreateQuote(
int id,
int? customerId,
string token,
int statusId = 1,
DateTime? expiresAt = null,
DateTime? approvalUsedAt = null,
bool requiresDeposit = false,
decimal depositPercent = 0m,
string? declineReason = null,
string? depositLinkToken = null,
DateTime? depositLinkExpiresAt = null,
decimal total = 100m)
{
return new Quote
{
Id = id,
CompanyId = 1,
QuoteNumber = $"Q-{id:000}",
CustomerId = customerId,
QuoteStatusId = statusId,
ApprovalToken = token,
ApprovalTokenExpiresAt = expiresAt ?? DateTime.UtcNow.AddDays(2),
ApprovalTokenUsedAt = approvalUsedAt,
RequiresDeposit = requiresDeposit,
DepositPercent = depositPercent,
DeclineReason = declineReason,
DepositPaymentLinkToken = depositLinkToken,
DepositPaymentLinkExpiresAt = depositLinkExpiresAt,
Total = total,
SubTotal = total,
QuoteItems = []
};
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
}