Add unit tests for 9 new services/controllers and expand existing test coverage
116 tests passing: JobPhotoService, MeasurementConversionService, PlatformSettingsService, QuoteApprovalController, QuotePhotoService, ShopCapabilityCalculator, StorageMigrationService, TenantContext, UsageQuotaController — plus expanded PricingCalculation, Registration, and Subscription tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
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.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(
|
||||
context,
|
||||
notifications.Object,
|
||||
inApp.Object,
|
||||
Mock.Of<IStripeConnectService>(),
|
||||
Mock.Of<ILogger<QuoteApprovalController>>(),
|
||||
new ConfigurationBuilder().Build(),
|
||||
hubContext.Object);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user