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(result); Assert.Equal("TokenExpired", view.ViewName); var model = Assert.IsType(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(result); Assert.Equal("AlreadyActed", view.ViewName); var model = Assert.IsType(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(result); Assert.Equal("ConfirmDetails", view.ViewName); var model = Assert.IsType(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(result); Assert.Equal("ConfirmDetails", view.ViewName); var model = Assert.IsType(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(); notifications .Setup(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny(), true, null)) .Returns(Task.CompletedTask); var inApp = new Mock(); inApp.Setup(x => x.CreateAsync(1, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var clientProxy = new Mock(); clientProxy .Setup(x => x.SendCoreAsync(It.IsAny(), It.IsAny(), It.IsAny())) .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(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(), true, null), Times.Once); inApp.Verify(x => x.CreateAsync(1, "Quote Approved", It.IsAny(), "QuoteApproved", "/Quotes/Details/1", 1, null, null), Times.Once); clientProxy.Verify(x => x.SendCoreAsync("QuoteActedByCustomer", It.IsAny(), It.IsAny()), 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(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(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(result); Assert.Equal("ApprovalPage", view.ViewName); var model = Assert.IsType(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(); notifications .Setup(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny(), false, It.IsAny())) .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(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(), false, It.IsAny()), 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(result); Assert.Equal("Confirmation", view.ViewName); var model = Assert.IsType(view.Model); Assert.Null(model.DepositPaymentLinkToken); Assert.Equal(30m, model.DepositAmount); Assert.Equal("approved", controller.ViewBag.Action); } private static QuoteApprovalController CreateController( ApplicationDbContext context, Mock? notifications = null, Mock? inApp = null, Mock? clientProxy = null, IPAddress? remoteIpAddress = null) { notifications ??= new Mock(); notifications.Setup(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); inApp ??= new Mock(); inApp.Setup(x => x.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); clientProxy ??= new Mock(); clientProxy.Setup(x => x.SendCoreAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var hubClients = new Mock(); hubClients.Setup(x => x.Group(It.IsAny())).Returns(clientProxy.Object); var hubContext = new Mock>(); hubContext.SetupGet(x => x.Clients).Returns(hubClients.Object); var controller = new QuoteApprovalController( new UnitOfWork(context), notifications.Object, inApp.Object, Mock.Of(), Mock.Of>(), 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() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new ApplicationDbContext(options); } }