972123c7a2
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>
452 lines
17 KiB
C#
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);
|
|
}
|
|
}
|