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(); 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(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(), It.IsAny())) .ReturnsAsync(IdentityResult.Failed(new IdentityError { Description = "boom" })); var stripeService = new Mock(); 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(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(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(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(); platformSettings .Setup(x => x.GetAsync(PlatformSettingKeys.MaxTenants)) .ReturnsAsync("1"); platformSettings .Setup(x => x.GetAsync(It.Is(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(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(); platformSettings.Setup(x => x.GetAsync(PlatformSettingKeys.TrialsEnabled)).ReturnsAsync("false"); platformSettings.Setup(x => x.GetAsync(It.Is(key => key != PlatformSettingKeys.TrialsEnabled))).ReturnsAsync((string?)null); var stripeService = new Mock(); stripeService .Setup(x => x.CreateRegistrationCheckoutSessionAsync( 1, false, "paid@example.com", "Paid Co", It.IsAny(), It.IsAny())) .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(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(); platformSettings.Setup(x => x.GetAsync(PlatformSettingKeys.TrialsEnabled)).ReturnsAsync("false"); platformSettings.Setup(x => x.GetAsync(It.Is(key => key != PlatformSettingKeys.TrialsEnabled))).ReturnsAsync((string?)null); var stripeService = new Mock(); stripeService .Setup(x => x.CreateRegistrationCheckoutSessionAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .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(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(); platformSettings .Setup(x => x.GetAsync(PlatformSettingKeys.MaxTenants)) .ReturnsAsync("1"); platformSettings .Setup(x => x.GetAsync(It.Is(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(); 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(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(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(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(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(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(result); Assert.Equal("Index", redirect.ActionName); var json = Assert.IsType(controller.TempData["PendingRegistrationJson"]); var model = JsonSerializer.Deserialize(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 = null, SignInManager? signInManager = null, Mock? stripeService = null, Mock? platformSettings = null) { var unitOfWork = new UnitOfWork(context); var userManagerMock = userManager ?? CreateUserManagerMock(); var signInManagerInstance = signInManager ?? CreateSignInManagerMock(userManagerMock.Object).Object; var platformSettingsMock = platformSettings ?? new Mock(); if (platformSettings is null) { platformSettingsMock.Setup(x => x.GetAsync(It.IsAny())).ReturnsAsync((string?)null); } var controller = new RegistrationController( unitOfWork, context, userManagerMock.Object, signInManagerInstance, Mock.Of(), Mock.Of(), Mock.Of(), platformSettingsMock.Object, (stripeService ?? new Mock()).Object, Mock.Of(), Mock.Of>()); var httpContext = new DefaultHttpContext(); controller.ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext { HttpContext = httpContext }; var urlHelper = new Mock(); urlHelper .Setup(x => x.Action(It.IsAny())) .Returns(ctx => $"https://example.test/{ctx.Action}"); controller.Url = urlHelper.Object; controller.TempData = new TempDataDictionary(httpContext, Mock.Of()); return controller; } private static Mock> CreateUserManagerMock() { var store = new Mock>(); return new Mock>( store.Object, null!, null!, null!, null!, null!, null!, null!, null!); } private static Mock> CreateSignInManagerMock(UserManager userManager) { var contextAccessor = new Mock(); contextAccessor.Setup(x => x.HttpContext).Returns(new DefaultHttpContext()); var claimsFactory = new Mock>(); return new Mock>( userManager, contextAccessor.Object, claimsFactory.Object, null!, null!, null!, null!); } private static ApplicationDbContext CreateContext() { var options = new DbContextOptionsBuilder() .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 }; } }