dbe4170986
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>
512 lines
20 KiB
C#
512 lines
20 KiB
C#
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Mvc.Routing;
|
|
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using Moq;
|
|
using PowderCoating.Application.DTOs.Registration;
|
|
using PowderCoating.Application.Interfaces;
|
|
using PowderCoating.Core.Entities;
|
|
using PowderCoating.Shared.Constants;
|
|
using PowderCoating.Infrastructure.Data;
|
|
using PowderCoating.Infrastructure.Repositories;
|
|
using System.Text.Json;
|
|
using PowderCoating.Web.Controllers;
|
|
using Xunit;
|
|
|
|
namespace PowderCoating.UnitTests;
|
|
|
|
public class RegistrationControllerTests
|
|
{
|
|
[Fact]
|
|
public async Task PaymentSuccess_WhenStripeSessionIsNotPaid_DoesNotBurnPendingSession()
|
|
{
|
|
await using var context = CreateContext();
|
|
context.PendingRegistrationSessions.Add(CreatePendingSession("token-1", "owner@example.com"));
|
|
await context.SaveChangesAsync();
|
|
|
|
var stripeService = new Mock<IStripeService>();
|
|
stripeService.Setup(x => x.IsRegistrationCheckoutPaidAsync("sess_unpaid")).ReturnsAsync(false);
|
|
|
|
var controller = CreateController(context, stripeService: stripeService);
|
|
|
|
var result = await controller.PaymentSuccess("sess_unpaid", "token-1");
|
|
|
|
var redirect = Assert.IsType<Microsoft.AspNetCore.Mvc.RedirectToActionResult>(result);
|
|
Assert.Equal("Index", redirect.ActionName);
|
|
Assert.False((await context.PendingRegistrationSessions.SingleAsync()).IsCompleted);
|
|
Assert.Contains("couldn't verify a completed payment", controller.TempData["Error"]?.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PaymentSuccess_WhenUserCreationFails_ReleasesPendingSessionAndDeletesCompany()
|
|
{
|
|
await using var context = CreateContext();
|
|
context.PendingRegistrationSessions.Add(CreatePendingSession("token-2", "owner2@example.com"));
|
|
await context.SaveChangesAsync();
|
|
|
|
var userManager = CreateUserManagerMock();
|
|
userManager.Setup(x => x.FindByEmailAsync("owner2@example.com")).ReturnsAsync((ApplicationUser?)null);
|
|
userManager.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>()))
|
|
.ReturnsAsync(IdentityResult.Failed(new IdentityError { Description = "boom" }));
|
|
|
|
var stripeService = new Mock<IStripeService>();
|
|
stripeService.Setup(x => x.IsRegistrationCheckoutPaidAsync("sess_paid")).ReturnsAsync(true);
|
|
|
|
var controller = CreateController(context, userManager, stripeService: stripeService);
|
|
|
|
var result = await controller.PaymentSuccess("sess_paid", "token-2");
|
|
|
|
var redirect = Assert.IsType<Microsoft.AspNetCore.Mvc.RedirectToActionResult>(result);
|
|
Assert.Equal("Index", redirect.ActionName);
|
|
Assert.False((await context.PendingRegistrationSessions.SingleAsync()).IsCompleted);
|
|
Assert.Empty(context.Companies);
|
|
Assert.Contains("Please try the success link again", controller.TempData["Error"]?.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PaymentSuccess_WhenSessionAlreadyCompletedAndUserExists_SignsUserInAndRedirectsToWelcome()
|
|
{
|
|
await using var context = CreateContext();
|
|
context.PendingRegistrationSessions.Add(CreatePendingSession("token-3", "owner3@example.com", isCompleted: true));
|
|
await context.SaveChangesAsync();
|
|
|
|
var existingUser = new ApplicationUser
|
|
{
|
|
Id = "user-3",
|
|
Email = "owner3@example.com",
|
|
UserName = "owner3@example.com",
|
|
FirstName = "Terry",
|
|
LastName = "Tenant",
|
|
CompanyId = 3
|
|
};
|
|
|
|
var userManager = CreateUserManagerMock();
|
|
userManager.Setup(x => x.FindByEmailAsync("owner3@example.com")).ReturnsAsync(existingUser);
|
|
userManager.Setup(x => x.UpdateAsync(existingUser)).ReturnsAsync(IdentityResult.Success);
|
|
|
|
var signInManager = CreateSignInManagerMock(userManager.Object);
|
|
signInManager.Setup(x => x.SignInAsync(existingUser, false, null)).Returns(Task.CompletedTask).Verifiable();
|
|
|
|
var controller = CreateController(context, userManager, signInManager.Object);
|
|
|
|
var result = await controller.PaymentSuccess("sess_complete", "token-3");
|
|
|
|
var redirect = Assert.IsType<Microsoft.AspNetCore.Mvc.RedirectToActionResult>(result);
|
|
Assert.Equal("Welcome", redirect.ActionName);
|
|
signInManager.Verify(x => x.SignInAsync(existingUser, false, null), Times.Once);
|
|
Assert.True((await context.PendingRegistrationSessions.SingleAsync()).IsCompleted);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_WhenEmailAlreadyExists_ReturnsIndexWithModelError()
|
|
{
|
|
await using var context = CreateContext();
|
|
SeedPlanConfig(context, plan: 1);
|
|
await context.SaveChangesAsync();
|
|
|
|
var existingUser = new ApplicationUser
|
|
{
|
|
Id = "existing",
|
|
Email = "owner@example.com",
|
|
UserName = "owner@example.com",
|
|
FirstName = "Existing",
|
|
LastName = "User"
|
|
};
|
|
|
|
var userManager = CreateUserManagerMock();
|
|
userManager.Setup(x => x.FindByEmailAsync("owner@example.com")).ReturnsAsync(existingUser);
|
|
|
|
var controller = CreateController(context, userManager: userManager);
|
|
|
|
var model = new RegisterCompanyDto
|
|
{
|
|
CompanyName = "Dup Co",
|
|
CompanyPhone = "555-0100",
|
|
FirstName = "Pat",
|
|
LastName = "Owner",
|
|
Email = "owner@example.com",
|
|
Plan = 1
|
|
};
|
|
|
|
var result = await controller.Create(model);
|
|
|
|
var view = Assert.IsType<ViewResult>(result);
|
|
Assert.Equal("Index", view.ViewName);
|
|
Assert.False(controller.ModelState.IsValid);
|
|
Assert.Contains(controller.ModelState["Email"]!.Errors, e => e.ErrorMessage.Contains("already exists"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_WhenRegistrationIsClosed_ReturnsIndexWithTempDataError()
|
|
{
|
|
await using var context = CreateContext();
|
|
SeedPlanConfig(context, plan: 1);
|
|
context.Companies.Add(new Company
|
|
{
|
|
Id = 1,
|
|
CompanyId = 1,
|
|
CompanyName = "Existing Company",
|
|
CompanyCode = "EXC",
|
|
PrimaryContactName = "Owner",
|
|
PrimaryContactEmail = "existing@example.com",
|
|
IsActive = true
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var platformSettings = new Mock<IPlatformSettingsService>();
|
|
platformSettings
|
|
.Setup(x => x.GetAsync(PlatformSettingKeys.MaxTenants))
|
|
.ReturnsAsync("1");
|
|
platformSettings
|
|
.Setup(x => x.GetAsync(It.Is<string>(key => key != PlatformSettingKeys.MaxTenants)))
|
|
.ReturnsAsync((string?)null);
|
|
|
|
var controller = CreateController(context, platformSettings: platformSettings);
|
|
|
|
var model = new RegisterCompanyDto
|
|
{
|
|
CompanyName = "New Co",
|
|
CompanyPhone = "555-0100",
|
|
FirstName = "Pat",
|
|
LastName = "Owner",
|
|
Email = "owner@example.com",
|
|
Plan = 1
|
|
};
|
|
|
|
var result = await controller.Create(model);
|
|
|
|
var view = Assert.IsType<ViewResult>(result);
|
|
Assert.Equal("Index", view.ViewName);
|
|
Assert.Equal("Registration is currently closed. Please contact us for more information.", controller.TempData["Error"]);
|
|
Assert.False((bool)controller.ViewBag.RegistrationOpen);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_WhenTrialsDisabledAndStripeCheckoutStarts_RedirectsAndPersistsPendingSession()
|
|
{
|
|
await using var context = CreateContext();
|
|
SeedPlanConfig(context, plan: 1);
|
|
await context.SaveChangesAsync();
|
|
|
|
var platformSettings = new Mock<IPlatformSettingsService>();
|
|
platformSettings.Setup(x => x.GetAsync(PlatformSettingKeys.TrialsEnabled)).ReturnsAsync("false");
|
|
platformSettings.Setup(x => x.GetAsync(It.Is<string>(key => key != PlatformSettingKeys.TrialsEnabled))).ReturnsAsync((string?)null);
|
|
|
|
var stripeService = new Mock<IStripeService>();
|
|
stripeService
|
|
.Setup(x => x.CreateRegistrationCheckoutSessionAsync(
|
|
1, false, "paid@example.com", "Paid Co", It.IsAny<string>(), It.IsAny<string>()))
|
|
.ReturnsAsync("https://checkout.example/session");
|
|
|
|
var controller = CreateController(
|
|
context,
|
|
stripeService: stripeService,
|
|
platformSettings: platformSettings);
|
|
|
|
var model = new RegisterCompanyDto
|
|
{
|
|
CompanyName = "Paid Co",
|
|
CompanyPhone = "555-0100",
|
|
FirstName = "Pat",
|
|
LastName = "Owner",
|
|
Email = "paid@example.com",
|
|
Plan = 1
|
|
};
|
|
|
|
var result = await controller.Create(model);
|
|
|
|
var redirect = Assert.IsType<RedirectResult>(result);
|
|
Assert.Equal("https://checkout.example/session", redirect.Url);
|
|
|
|
var pending = await context.PendingRegistrationSessions.SingleAsync();
|
|
Assert.Equal("Paid Co", pending.CompanyName);
|
|
Assert.Equal("paid@example.com", pending.Email);
|
|
Assert.False(pending.IsCompleted);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_WhenStripeConfigFails_DoesNotPersistPendingSession()
|
|
{
|
|
await using var context = CreateContext();
|
|
SeedPlanConfig(context, plan: 1);
|
|
await context.SaveChangesAsync();
|
|
|
|
var platformSettings = new Mock<IPlatformSettingsService>();
|
|
platformSettings.Setup(x => x.GetAsync(PlatformSettingKeys.TrialsEnabled)).ReturnsAsync("false");
|
|
platformSettings.Setup(x => x.GetAsync(It.Is<string>(key => key != PlatformSettingKeys.TrialsEnabled))).ReturnsAsync((string?)null);
|
|
|
|
var stripeService = new Mock<IStripeService>();
|
|
stripeService
|
|
.Setup(x => x.CreateRegistrationCheckoutSessionAsync(
|
|
It.IsAny<int>(), It.IsAny<bool>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
|
.ThrowsAsync(new InvalidOperationException("Stripe prices are not configured."));
|
|
|
|
var controller = CreateController(
|
|
context,
|
|
stripeService: stripeService,
|
|
platformSettings: platformSettings);
|
|
|
|
var model = new RegisterCompanyDto
|
|
{
|
|
CompanyName = "Paid Co",
|
|
CompanyPhone = "555-0100",
|
|
FirstName = "Pat",
|
|
LastName = "Owner",
|
|
Email = "paid@example.com",
|
|
Plan = 1
|
|
};
|
|
|
|
var result = await controller.Create(model);
|
|
|
|
var view = Assert.IsType<ViewResult>(result);
|
|
Assert.Equal("Index", view.ViewName);
|
|
Assert.Empty(context.PendingRegistrationSessions);
|
|
Assert.Contains(controller.ModelState[string.Empty]!.Errors, e => e.ErrorMessage.Contains("Stripe prices are not configured."));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PaymentSuccess_WhenRegistrationClosedAfterPayment_ReleasesPendingSessionWithoutCreatingCompany()
|
|
{
|
|
await using var context = CreateContext();
|
|
SeedPlanConfig(context, plan: 1);
|
|
context.Companies.Add(new Company
|
|
{
|
|
Id = 1,
|
|
CompanyId = 1,
|
|
CompanyName = "At Capacity",
|
|
CompanyCode = "CAP",
|
|
PrimaryContactName = "Owner",
|
|
PrimaryContactEmail = "capacity@example.com",
|
|
IsActive = true
|
|
});
|
|
context.PendingRegistrationSessions.Add(CreatePendingSession("token-closed", "closed@example.com"));
|
|
await context.SaveChangesAsync();
|
|
|
|
var platformSettings = new Mock<IPlatformSettingsService>();
|
|
platformSettings
|
|
.Setup(x => x.GetAsync(PlatformSettingKeys.MaxTenants))
|
|
.ReturnsAsync("1");
|
|
platformSettings
|
|
.Setup(x => x.GetAsync(It.Is<string>(key => key != PlatformSettingKeys.MaxTenants)))
|
|
.ReturnsAsync((string?)null);
|
|
|
|
var userManager = CreateUserManagerMock();
|
|
userManager.Setup(x => x.FindByEmailAsync("closed@example.com")).ReturnsAsync((ApplicationUser?)null);
|
|
|
|
var stripeService = new Mock<IStripeService>();
|
|
stripeService.Setup(x => x.IsRegistrationCheckoutPaidAsync("sess_paid")).ReturnsAsync(true);
|
|
|
|
var controller = CreateController(
|
|
context,
|
|
userManager: userManager,
|
|
stripeService: stripeService,
|
|
platformSettings: platformSettings);
|
|
|
|
var result = await controller.PaymentSuccess("sess_paid", "token-closed");
|
|
|
|
var redirect = Assert.IsType<RedirectToActionResult>(result);
|
|
Assert.Equal("Index", redirect.ActionName);
|
|
Assert.Equal("Registration is currently closed. Your payment has been received but no account was created. Please contact support.", controller.TempData["Error"]);
|
|
Assert.False((await context.PendingRegistrationSessions.SingleAsync()).IsCompleted);
|
|
Assert.Single(context.Companies);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PaymentSuccess_WhenSessionIdMissing_RedirectsToIndex()
|
|
{
|
|
await using var context = CreateContext();
|
|
var controller = CreateController(context);
|
|
|
|
var result = await controller.PaymentSuccess(null, "token");
|
|
|
|
var redirect = Assert.IsType<RedirectToActionResult>(result);
|
|
Assert.Equal("Index", redirect.ActionName);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PaymentSuccess_WhenRegistrationTokenMissing_SetsExpiredError()
|
|
{
|
|
await using var context = CreateContext();
|
|
var controller = CreateController(context);
|
|
|
|
var result = await controller.PaymentSuccess("sess_123", null);
|
|
|
|
var redirect = Assert.IsType<RedirectToActionResult>(result);
|
|
Assert.Equal("Index", redirect.ActionName);
|
|
Assert.Equal("Your registration session has expired. Please fill in your details again.", controller.TempData["Error"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PaymentSuccess_WhenPendingSessionMissing_SetsNotFoundError()
|
|
{
|
|
await using var context = CreateContext();
|
|
var controller = CreateController(context);
|
|
|
|
var result = await controller.PaymentSuccess("sess_123", "missing-token");
|
|
|
|
var redirect = Assert.IsType<RedirectToActionResult>(result);
|
|
Assert.Equal("Index", redirect.ActionName);
|
|
Assert.Equal("Your registration session was not found. Please fill in your details again.", controller.TempData["Error"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PaymentSuccess_WhenSessionAlreadyCompletedButUserMissing_ShowsSupportError()
|
|
{
|
|
await using var context = CreateContext();
|
|
context.PendingRegistrationSessions.Add(CreatePendingSession("token-missing-user", "retry@example.com", isCompleted: true));
|
|
await context.SaveChangesAsync();
|
|
|
|
var userManager = CreateUserManagerMock();
|
|
userManager.Setup(x => x.FindByEmailAsync("retry@example.com")).ReturnsAsync((ApplicationUser?)null);
|
|
|
|
var controller = CreateController(context, userManager: userManager);
|
|
|
|
var result = await controller.PaymentSuccess("sess_done", "token-missing-user");
|
|
|
|
var redirect = Assert.IsType<RedirectToActionResult>(result);
|
|
Assert.Equal("Index", redirect.ActionName);
|
|
Assert.Contains("couldn't finish signing you in", controller.TempData["Error"]?.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PaymentCancelled_WhenPendingSessionExists_PrefillsTempDataAndDeletesSession()
|
|
{
|
|
await using var context = CreateContext();
|
|
context.PendingRegistrationSessions.Add(CreatePendingSession("token-cancel", "cancel@example.com"));
|
|
await context.SaveChangesAsync();
|
|
|
|
var controller = CreateController(context);
|
|
|
|
var result = await controller.PaymentCancelled("token-cancel");
|
|
|
|
var redirect = Assert.IsType<RedirectToActionResult>(result);
|
|
Assert.Equal("Index", redirect.ActionName);
|
|
|
|
var json = Assert.IsType<string>(controller.TempData["PendingRegistrationJson"]);
|
|
var model = JsonSerializer.Deserialize<RegisterCompanyDto>(json);
|
|
Assert.NotNull(model);
|
|
Assert.Equal("Retry Co", model!.CompanyName);
|
|
Assert.Equal("cancel@example.com", model.Email);
|
|
Assert.Empty(context.PendingRegistrationSessions);
|
|
}
|
|
|
|
private static RegistrationController CreateController(
|
|
ApplicationDbContext context,
|
|
Mock<UserManager<ApplicationUser>>? userManager = null,
|
|
SignInManager<ApplicationUser>? signInManager = null,
|
|
Mock<IStripeService>? stripeService = null,
|
|
Mock<IPlatformSettingsService>? platformSettings = null)
|
|
{
|
|
var unitOfWork = new UnitOfWork(context);
|
|
var userManagerMock = userManager ?? CreateUserManagerMock();
|
|
var signInManagerInstance = signInManager ?? CreateSignInManagerMock(userManagerMock.Object).Object;
|
|
|
|
var platformSettingsMock = platformSettings ?? new Mock<IPlatformSettingsService>();
|
|
if (platformSettings is null)
|
|
{
|
|
platformSettingsMock.Setup(x => x.GetAsync(It.IsAny<string>())).ReturnsAsync((string?)null);
|
|
}
|
|
|
|
var controller = new RegistrationController(
|
|
unitOfWork,
|
|
context,
|
|
userManagerMock.Object,
|
|
signInManagerInstance,
|
|
Mock.Of<ISeedDataService>(),
|
|
Mock.Of<IAdminNotificationService>(),
|
|
Mock.Of<IInAppNotificationService>(),
|
|
platformSettingsMock.Object,
|
|
(stripeService ?? new Mock<IStripeService>()).Object,
|
|
Mock.Of<IEmailService>(),
|
|
Mock.Of<ILogger<RegistrationController>>());
|
|
|
|
var httpContext = new DefaultHttpContext();
|
|
controller.ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext
|
|
{
|
|
HttpContext = httpContext
|
|
};
|
|
var urlHelper = new Mock<IUrlHelper>();
|
|
urlHelper
|
|
.Setup(x => x.Action(It.IsAny<UrlActionContext>()))
|
|
.Returns<UrlActionContext>(ctx => $"https://example.test/{ctx.Action}");
|
|
controller.Url = urlHelper.Object;
|
|
controller.TempData = new TempDataDictionary(httpContext, Mock.Of<ITempDataProvider>());
|
|
|
|
return controller;
|
|
}
|
|
|
|
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 Mock<SignInManager<ApplicationUser>> CreateSignInManagerMock(UserManager<ApplicationUser> userManager)
|
|
{
|
|
var contextAccessor = new Mock<IHttpContextAccessor>();
|
|
contextAccessor.Setup(x => x.HttpContext).Returns(new DefaultHttpContext());
|
|
|
|
var claimsFactory = new Mock<IUserClaimsPrincipalFactory<ApplicationUser>>();
|
|
|
|
return new Mock<SignInManager<ApplicationUser>>(
|
|
userManager,
|
|
contextAccessor.Object,
|
|
claimsFactory.Object,
|
|
null!,
|
|
null!,
|
|
null!,
|
|
null!);
|
|
}
|
|
|
|
private static ApplicationDbContext CreateContext()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
|
|
return new ApplicationDbContext(options);
|
|
}
|
|
|
|
private static void SeedPlanConfig(ApplicationDbContext context, int plan)
|
|
{
|
|
context.SubscriptionPlanConfigs.Add(new SubscriptionPlanConfig
|
|
{
|
|
Id = plan,
|
|
CompanyId = 0,
|
|
Plan = plan,
|
|
DisplayName = $"Plan {plan}",
|
|
SortOrder = plan,
|
|
IsActive = true
|
|
});
|
|
}
|
|
|
|
private static PendingRegistrationSession CreatePendingSession(string token, string email, bool isCompleted = false)
|
|
{
|
|
return new PendingRegistrationSession
|
|
{
|
|
Token = token,
|
|
CompanyName = "Retry Co",
|
|
CompanyPhone = "555-0100",
|
|
FirstName = "Pat",
|
|
LastName = "Owner",
|
|
Email = email,
|
|
Plan = 1,
|
|
IsAnnual = false,
|
|
IsCompleted = isCompleted,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
}
|
|
}
|