From dbe41709865733ff48dd87c9b13688e56e8ff877 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sat, 25 Apr 2026 18:27:30 -0400 Subject: [PATCH] Add unit tests for 9 new services/controllers and expand existing test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Services/StorageMigrationService.cs | 7 +- .../JobPhotoServiceTests.cs | 173 +++++++ .../MeasurementConversionServiceTests.cs | 62 +++ .../PlatformSettingsServiceTests.cs | 100 ++++ .../PricingCalculationServiceTests.cs | 440 ++++++++++++++++- .../QuoteApprovalControllerTests.cs | 449 ++++++++++++++++++ .../QuotePhotoServiceTests.cs | 239 ++++++++++ .../RegistrationControllerTests.cs | 328 ++++++++++++- .../ShopCapabilityCalculatorTests.cs | 107 +++++ .../StorageMigrationServiceTests.cs | 213 +++++++++ .../SubscriptionServiceTests.cs | 195 +++++++- .../TenantContextTests.cs | 300 ++++++++++++ .../UsageQuotaControllerTests.cs | 329 +++++++++++++ 13 files changed, 2930 insertions(+), 12 deletions(-) create mode 100644 tests/PowderCoating.UnitTests/JobPhotoServiceTests.cs create mode 100644 tests/PowderCoating.UnitTests/MeasurementConversionServiceTests.cs create mode 100644 tests/PowderCoating.UnitTests/PlatformSettingsServiceTests.cs create mode 100644 tests/PowderCoating.UnitTests/QuoteApprovalControllerTests.cs create mode 100644 tests/PowderCoating.UnitTests/QuotePhotoServiceTests.cs create mode 100644 tests/PowderCoating.UnitTests/ShopCapabilityCalculatorTests.cs create mode 100644 tests/PowderCoating.UnitTests/StorageMigrationServiceTests.cs create mode 100644 tests/PowderCoating.UnitTests/TenantContextTests.cs create mode 100644 tests/PowderCoating.UnitTests/UsageQuotaControllerTests.cs diff --git a/src/PowderCoating.Application/Services/StorageMigrationService.cs b/src/PowderCoating.Application/Services/StorageMigrationService.cs index 31c72ed..f483b9b 100644 --- a/src/PowderCoating.Application/Services/StorageMigrationService.cs +++ b/src/PowderCoating.Application/Services/StorageMigrationService.cs @@ -120,8 +120,11 @@ public class StorageMigrationService : IStorageMigrationService var contentType = GetContentType(Path.GetExtension(fullPath).ToLowerInvariant()); - await using var stream = File.OpenRead(fullPath); - var uploadResult = await _blobService.UploadAsync(container, relativePath, stream, contentType); + (bool Success, string ErrorMessage) uploadResult; + await using (var stream = File.OpenRead(fullPath)) + { + uploadResult = await _blobService.UploadAsync(container, relativePath, stream, contentType); + } if (!uploadResult.Success) { diff --git a/tests/PowderCoating.UnitTests/JobPhotoServiceTests.cs b/tests/PowderCoating.UnitTests/JobPhotoServiceTests.cs new file mode 100644 index 0000000..f7c4daf --- /dev/null +++ b/tests/PowderCoating.UnitTests/JobPhotoServiceTests.cs @@ -0,0 +1,173 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using PowderCoating.Application.Configuration; +using PowderCoating.Application.Interfaces; +using PowderCoating.Application.Services; +using PowderCoating.Core.Enums; + +namespace PowderCoating.UnitTests; + +public class JobPhotoServiceTests +{ + [Fact] + public async Task SaveJobPhotoAsync_ReturnsError_WhenFileMissing() + { + var service = CreateService(); + + var result = await service.SaveJobPhotoAsync(null!, 1, 2); + + Assert.False(result.Success); + Assert.Equal("No file was uploaded.", result.ErrorMessage); + } + + [Fact] + public async Task SaveJobPhotoAsync_ReturnsError_WhenFileTooLarge() + { + var service = CreateService(); + var file = CreateFormFile("big.jpg", 10 * 1024 * 1024 + 1); + + var result = await service.SaveJobPhotoAsync(file, 1, 2); + + Assert.False(result.Success); + Assert.Equal("Photo must be smaller than 10 MB.", result.ErrorMessage); + } + + [Fact] + public async Task SaveJobPhotoAsync_ReturnsError_WhenExtensionNotAllowed() + { + var service = CreateService(); + var file = CreateFormFile("notes.txt"); + + var result = await service.SaveJobPhotoAsync(file, 1, 2); + + Assert.False(result.Success); + Assert.Equal("Only JPG, PNG, GIF, and WebP images are allowed.", result.ErrorMessage); + } + + [Fact] + public async Task SaveJobPhotoAsync_ReturnsBlobError_WhenUploadFails() + { + var blobService = new Mock(); + blobService + .Setup(x => x.UploadAsync("jobimages", It.IsAny(), It.IsAny(), "image/png")) + .ReturnsAsync((false, "upload failed")); + + var service = CreateService(blobService); + + var result = await service.SaveJobPhotoAsync(CreateFormFile("photo.png"), 9, 7); + + Assert.False(result.Success); + Assert.Equal("upload failed", result.ErrorMessage); + } + + [Fact] + public async Task SaveJobPhotoAsync_UsesTenantScopedBlobPath_WhenSuccessful() + { + var blobService = new Mock(); + blobService + .Setup(x => x.UploadAsync("jobimages", It.IsAny(), It.IsAny(), "image/webp")) + .ReturnsAsync((true, string.Empty)); + + var service = CreateService(blobService); + + var result = await service.SaveJobPhotoAsync(CreateFormFile("photo.webp"), 9, 7, "caption", JobPhotoType.After); + + Assert.True(result.Success); + Assert.StartsWith("7/job-photos/9/", result.FilePath); + Assert.EndsWith(".webp", result.FilePath); + } + + [Fact] + public async Task DeleteJobPhotoAsync_ReturnsError_WhenPathMissing() + { + var service = CreateService(); + + var result = await service.DeleteJobPhotoAsync(string.Empty); + + Assert.False(result.Success); + Assert.Equal("File path is required.", result.ErrorMessage); + } + + [Fact] + public async Task GetJobPhotoAsync_ReturnsError_WhenPathMissing() + { + var service = CreateService(); + + var result = await service.GetJobPhotoAsync(" "); + + Assert.False(result.Success); + Assert.Equal("File path is required.", result.ErrorMessage); + Assert.Empty(result.FileContent); + } + + [Fact] + public async Task JobPhotoExistsAsync_ReturnsFalse_WhenPathMissing() + { + var service = CreateService(); + + var result = await service.JobPhotoExistsAsync(null!); + + Assert.False(result); + } + + [Fact] + public async Task GetJobPhotoAsync_ProxiesBlobDownload() + { + var blobService = new Mock(); + blobService + .Setup(x => x.DownloadAsync("jobimages", "7/job-photos/9/photo.jpg")) + .ReturnsAsync((true, new byte[] { 1, 2 }, "image/jpeg", string.Empty)); + + var service = CreateService(blobService); + + var result = await service.GetJobPhotoAsync("7/job-photos/9/photo.jpg"); + + Assert.True(result.Success); + Assert.Equal("image/jpeg", result.ContentType); + Assert.Equal(new byte[] { 1, 2 }, result.FileContent); + } + + [Fact] + public async Task JobPhotoExistsAsync_UsesBlobServiceForValidPath() + { + var blobService = new Mock(); + blobService + .Setup(x => x.ExistsAsync("jobimages", "7/job-photos/9/photo.jpg")) + .ReturnsAsync(true); + + var service = CreateService(blobService); + + var result = await service.JobPhotoExistsAsync("7/job-photos/9/photo.jpg"); + + Assert.True(result); + } + + private static JobPhotoService CreateService(Mock? blobService = null) + { + var settings = Options.Create(new StorageSettings + { + Containers = new StorageContainers + { + JobImages = "jobimages" + } + }); + + return new JobPhotoService( + (blobService ?? new Mock()).Object, + settings, + Mock.Of>()); + } + + private static IFormFile CreateFormFile(string fileName, long? lengthOverride = null) + { + var dataLength = lengthOverride.HasValue + ? (int)Math.Min(lengthOverride.Value, 1024) + : 16; + var bytes = Enumerable.Repeat((byte)65, dataLength).ToArray(); + var stream = new MemoryStream(bytes); + + return new FormFile(stream, 0, lengthOverride ?? bytes.Length, "file", fileName); + } +} diff --git a/tests/PowderCoating.UnitTests/MeasurementConversionServiceTests.cs b/tests/PowderCoating.UnitTests/MeasurementConversionServiceTests.cs new file mode 100644 index 0000000..aecf8e5 --- /dev/null +++ b/tests/PowderCoating.UnitTests/MeasurementConversionServiceTests.cs @@ -0,0 +1,62 @@ +using PowderCoating.Application.Services; + +namespace PowderCoating.UnitTests; + +public class MeasurementConversionServiceTests +{ + private readonly MeasurementConversionService _service = new(); + + [Fact] + public void SquareFeetToMeters_AndBack_RoundTripsToCurrencyStylePrecision() + { + var squareMeters = _service.SquareFeetToMeters(100m); + var squareFeet = _service.SquareMetersToFeet(squareMeters); + + Assert.Equal(9.29m, squareMeters); + Assert.Equal(100m, squareFeet, 1); + } + + [Fact] + public void PoundsToKilograms_AndBack_RoundTripsToCurrencyStylePrecision() + { + var kilograms = _service.PoundsToKilograms(10m); + var pounds = _service.KilogramsToPounds(kilograms); + + Assert.Equal(4.54m, kilograms); + Assert.Equal(10.01m, pounds, 2); + } + + [Fact] + public void ConvertArea_WhenImperialToMetric_UsesSquareFeetConversion() + { + var result = _service.ConvertArea(50m, fromImperial: true, toMetric: true); + + Assert.Equal(4.65m, result); + } + + [Fact] + public void ConvertArea_WhenSourceAndTargetAreSameSystem_ReturnsOriginalValue() + { + Assert.Equal(12.34m, _service.ConvertArea(12.34m, fromImperial: true, toMetric: false)); + Assert.Equal(56.78m, _service.ConvertArea(56.78m, fromImperial: false, toMetric: true)); + } + + [Fact] + public void ConvertWeight_WhenMetricToImperial_UsesKilogramsToPounds() + { + var result = _service.ConvertWeight(5m, fromImperial: false, toMetric: false); + + Assert.Equal(11.02m, result); + } + + [Fact] + public void UnitLabelHelpers_ReturnExpectedMetricAndImperialLabels() + { + Assert.Equal("sq ft", _service.GetAreaUnitLabel(false)); + Assert.Equal("sq m", _service.GetAreaUnitLabel(true)); + Assert.Equal("lb", _service.GetWeightUnitLabel(false)); + Assert.Equal("kg", _service.GetWeightUnitLabel(true)); + Assert.Equal("sq ft/lb", _service.GetCoverageUnitLabel(false)); + Assert.Equal("sq m/kg", _service.GetCoverageUnitLabel(true)); + } +} diff --git a/tests/PowderCoating.UnitTests/PlatformSettingsServiceTests.cs b/tests/PowderCoating.UnitTests/PlatformSettingsServiceTests.cs new file mode 100644 index 0000000..0448637 --- /dev/null +++ b/tests/PowderCoating.UnitTests/PlatformSettingsServiceTests.cs @@ -0,0 +1,100 @@ +using Microsoft.EntityFrameworkCore; +using PowderCoating.Core.Entities; +using PowderCoating.Infrastructure.Data; +using PowderCoating.Infrastructure.Services; + +namespace PowderCoating.UnitTests; + +public class PlatformSettingsServiceTests +{ + [Fact] + public async Task GetAsync_ReturnsStoredValue() + { + await using var context = CreateContext(); + context.PlatformSettings.Add(new PlatformSetting + { + Key = "BrandName", + Value = "Powder Coating Pro" + }); + await context.SaveChangesAsync(); + + var service = new PlatformSettingsService(context); + + var value = await service.GetAsync("BrandName"); + + Assert.Equal("Powder Coating Pro", value); + } + + [Fact] + public async Task GetAsync_WhenMissing_ReturnsNull() + { + await using var context = CreateContext(); + var service = new PlatformSettingsService(context); + + var value = await service.GetAsync("MissingKey"); + + Assert.Null(value); + } + + [Fact] + public async Task SetAsync_WhenSettingExists_UpdatesValueAndAuditFields() + { + await using var context = CreateContext(); + context.PlatformSettings.Add(new PlatformSetting + { + Key = "TrialsEnabled", + Value = "true", + UpdatedBy = "old-user" + }); + await context.SaveChangesAsync(); + + var service = new PlatformSettingsService(context); + + await service.SetAsync("TrialsEnabled", "false", "superadmin@example.com"); + + var setting = await context.PlatformSettings.SingleAsync(); + Assert.Equal("false", setting.Value); + Assert.Equal("superadmin@example.com", setting.UpdatedBy); + Assert.True(setting.UpdatedAt.HasValue); + } + + [Fact] + public async Task SetAsync_WhenSettingMissing_InsertsRow() + { + await using var context = CreateContext(); + var service = new PlatformSettingsService(context); + + await service.SetAsync("SupportEmail", "help@example.com", "setup"); + + var setting = await context.PlatformSettings.SingleAsync(); + Assert.Equal("SupportEmail", setting.Key); + Assert.Equal("help@example.com", setting.Value); + Assert.Equal("setup", setting.UpdatedBy); + } + + [Fact] + public async Task GetAllAsync_OrdersByGroupThenKey() + { + await using var context = CreateContext(); + context.PlatformSettings.AddRange( + new PlatformSetting { Key = "Zeta", GroupName = "Billing", Value = "1" }, + new PlatformSetting { Key = "Alpha", GroupName = "Billing", Value = "2" }, + new PlatformSetting { Key = "Bravo", GroupName = "Alerts", Value = "3" }); + await context.SaveChangesAsync(); + + var service = new PlatformSettingsService(context); + + var settings = await service.GetAllAsync(); + + Assert.Equal(new[] { "Bravo", "Alpha", "Zeta" }, settings.Select(s => s.Key).ToArray()); + } + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new ApplicationDbContext(options); + } +} diff --git a/tests/PowderCoating.UnitTests/PricingCalculationServiceTests.cs b/tests/PowderCoating.UnitTests/PricingCalculationServiceTests.cs index 8866f13..8419a16 100644 --- a/tests/PowderCoating.UnitTests/PricingCalculationServiceTests.cs +++ b/tests/PowderCoating.UnitTests/PricingCalculationServiceTests.cs @@ -106,6 +106,435 @@ public class PricingCalculationServiceTests Assert.Equal(246m, result.TotalPrice); } + [Fact] + public async Task CalculateCoatPriceAsync_InventoryPowder_UsesCalculatedUsageOnly() + { + var unitOfWork = CreateUnitOfWorkMock( + CreateOperatingCosts(), + inventoryItem: new InventoryItem + { + Id = 12, + CompanyId = 1, + Name = "Gloss Black", + UnitCost = 12m + }); + + var tenantContext = new Mock(); + tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false); + + var service = new PricingCalculationService( + unitOfWork.Object, + Mock.Of>(), + new MeasurementConversionService(), + tenantContext.Object); + + var coat = new CreateQuoteItemCoatDto + { + CoatName = "Gloss Black", + InventoryItemId = 12, + CoverageSqFtPerLb = 24m, + TransferEfficiency = 50m + }; + + var result = await service.CalculateCoatPriceAsync( + coat, + itemSurfaceAreaSqFt: 10m, + quantity: 2m, + coatIndex: 0, + estimatedMinutesBase: 30, + companyId: 1); + + Assert.Equal(20m, result.CoatMaterialCost, 2); + Assert.Equal(60m, result.CoatLaborCost); + Assert.Equal(80m, result.CoatTotalCost, 2); + } + + [Fact] + public async Task CalculateCoatPriceAsync_MetricTenant_ConvertsSurfaceAreaBeforePricing() + { + var unitOfWork = CreateUnitOfWorkMock(CreateOperatingCosts()); + var tenantContext = new Mock(); + tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(true); + + var service = new PricingCalculationService( + unitOfWork.Object, + Mock.Of>(), + new MeasurementConversionService(), + tenantContext.Object); + + var coat = new CreateQuoteItemCoatDto + { + CoatName = "Metric Blue", + PowderCostPerLb = 5m, + CoverageSqFtPerLb = 10m, + TransferEfficiency = 100m + }; + + var result = await service.CalculateCoatPriceAsync( + coat, + itemSurfaceAreaSqFt: 1m, + quantity: 1m, + coatIndex: 0, + estimatedMinutesBase: 0, + companyId: 1); + + Assert.Equal(5.38m, result.CoatMaterialCost); + Assert.Equal(0m, result.CoatLaborCost); + Assert.Equal(5.38m, result.CoatTotalCost); + } + + [Fact] + public async Task CalculateCoatPriceAsync_AdditionalCoatWithNoExtraLayerCharge_SkipsLabor() + { + var unitOfWork = CreateUnitOfWorkMock(CreateOperatingCosts()); + var tenantContext = new Mock(); + tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false); + + var service = new PricingCalculationService( + unitOfWork.Object, + Mock.Of>(), + new MeasurementConversionService(), + tenantContext.Object); + + var coat = new CreateQuoteItemCoatDto + { + CoatName = "Clear Coat", + PowderCostPerLb = 4m, + PowderToOrder = 1m, + CoverageSqFtPerLb = 20m, + TransferEfficiency = 100m, + NoExtraLayerCharge = true + }; + + var result = await service.CalculateCoatPriceAsync( + coat, + itemSurfaceAreaSqFt: 0m, + quantity: 2m, + coatIndex: 1, + estimatedMinutesBase: 45, + companyId: 1); + + Assert.Equal(4m, result.CoatMaterialCost); + Assert.Equal(0m, result.CoatLaborCost); + Assert.Equal(4m, result.CoatTotalCost); + } + + [Fact] + public async Task CalculateCoatPriceAsync_WhenOperatingCostsMissing_ReturnsZeros() + { + var unitOfWork = CreateUnitOfWorkMock(costs: null); + var tenantContext = new Mock(); + tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false); + + var service = new PricingCalculationService( + unitOfWork.Object, + Mock.Of>(), + new MeasurementConversionService(), + tenantContext.Object); + + var result = await service.CalculateCoatPriceAsync( + new CreateQuoteItemCoatDto { CoatName = "Unpriced" }, + itemSurfaceAreaSqFt: 10m, + quantity: 1m, + coatIndex: 0, + estimatedMinutesBase: 30, + companyId: 1); + + Assert.Equal(0m, result.CoatMaterialCost); + Assert.Equal(0m, result.CoatLaborCost); + Assert.Equal(0m, result.CoatTotalCost); + } + + [Fact] + public async Task CalculateQuoteItemPriceAsync_CatalogItem_UsesPowderCostOverrideAsBasePrice() + { + var unitOfWork = CreateUnitOfWorkMock( + CreateOperatingCosts(), + catalogItem: new CatalogItem + { + Id = 50, + CompanyId = 1, + Name = "Wheel", + DefaultPrice = 10m + }); + var tenantContext = new Mock(); + tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false); + + var service = new PricingCalculationService( + unitOfWork.Object, + Mock.Of>(), + new MeasurementConversionService(), + tenantContext.Object); + + var item = new CreateQuoteItemDto + { + Description = "Override catalog item", + CatalogItemId = 50, + PowderCostOverride = 77m, + Quantity = 3m + }; + + var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1); + + Assert.Equal(0m, result.MaterialCost); + Assert.Equal(0m, result.LaborCost); + Assert.Equal(77m, result.UnitPrice); + Assert.Equal(231m, result.TotalPrice); + } + + [Fact] + public async Task CalculateQuoteItemPriceAsync_CatalogItem_AddsPrepCostAndCustomPowder() + { + var unitOfWork = CreateUnitOfWorkMock( + CreateOperatingCosts(), + catalogItem: new CatalogItem + { + Id = 51, + CompanyId = 1, + Name = "Bracket", + DefaultPrice = 50m + }); + var tenantContext = new Mock(); + tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false); + + var service = new PricingCalculationService( + unitOfWork.Object, + Mock.Of>(), + new MeasurementConversionService(), + tenantContext.Object); + + var item = new CreateQuoteItemDto + { + Description = "Bracket with prep", + CatalogItemId = 51, + Quantity = 2m, + IncludePrepCost = true, + PrepServices = new List + { + new() { PrepServiceId = 1, EstimatedMinutes = 30 } + }, + Coats = new List + { + new() + { + CoatName = "Custom Green", + PowderCostPerLb = 5m, + PowderToOrder = 2m + } + } + }; + + var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1); + + Assert.Equal(10m, result.MaterialCost); + Assert.Equal(30m, result.LaborCost); + Assert.Equal(0m, result.EquipmentCost); + Assert.Equal(70m, result.UnitPrice); + Assert.Equal(140m, result.TotalPrice); + } + + [Fact] + public async Task CalculateQuoteItemPriceAsync_MarginMode_AppliesAdditionalCoatAndComplexity() + { + var costs = CreateOperatingCosts(); + costs.PricingMode = PowderCoating.Core.Enums.PricingMode.MarginOnTotalCost; + costs.TargetMarginPercent = 50m; + costs.CoatingBoothCostPerHour = 0m; + costs.ComplexityModeratePercent = 5m; + + var unitOfWork = CreateUnitOfWorkMock(costs); + var tenantContext = new Mock(); + tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false); + + var service = new PricingCalculationService( + unitOfWork.Object, + Mock.Of>(), + new MeasurementConversionService(), + tenantContext.Object); + + var item = new CreateQuoteItemDto + { + Description = "Complex fabricated part", + Quantity = 1m, + SurfaceAreaSqFt = 10m, + EstimatedMinutes = 60, + Complexity = "Moderate", + Coats = new List + { + new() + { + CoatName = "Base", + PowderCostPerLb = 10m, + CoverageSqFtPerLb = 10m, + TransferEfficiency = 100m + }, + new() + { + CoatName = "Top", + PowderCostPerLb = 10m, + CoverageSqFtPerLb = 10m, + TransferEfficiency = 100m + } + } + }; + + var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1); + + Assert.Equal(10.5m, result.MaterialCost); + Assert.Equal(60m, result.LaborCost); + Assert.Equal(0m, result.EquipmentCost); + Assert.Equal(222.075m, result.ItemSubtotal); + Assert.Equal(222.075m, result.TotalPrice); + } + + [Fact] + public async Task CalculateQuoteTotalsAsync_MixedAiAndManualItems_ScalesOvenCostBySurfaceAreaAndUsesManualTax() + { + var costs = CreateOperatingCosts(); + costs.OvenOperatingCostPerHour = 30m; + costs.DefaultOvenCycleMinutes = 60; + costs.ShopSuppliesRate = 0m; + costs.TaxPercent = 5m; + costs.MonthlyRent = 0m; + costs.MonthlyUtilities = 0m; + + var unitOfWork = CreateUnitOfWorkMock(costs); + var tenantContext = new Mock(); + tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false); + + var service = new PricingCalculationService( + unitOfWork.Object, + Mock.Of>(), + new MeasurementConversionService(), + tenantContext.Object); + + var items = new List + { + new() + { + Description = "AI estimate", + IsAiItem = true, + ManualUnitPrice = 200m, + Quantity = 1m, + SurfaceAreaSqFt = 50m + }, + new() + { + Description = "Labor item", + IsLaborItem = true, + Quantity = 1m, + SurfaceAreaSqFt = 50m, + EstimatedMinutes = 60 + } + }; + + var result = await service.CalculateQuoteTotalsAsync( + items, + companyId: 1, + manualTaxPercent: 8m); + + Assert.Equal(260m, result.ItemsSubtotal); + Assert.Equal(15m, result.OvenBatchCost); + Assert.Equal(275m, result.SubtotalBeforeDiscount); + Assert.Equal(8m, result.TaxPercent); + Assert.Equal(22m, result.TaxAmount); + Assert.Equal(297m, result.Total); + } + + [Fact] + public async Task CalculateQuoteTotalsAsync_ZeroSurfaceAreaFallback_UsesItemCountForOvenFraction() + { + var costs = CreateOperatingCosts(); + costs.OvenOperatingCostPerHour = 20m; + costs.DefaultOvenCycleMinutes = 60; + costs.ShopSuppliesRate = 0m; + costs.TaxPercent = 0m; + costs.MonthlyRent = 0m; + costs.MonthlyUtilities = 0m; + + var unitOfWork = CreateUnitOfWorkMock(costs); + var tenantContext = new Mock(); + tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false); + + var service = new PricingCalculationService( + unitOfWork.Object, + Mock.Of>(), + new MeasurementConversionService(), + tenantContext.Object); + + var items = new List + { + new() + { + Description = "AI item", + IsAiItem = true, + ManualUnitPrice = 100m, + Quantity = 1m + }, + new() + { + Description = "Shelf item", + IsSalesItem = true, + ManualUnitPrice = 40m, + Quantity = 1m + } + }; + + var result = await service.CalculateQuoteTotalsAsync(items, companyId: 1); + + Assert.Equal(140m, result.ItemsSubtotal); + Assert.Equal(10m, result.OvenBatchCost); + Assert.Equal(150m, result.Total); + } + + [Fact] + public async Task CalculateQuoteTotalsAsync_FixedRushAndFacilityOverhead_AreAppliedBeforeTotal() + { + var costs = CreateOperatingCosts(); + costs.OvenOperatingCostPerHour = 0m; + costs.MonthlyRent = 1600m; + costs.MonthlyUtilities = 0m; + costs.MonthlyBillableHours = 160; + costs.ShopSuppliesRate = 10m; + costs.RushChargeType = "FixedAmount"; + costs.RushChargeFixedAmount = 25m; + costs.TaxPercent = 0m; + + var unitOfWork = CreateUnitOfWorkMock(costs); + var tenantContext = new Mock(); + tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false); + + var service = new PricingCalculationService( + unitOfWork.Object, + Mock.Of>(), + new MeasurementConversionService(), + tenantContext.Object); + + var items = new List + { + new() + { + Description = "Labor item", + IsLaborItem = true, + Quantity = 2m, + EstimatedMinutes = 60 + } + }; + + var result = await service.CalculateQuoteTotalsAsync( + items, + companyId: 1, + isRushJob: true); + + Assert.Equal(120m, result.ItemsSubtotal); + Assert.Equal(10m, result.FacilityOverheadRatePerHour); + Assert.Equal(20m, result.FacilityOverheadCost); + Assert.Equal(12m, result.ShopSuppliesAmount); + Assert.Equal(152m, result.SubtotalBeforeDiscount); + Assert.Equal(25m, result.RushFee); + Assert.Equal(177m, result.Total); + } + [Fact] public async Task CalculateQuoteTotalsAsync_AppliesTierDiscount_QuoteDiscount_RushFee_AndTax() { @@ -174,24 +603,27 @@ public class PricingCalculationServiceTests Assert.Equal(243.18m, result.Total); } - private static Mock CreateUnitOfWorkMock(CompanyOperatingCosts costs) + private static Mock CreateUnitOfWorkMock( + CompanyOperatingCosts? costs, + InventoryItem? inventoryItem = null, + CatalogItem? catalogItem = null) { var unitOfWork = new Mock(); var companyOperatingCostsRepo = new Mock>(); companyOperatingCostsRepo .Setup(x => x.FindAsync(It.IsAny>>(), false, It.IsAny>[]>())) - .ReturnsAsync(new[] { costs }); + .ReturnsAsync(costs != null ? new[] { costs } : Array.Empty()); var inventoryRepo = new Mock>(); inventoryRepo .Setup(x => x.GetByIdAsync(It.IsAny(), false, It.IsAny>[]>())) - .ReturnsAsync((InventoryItem?)null); + .ReturnsAsync(inventoryItem); var catalogRepo = new Mock>(); catalogRepo .Setup(x => x.GetByIdAsync(It.IsAny(), false, It.IsAny>[]>())) - .ReturnsAsync((CatalogItem?)null); + .ReturnsAsync(catalogItem); var customerRepo = new Mock>(); customerRepo diff --git a/tests/PowderCoating.UnitTests/QuoteApprovalControllerTests.cs b/tests/PowderCoating.UnitTests/QuoteApprovalControllerTests.cs new file mode 100644 index 0000000..e26b790 --- /dev/null +++ b/tests/PowderCoating.UnitTests/QuoteApprovalControllerTests.cs @@ -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(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( + 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); + } +} diff --git a/tests/PowderCoating.UnitTests/QuotePhotoServiceTests.cs b/tests/PowderCoating.UnitTests/QuotePhotoServiceTests.cs new file mode 100644 index 0000000..ffd9a86 --- /dev/null +++ b/tests/PowderCoating.UnitTests/QuotePhotoServiceTests.cs @@ -0,0 +1,239 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using PowderCoating.Application.Configuration; +using PowderCoating.Application.Interfaces; +using PowderCoating.Application.Services; + +namespace PowderCoating.UnitTests; + +public class QuotePhotoServiceTests +{ + [Fact] + public async Task SaveTempPhotoAsync_ReturnsError_WhenFileMissing() + { + var service = CreateService(); + + var result = await service.SaveTempPhotoAsync(null!, companyId: 1); + + Assert.False(result.Success); + Assert.Equal("No file provided.", result.ErrorMessage); + } + + [Fact] + public async Task SaveTempPhotoAsync_ReturnsError_WhenFileTooLarge() + { + var service = CreateService(); + var file = CreateFormFile("huge.jpg", 10 * 1024 * 1024 + 1); + + var result = await service.SaveTempPhotoAsync(file, companyId: 1); + + Assert.False(result.Success); + Assert.Equal("File exceeds the 10 MB limit.", result.ErrorMessage); + } + + [Fact] + public async Task SaveTempPhotoAsync_ReturnsError_WhenExtensionNotAllowed() + { + var service = CreateService(); + var file = CreateFormFile("photo.bmp"); + + var result = await service.SaveTempPhotoAsync(file, companyId: 1); + + Assert.False(result.Success); + Assert.Equal("File type '.bmp' is not allowed.", result.ErrorMessage); + } + + [Fact] + public async Task SaveTempPhotoAsync_ReturnsBlobError_WhenUploadFails() + { + var blobService = new Mock(); + blobService + .Setup(x => x.UploadAsync("quoteimages", It.IsAny(), It.IsAny(), "image/png")) + .ReturnsAsync((false, "blob upload failed")); + + var service = CreateService(blobService: blobService); + var file = CreateFormFile("photo.png"); + + var result = await service.SaveTempPhotoAsync(file, companyId: 1); + + Assert.False(result.Success); + Assert.Equal("blob upload failed", result.ErrorMessage); + } + + [Fact] + public async Task SaveTempPhotoAsync_UploadsToTempPath_WhenValid() + { + var blobService = new Mock(); + blobService + .Setup(x => x.UploadAsync("quoteimages", It.IsAny(), It.IsAny(), "image/jpeg")) + .ReturnsAsync((true, string.Empty)); + + var service = CreateService(blobService: blobService); + var file = CreateFormFile("photo.jpg"); + + var result = await service.SaveTempPhotoAsync(file, companyId: 5); + + Assert.True(result.Success); + Assert.False(string.IsNullOrWhiteSpace(result.TempId)); + Assert.StartsWith($"temp/{result.TempId}/", result.FilePath); + Assert.EndsWith(".jpg", result.FilePath); + } + + [Fact] + public async Task PromoteTempPhotoAsync_ReturnsError_WhenTempPhotoMissing() + { + var blobService = new Mock(); + blobService + .Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/")) + .ReturnsAsync(Array.Empty()); + + var service = CreateService(blobService: blobService); + + var result = await service.PromoteTempPhotoAsync("temp123", quoteId: 10, companyId: 3); + + Assert.False(result.Success); + Assert.Equal("Temp photo not found.", result.ErrorMessage); + } + + [Fact] + public async Task PromoteTempPhotoAsync_ReturnsError_WhenTempDownloadFails() + { + var blobService = new Mock(); + blobService + .Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/")) + .ReturnsAsync(new[] { "temp/temp123/original.png" }); + blobService + .Setup(x => x.DownloadAsync("quoteimages", "temp/temp123/original.png")) + .ReturnsAsync((false, Array.Empty(), string.Empty, "download failed")); + + var service = CreateService(blobService: blobService); + + var result = await service.PromoteTempPhotoAsync("temp123", quoteId: 10, companyId: 3); + + Assert.False(result.Success); + Assert.Equal("Failed to read temp photo.", result.ErrorMessage); + } + + [Fact] + public async Task PromoteTempPhotoAsync_ReturnsError_WhenPermanentUploadFails() + { + var blobService = new Mock(); + blobService + .Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/")) + .ReturnsAsync(new[] { "temp/temp123/original.webp" }); + blobService + .Setup(x => x.DownloadAsync("quoteimages", "temp/temp123/original.webp")) + .ReturnsAsync((true, new byte[] { 1, 2, 3 }, "image/webp", string.Empty)); + blobService + .Setup(x => x.UploadAsync("quoteimages", It.IsAny(), It.IsAny(), "image/webp")) + .ReturnsAsync((false, "upload failed")); + + var service = CreateService(blobService: blobService); + + var result = await service.PromoteTempPhotoAsync("temp123", quoteId: 10, companyId: 3); + + Assert.False(result.Success); + Assert.Equal("Failed to save permanent photo.", result.ErrorMessage); + } + + [Fact] + public async Task PromoteTempPhotoAsync_PromotesAndDeletesTempBlob_WhenSuccessful() + { + var blobService = new Mock(); + blobService + .Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/")) + .ReturnsAsync(new[] { "temp/temp123/original.png" }); + blobService + .Setup(x => x.DownloadAsync("quoteimages", "temp/temp123/original.png")) + .ReturnsAsync((true, new byte[] { 1, 2, 3 }, "image/png", string.Empty)); + blobService + .Setup(x => x.UploadAsync("quoteimages", It.IsAny(), It.IsAny(), "image/png")) + .ReturnsAsync((true, string.Empty)); + blobService + .Setup(x => x.DeleteAsync("quoteimages", "temp/temp123/original.png")) + .ReturnsAsync((true, string.Empty)); + + var service = CreateService(blobService: blobService); + + var result = await service.PromoteTempPhotoAsync("temp123", quoteId: 10, companyId: 3); + + Assert.True(result.Success); + Assert.StartsWith("3/quote-photos/10/", result.FilePath); + Assert.EndsWith(".png", result.FilePath); + blobService.Verify(x => x.DeleteAsync("quoteimages", "temp/temp123/original.png"), Times.Once); + } + + [Fact] + public async Task ReadTempPhotosAsync_ReturnsOnlySuccessfulDownloads() + { + var blobService = new Mock(); + blobService + .Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/")) + .ReturnsAsync(new[] { "temp/temp123/one.jpg", "temp/temp123/two.jpg" }); + blobService + .Setup(x => x.DownloadAsync("quoteimages", "temp/temp123/one.jpg")) + .ReturnsAsync((true, new byte[] { 1 }, "image/jpeg", string.Empty)); + blobService + .Setup(x => x.DownloadAsync("quoteimages", "temp/temp123/two.jpg")) + .ReturnsAsync((false, Array.Empty(), string.Empty, "failed")); + + var service = CreateService(blobService: blobService); + + var result = await service.ReadTempPhotosAsync("temp123"); + + Assert.Single(result); + Assert.Equal("one.jpg", result[0].FileName); + Assert.Equal("image/jpeg", result[0].ContentType); + } + + [Fact] + public async Task CleanupTempAsync_ContinuesDeleting_WhenOneDeleteThrows() + { + var blobService = new Mock(); + blobService + .Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/")) + .ReturnsAsync(new[] { "temp/temp123/one.jpg", "temp/temp123/two.jpg" }); + blobService + .Setup(x => x.DeleteAsync("quoteimages", "temp/temp123/one.jpg")) + .ThrowsAsync(new InvalidOperationException("boom")); + blobService + .Setup(x => x.DeleteAsync("quoteimages", "temp/temp123/two.jpg")) + .ReturnsAsync((true, string.Empty)); + + var service = CreateService(blobService: blobService); + + await service.CleanupTempAsync("temp123"); + + blobService.Verify(x => x.DeleteAsync("quoteimages", "temp/temp123/one.jpg"), Times.Once); + blobService.Verify(x => x.DeleteAsync("quoteimages", "temp/temp123/two.jpg"), Times.Once); + } + + private static QuotePhotoService CreateService(Mock? blobService = null) + { + var settings = Options.Create(new StorageSettings + { + Containers = new StorageContainers + { + QuoteImages = "quoteimages" + } + }); + + return new QuotePhotoService( + (blobService ?? new Mock()).Object, + settings, + Mock.Of>()); + } + + private static IFormFile CreateFormFile(string fileName, long? lengthOverride = null) + { + var dataLength = lengthOverride.HasValue + ? (int)Math.Min(lengthOverride.Value, 1024) + : 16; + var bytes = Enumerable.Repeat((byte)65, dataLength).ToArray(); + var stream = new MemoryStream(bytes); + + return new FormFile(stream, 0, lengthOverride ?? bytes.Length, "file", fileName); + } +} diff --git a/tests/PowderCoating.UnitTests/RegistrationControllerTests.cs b/tests/PowderCoating.UnitTests/RegistrationControllerTests.cs index 60cea60..1998628 100644 --- a/tests/PowderCoating.UnitTests/RegistrationControllerTests.cs +++ b/tests/PowderCoating.UnitTests/RegistrationControllerTests.cs @@ -1,13 +1,18 @@ 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; @@ -95,18 +100,315 @@ public class RegistrationControllerTests 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? stripeService = null, + Mock? platformSettings = null) { var unitOfWork = new UnitOfWork(context); var userManagerMock = userManager ?? CreateUserManagerMock(); var signInManagerInstance = signInManager ?? CreateSignInManagerMock(userManagerMock.Object).Object; - var platformSettings = new Mock(); - platformSettings.Setup(x => x.GetAsync(It.IsAny())).ReturnsAsync((string?)null); + var platformSettingsMock = platformSettings ?? new Mock(); + if (platformSettings is null) + { + platformSettingsMock.Setup(x => x.GetAsync(It.IsAny())).ReturnsAsync((string?)null); + } var controller = new RegistrationController( unitOfWork, @@ -116,7 +418,7 @@ public class RegistrationControllerTests Mock.Of(), Mock.Of(), Mock.Of(), - platformSettings.Object, + platformSettingsMock.Object, (stripeService ?? new Mock()).Object, Mock.Of(), Mock.Of>()); @@ -126,6 +428,11 @@ public class RegistrationControllerTests { 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; @@ -172,6 +479,19 @@ public class RegistrationControllerTests 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 diff --git a/tests/PowderCoating.UnitTests/ShopCapabilityCalculatorTests.cs b/tests/PowderCoating.UnitTests/ShopCapabilityCalculatorTests.cs new file mode 100644 index 0000000..fc22ade --- /dev/null +++ b/tests/PowderCoating.UnitTests/ShopCapabilityCalculatorTests.cs @@ -0,0 +1,107 @@ +using PowderCoating.Application.Services; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; + +namespace PowderCoating.UnitTests; + +public class ShopCapabilityCalculatorTests +{ + [Fact] + public void GetBlastRateSqFtPerHour_WithOverride_ReturnsOverride() + { + var costs = new CompanyOperatingCosts + { + BlastRateSqFtPerHourOverride = 42.5m, + CompressorCfm = 150m, + BlastNozzleSize = 6, + BlastSetupType = BlastSetupType.PressurePot, + PrimaryBlastSubstrate = BlastSubstrateType.Paint + }; + + var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs); + + Assert.Equal(42.5m, result); + } + + [Fact] + public void GetBlastRateSqFtPerHour_WithNoCompressorCfm_ReturnsZero() + { + var costs = new CompanyOperatingCosts + { + CompressorCfm = 0m + }; + + var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs); + + Assert.Equal(0m, result); + } + + [Fact] + public void GetBlastRateSqFtPerHour_DerivesRateFromEquipmentInputs() + { + var costs = new CompanyOperatingCosts + { + CompressorCfm = 150m, + BlastNozzleSize = 6, + BlastSetupType = BlastSetupType.PressurePot, + PrimaryBlastSubstrate = BlastSubstrateType.Paint + }; + + var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs); + + Assert.Equal(58.5m, result); + } + + [Fact] + public void GetBlastRateSqFtPerHour_ForNamedSetup_UsesSetupOverload() + { + var setup = new CompanyBlastSetup + { + Name = "Main Cabinet", + CompressorCfm = 7m, + BlastNozzleSize = 4, + SetupType = BlastSetupType.SiphonCabinet, + PrimarySubstrate = BlastSubstrateType.Mixed + }; + + var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(setup); + + Assert.Equal(1.7m, result); + } + + [Theory] + [InlineData(CoatingGunType.Corona, 40)] + [InlineData(CoatingGunType.Tribo, 35)] + [InlineData(CoatingGunType.Both, 40)] + public void GetCoatingRateSqFtPerHour_ReturnsExpectedDefaultByGunType(CoatingGunType gunType, decimal expected) + { + var costs = new CompanyOperatingCosts + { + CoatingGunType = gunType + }; + + var result = ShopCapabilityCalculator.GetCoatingRateSqFtPerHour(costs); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(ShopCapabilityTier.Garage, BlastSetupType.SiphonCabinet, 7, 4, BlastSubstrateType.Mixed)] + [InlineData(ShopCapabilityTier.Small, BlastSetupType.PressurePot, 40, 5, BlastSubstrateType.Mixed)] + [InlineData(ShopCapabilityTier.Medium, BlastSetupType.PressurePot, 80, 5, BlastSubstrateType.Mixed)] + [InlineData(ShopCapabilityTier.Large, BlastSetupType.PressurePot, 150, 6, BlastSubstrateType.Mixed)] + public void TierDefaults_ReturnExpectedPresetValues( + ShopCapabilityTier tier, + BlastSetupType expectedSetup, + decimal expectedCfm, + int expectedNozzle, + BlastSubstrateType expectedSubstrate) + { + var defaults = ShopCapabilityCalculator.TierDefaults(tier); + + Assert.Equal(expectedSetup, defaults.SetupType); + Assert.Equal(expectedCfm, defaults.Cfm); + Assert.Equal(expectedNozzle, defaults.NozzleSize); + Assert.Equal(expectedSubstrate, defaults.Substrate); + } +} diff --git a/tests/PowderCoating.UnitTests/StorageMigrationServiceTests.cs b/tests/PowderCoating.UnitTests/StorageMigrationServiceTests.cs new file mode 100644 index 0000000..0a6e4a4 --- /dev/null +++ b/tests/PowderCoating.UnitTests/StorageMigrationServiceTests.cs @@ -0,0 +1,213 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using PowderCoating.Application.Configuration; +using PowderCoating.Application.Interfaces; +using PowderCoating.Application.Services; + +namespace PowderCoating.UnitTests; + +public class StorageMigrationServiceTests +{ + [Fact] + public async Task MigrateFilesystemToAzureAsync_ReturnsError_WhenDirectoryMissing() + { + var service = CreateService(); + var missingPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + + var result = await service.MigrateFilesystemToAzureAsync(missingPath); + + Assert.Equal(0, result.Failed); + Assert.Equal(0, result.Total); + Assert.Contains("Media directory not found", result.Errors.Single()); + Assert.True(result.HasErrors); + } + + [Fact] + public async Task MigrateFilesystemToAzureAsync_MarksUnknownPathsAsFailed() + { + var mediaRoot = CreateTempMediaRoot(); + try + { + var unknownPath = Path.Combine(mediaRoot, "1", "misc"); + Directory.CreateDirectory(unknownPath); + await File.WriteAllTextAsync(Path.Combine(unknownPath, "file.bin"), "abc"); + + var service = CreateService(); + + var result = await service.MigrateFilesystemToAzureAsync(mediaRoot); + + Assert.Equal(1, result.Failed); + Assert.Contains(result.Errors, e => e.Contains("Unknown file type")); + Assert.Equal(MigrationFileStatus.Failed, result.Files.Single().Status); + } + finally + { + SafeDeleteDirectory(mediaRoot); + } + } + + [Fact] + public async Task MigrateFilesystemToAzureAsync_SkipsExistingBlobs() + { + var mediaRoot = CreateTempMediaRoot(); + try + { + var filePath = WriteMediaFile(mediaRoot, "1/profile-photos/user.jpg", "profile"); + var relativePath = "1/profile-photos/user.jpg"; + + var blobService = new Mock(); + blobService + .Setup(x => x.ExistsAsync("profileimages", relativePath)) + .ReturnsAsync(true); + + var service = CreateService(blobService); + + var result = await service.MigrateFilesystemToAzureAsync(mediaRoot); + + Assert.Equal(1, result.Skipped); + Assert.Equal(0, result.Migrated); + Assert.Equal(MigrationFileStatus.Skipped, result.Files.Single().Status); + Assert.True(File.Exists(filePath)); + } + finally + { + SafeDeleteDirectory(mediaRoot); + } + } + + [Fact] + public async Task MigrateFilesystemToAzureAsync_RecordsUploadFailures() + { + var mediaRoot = CreateTempMediaRoot(); + try + { + WriteMediaFile(mediaRoot, "1/job-photos/9/photo.png", "photo"); + const string relativePath = "1/job-photos/9/photo.png"; + + var blobService = new Mock(); + blobService.Setup(x => x.ExistsAsync("jobimages", relativePath)).ReturnsAsync(false); + blobService + .Setup(x => x.UploadAsync("jobimages", relativePath, It.IsAny(), "image/png")) + .ReturnsAsync((false, "upload failed")); + + var service = CreateService(blobService); + + var result = await service.MigrateFilesystemToAzureAsync(mediaRoot); + + Assert.Equal(1, result.Failed); + Assert.Contains(result.Errors, e => e.Contains("upload failed")); + Assert.Equal(MigrationFileStatus.Failed, result.Files.Single().Status); + } + finally + { + SafeDeleteDirectory(mediaRoot); + } + } + + [Fact] + public async Task MigrateFilesystemToAzureAsync_DeletesLocalFileAfterSuccessfulMigration_WhenRequested() + { + var mediaRoot = CreateTempMediaRoot(); + try + { + var fullPath = WriteMediaFile(mediaRoot, "1/company-logo.png", "logo"); + const string relativePath = "1/company-logo.png"; + + var blobService = new Mock(); + blobService.Setup(x => x.ExistsAsync("companylogos", relativePath)).ReturnsAsync(false); + blobService + .Setup(x => x.UploadAsync("companylogos", relativePath, It.IsAny(), "image/png")) + .ReturnsAsync((true, string.Empty)); + + var service = CreateService(blobService); + + var result = await service.MigrateFilesystemToAzureAsync(mediaRoot, deleteLocalAfterMigration: true); + + Assert.Equal(1, result.Migrated); + Assert.Contains(result.Files, f => f.RelativePath == relativePath && f.Status == MigrationFileStatus.Migrated); + Assert.False(File.Exists(fullPath)); + } + finally + { + SafeDeleteDirectory(mediaRoot); + } + } + + [Fact] + public async Task MigrateFilesystemToAzureAsync_ContinuesAfterPerFileException() + { + var mediaRoot = CreateTempMediaRoot(); + try + { + WriteMediaFile(mediaRoot, "1/profile-photos/user.jpg", "profile"); + WriteMediaFile(mediaRoot, "1/equipment-manuals/manual.pdf", "manual"); + + var blobService = new Mock(); + blobService + .Setup(x => x.ExistsAsync("profileimages", "1/profile-photos/user.jpg")) + .ThrowsAsync(new InvalidOperationException("broken exists")); + blobService + .Setup(x => x.ExistsAsync("manuals", "1/equipment-manuals/manual.pdf")) + .ReturnsAsync(false); + blobService + .Setup(x => x.UploadAsync("manuals", "1/equipment-manuals/manual.pdf", It.IsAny(), "application/pdf")) + .ReturnsAsync((true, string.Empty)); + + var service = CreateService(blobService); + + var result = await service.MigrateFilesystemToAzureAsync(mediaRoot); + + Assert.Equal(1, result.Failed); + Assert.Equal(1, result.Migrated); + Assert.Contains(result.Errors, e => e.Contains("broken exists")); + Assert.Equal(2, result.Total); + } + finally + { + SafeDeleteDirectory(mediaRoot); + } + } + + private static StorageMigrationService CreateService(Mock? blobService = null) + { + var settings = Options.Create(new StorageSettings + { + Containers = new StorageContainers + { + ProfileImages = "profileimages", + JobImages = "jobimages", + Manuals = "manuals", + CompanyLogos = "companylogos" + } + }); + + return new StorageMigrationService( + (blobService ?? new Mock()).Object, + settings, + Mock.Of>()); + } + + private static string CreateTempMediaRoot() + { + var path = Path.Combine(Path.GetTempPath(), "pca-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static string WriteMediaFile(string mediaRoot, string relativePath, string content) + { + var fullPath = Path.Combine(mediaRoot, relativePath.Replace("/", Path.DirectorySeparatorChar.ToString())); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, content); + return fullPath; + } + + private static void SafeDeleteDirectory(string path) + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } +} diff --git a/tests/PowderCoating.UnitTests/SubscriptionServiceTests.cs b/tests/PowderCoating.UnitTests/SubscriptionServiceTests.cs index 5e4f3a6..be67892 100644 --- a/tests/PowderCoating.UnitTests/SubscriptionServiceTests.cs +++ b/tests/PowderCoating.UnitTests/SubscriptionServiceTests.cs @@ -130,6 +130,191 @@ public class SubscriptionServiceTests Assert.False(allowed); } + [Fact] + public async Task GetStatusAsync_ReturnsGracePeriod_WhenSubscriptionRecentlyExpired() + { + await using var context = CreateContext(); + context.Companies.Add(new Company + { + Id = 20, + CompanyId = 20, + CompanyName = "Grace Co", + PrimaryContactName = "Owner", + PrimaryContactEmail = "grace@example.com", + SubscriptionStatus = SubscriptionStatus.Active, + SubscriptionEndDate = DateTime.UtcNow.Date.AddDays(-5), + IsActive = true + }); + await context.SaveChangesAsync(); + + var service = new SubscriptionService(new UnitOfWork(context), context); + + var status = await service.GetStatusAsync(20); + + Assert.Equal(SubscriptionStatus.GracePeriod, status); + } + + [Fact] + public async Task GetStatusAsync_ReturnsExpired_WhenPastGraceWindow() + { + await using var context = CreateContext(); + context.Companies.Add(new Company + { + Id = 21, + CompanyId = 21, + CompanyName = "Expired Co", + PrimaryContactName = "Owner", + PrimaryContactEmail = "expired@example.com", + SubscriptionStatus = SubscriptionStatus.Active, + SubscriptionEndDate = DateTime.UtcNow.Date.AddDays(-15), + IsActive = true + }); + await context.SaveChangesAsync(); + + var service = new SubscriptionService(new UnitOfWork(context), context); + + var status = await service.GetStatusAsync(21); + + Assert.Equal(SubscriptionStatus.Expired, status); + } + + [Fact] + public async Task GetStatusAsync_ReturnsActive_ForCompedCompanyEvenWhenExpired() + { + await using var context = CreateContext(); + context.Companies.Add(new Company + { + Id = 22, + CompanyId = 22, + CompanyName = "Comped Co", + PrimaryContactName = "Owner", + PrimaryContactEmail = "comped@example.com", + SubscriptionStatus = SubscriptionStatus.Active, + SubscriptionEndDate = DateTime.UtcNow.Date.AddDays(-30), + IsActive = true, + IsComped = true + }); + await context.SaveChangesAsync(); + + var service = new SubscriptionService(new UnitOfWork(context), context); + + var status = await service.GetStatusAsync(22); + + Assert.Equal(SubscriptionStatus.Active, status); + } + + [Fact] + public async Task IsAiInventoryAssistEnabledAsync_RequiresCompanyToggle() + { + await using var context = CreateContext(); + SeedCompanyAndPlan(context, companyId: 13, plan: 7, allowAiInventoryAssist: true); + var company = await context.Companies.FindAsync(13); + company!.AiInventoryAssistEnabled = false; + await context.SaveChangesAsync(); + + var service = new SubscriptionService(new UnitOfWork(context), context); + + var enabled = await service.IsAiInventoryAssistEnabledAsync(13); + + Assert.False(enabled); + } + + [Fact] + public async Task GetJobPhotoCountAsync_ExcludesAiAnalysisPhotos() + { + await using var context = CreateContext(); + SeedCompanyAndPlan(context, companyId: 14, plan: 8, maxJobPhotos: 5); + context.JobPhotos.AddRange( + new JobPhoto + { + Id = 1, + CompanyId = 14, + JobId = 100, + FilePath = "jobs/100/1.jpg", + FileName = "1.jpg", + FileSize = 100, + ContentType = "image/jpeg", + UploadedById = "u1" + }, + new JobPhoto + { + Id = 2, + CompanyId = 14, + JobId = 100, + FilePath = "jobs/100/2.jpg", + FileName = "2.jpg", + FileSize = 100, + ContentType = "image/jpeg", + UploadedById = "u1", + IsAiAnalysisPhoto = true + }); + await context.SaveChangesAsync(); + + var service = new SubscriptionService(new UnitOfWork(context), context); + + var (used, max) = await service.GetJobPhotoCountAsync(14, 100); + + Assert.Equal(1, used); + Assert.Equal(5, max); + } + + [Fact] + public async Task GetQuotePhotoCountAsync_ExcludesAiAnalysisPhotos() + { + await using var context = CreateContext(); + SeedCompanyAndPlan(context, companyId: 15, plan: 9, maxQuotePhotos: 4); + context.QuotePhotos.AddRange( + new QuotePhoto + { + Id = 1, + CompanyId = 15, + QuoteId = 200, + TempId = "temp-1", + FilePath = "quotes/200/1.jpg", + FileName = "1.jpg", + FileSize = 100, + ContentType = "image/jpeg", + IsAiAnalysisPhoto = false + }, + new QuotePhoto + { + Id = 2, + CompanyId = 15, + QuoteId = 200, + TempId = "temp-2", + FilePath = "quotes/200/2.jpg", + FileName = "2.jpg", + FileSize = 100, + ContentType = "image/jpeg", + IsAiAnalysisPhoto = true + }); + await context.SaveChangesAsync(); + + var service = new SubscriptionService(new UnitOfWork(context), context); + + var (used, max) = await service.GetQuotePhotoCountAsync(15, 200); + + Assert.Equal(1, used); + Assert.Equal(4, max); + } + + [Fact] + public async Task CanUseAiPhotoQuoteAsync_ReturnsFalse_WhenUsageEqualsLimit() + { + await using var context = CreateContext(); + SeedCompanyAndPlan(context, companyId: 16, plan: 10, maxAiPhotoQuotesPerMonth: 2, allowAiPhotoQuotes: true); + context.AiItemPredictions.AddRange( + new AiItemPrediction { Id = 1, CompanyId = 16, CreatedAt = DateTime.UtcNow.AddDays(-1) }, + new AiItemPrediction { Id = 2, CompanyId = 16, CreatedAt = DateTime.UtcNow.AddDays(-2) }); + await context.SaveChangesAsync(); + + var service = new SubscriptionService(new UnitOfWork(context), context); + + var allowed = await service.CanUseAiPhotoQuoteAsync(16); + + Assert.False(allowed); + } + private static ApplicationDbContext CreateContext() { var options = new DbContextOptionsBuilder() @@ -148,7 +333,10 @@ public class SubscriptionServiceTests int maxCustomers = -1, int maxQuotes = -1, int maxAiPhotoQuotesPerMonth = -1, - bool allowAiPhotoQuotes = true) + int maxJobPhotos = -1, + int maxQuotePhotos = -1, + bool allowAiPhotoQuotes = true, + bool allowAiInventoryAssist = true) { context.Companies.Add(new Company { @@ -173,8 +361,11 @@ public class SubscriptionServiceTests MaxActiveJobs = maxActiveJobs, MaxCustomers = maxCustomers, MaxQuotes = maxQuotes, + MaxJobPhotos = maxJobPhotos, + MaxQuotePhotos = maxQuotePhotos, MaxAiPhotoQuotesPerMonth = maxAiPhotoQuotesPerMonth, - AllowAiPhotoQuotes = allowAiPhotoQuotes + AllowAiPhotoQuotes = allowAiPhotoQuotes, + AllowAiInventoryAssist = allowAiInventoryAssist }); context.JobPriorityLookups.Add(new JobPriorityLookup diff --git a/tests/PowderCoating.UnitTests/TenantContextTests.cs b/tests/PowderCoating.UnitTests/TenantContextTests.cs new file mode 100644 index 0000000..6f6955c --- /dev/null +++ b/tests/PowderCoating.UnitTests/TenantContextTests.cs @@ -0,0 +1,300 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Moq; +using PowderCoating.Core.Entities; +using PowderCoating.Infrastructure.Data; +using PowderCoating.Infrastructure.Services; + +namespace PowderCoating.UnitTests; + +public class TenantContextTests +{ + [Fact] + public void GetCurrentCompanyId_WhenUnauthenticated_ReturnsNull() + { + using var context = CreateContext(); + var userManager = CreateUserManagerMock(); + var accessor = CreateHttpContextAccessor(new ClaimsPrincipal(new ClaimsIdentity())); + var tenantContext = new TenantContext(accessor.Object, userManager.Object, context); + + var companyId = tenantContext.GetCurrentCompanyId(); + + Assert.Null(companyId); + } + + [Fact] + public void GetCurrentCompanyId_WhenSuperAdminIsImpersonating_ReturnsSessionOverride() + { + using var context = CreateContext(); + var userManager = CreateUserManagerMock(); + var session = new TestSession(); + session.SetInt32("ImpersonatingCompanyId", 42); + var accessor = CreateHttpContextAccessor( + CreatePrincipal(isAuthenticated: true, name: "admin@example.com", roles: ["SuperAdmin"]), + session); + var tenantContext = new TenantContext(accessor.Object, userManager.Object, context); + + var companyId = tenantContext.GetCurrentCompanyId(); + + Assert.Equal(42, companyId); + } + + [Fact] + public void GetCurrentCompanyId_PrefersCompanyClaim() + { + using var context = CreateContext(); + var userManager = CreateUserManagerMock(); + userManager.Setup(x => x.Users).Returns(Enumerable.Empty().AsQueryable()); + var accessor = CreateHttpContextAccessor( + CreatePrincipal(isAuthenticated: true, name: "user@example.com", companyIdClaim: 9)); + var tenantContext = new TenantContext(accessor.Object, userManager.Object, context); + + var companyId = tenantContext.GetCurrentCompanyId(); + + Assert.Equal(9, companyId); + } + + [Fact] + public async Task GetCurrentCompanyId_WhenClaimMissing_FallsBackToUserLookup() + { + await using var context = CreateContext(); + context.Users.Add(new ApplicationUser + { + Id = "user-1", + UserName = "legacy@example.com", + Email = "legacy@example.com", + FirstName = "Legacy", + LastName = "User", + CompanyId = 17 + }); + await context.SaveChangesAsync(); + + var userManager = CreateUserManagerMock(); + userManager.Setup(x => x.Users).Returns(context.Users); + var accessor = CreateHttpContextAccessor( + CreatePrincipal(isAuthenticated: true, name: "legacy@example.com")); + var tenantContext = new TenantContext(accessor.Object, userManager.Object, context); + + var companyId = tenantContext.GetCurrentCompanyId(); + + Assert.Equal(17, companyId); + } + + [Fact] + public void IsPlatformAdmin_ReturnsTrue_ForSuperAdminWithoutTenantScope() + { + using var context = CreateContext(); + var userManager = CreateUserManagerMock(); + userManager.Setup(x => x.Users).Returns(Enumerable.Empty().AsQueryable()); + var accessor = CreateHttpContextAccessor( + CreatePrincipal(isAuthenticated: true, roles: ["SuperAdmin"])); + var tenantContext = new TenantContext(accessor.Object, userManager.Object, context); + + var isPlatformAdmin = tenantContext.IsPlatformAdmin(); + + Assert.True(isPlatformAdmin); + } + + [Fact] + public void IsPlatformAdmin_ReturnsFalse_ForSuperAdminImpersonatingCompany() + { + using var context = CreateContext(); + var userManager = CreateUserManagerMock(); + var session = new TestSession(); + session.SetInt32("ImpersonatingCompanyId", 2); + var accessor = CreateHttpContextAccessor( + CreatePrincipal(isAuthenticated: true, name: "admin@example.com", roles: ["SuperAdmin"]), + session); + var tenantContext = new TenantContext(accessor.Object, userManager.Object, context); + + var isPlatformAdmin = tenantContext.IsPlatformAdmin(); + + Assert.False(isPlatformAdmin); + } + + [Fact] + public async Task UseMetricSystemAsync_ReturnsStoredPreference() + { + await using var context = CreateContext(); + context.CompanyPreferences.Add(new CompanyPreferences + { + Id = 1, + CompanyId = 25, + UseMetricSystem = true + }); + await context.SaveChangesAsync(); + + var userManager = CreateUserManagerMock(); + var accessor = CreateHttpContextAccessor( + CreatePrincipal(isAuthenticated: true, name: "metric@example.com", companyIdClaim: 25)); + var tenantContext = new TenantContext(accessor.Object, userManager.Object, context); + + var useMetric = await tenantContext.UseMetricSystemAsync(); + + Assert.True(useMetric); + } + + [Fact] + public async Task GetCurrentCompanyAsync_ReturnsCompanyFromUserManager() + { + await using var context = CreateContext(); + var company = new Company + { + Id = 31, + CompanyId = 31, + CompanyName = "Current Co", + PrimaryContactName = "Owner", + PrimaryContactEmail = "owner@example.com" + }; + var principal = CreatePrincipal(isAuthenticated: true, name: "current@example.com", companyIdClaim: 31); + var user = new ApplicationUser + { + Id = "user-31", + UserName = "current@example.com", + Email = "current@example.com", + FirstName = "Current", + LastName = "User", + CompanyId = 31, + Company = company + }; + + var userManager = CreateUserManagerMock(); + userManager.Setup(x => x.GetUserAsync(principal)).ReturnsAsync(user); + var accessor = CreateHttpContextAccessor(principal); + var tenantContext = new TenantContext(accessor.Object, userManager.Object, context); + + var currentCompany = await tenantContext.GetCurrentCompanyAsync(); + + Assert.NotNull(currentCompany); + Assert.Equal("Current Co", currentCompany!.CompanyName); + } + + [Fact] + public void IsPlatformAdmin_ReturnsTrue_ForSuperAdminOnCompanyOne() + { + using var context = CreateContext(); + var userManager = CreateUserManagerMock(); + var accessor = CreateHttpContextAccessor( + CreatePrincipal(isAuthenticated: true, name: "platform@example.com", companyIdClaim: 1, roles: ["SuperAdmin"])); + var tenantContext = new TenantContext(accessor.Object, userManager.Object, context); + + var isPlatformAdmin = tenantContext.IsPlatformAdmin(); + + Assert.True(isPlatformAdmin); + } + + [Fact] + public async Task UseMetricSystemAsync_WhenNoCompanyContext_ReturnsFalse() + { + await using var context = CreateContext(); + var userManager = CreateUserManagerMock(); + var accessor = CreateHttpContextAccessor(CreatePrincipal(isAuthenticated: true, name: "nocompany@example.com")); + var tenantContext = new TenantContext(accessor.Object, userManager.Object, context); + + var useMetric = await tenantContext.UseMetricSystemAsync(); + + Assert.False(useMetric); + } + + [Fact] + public async Task GetCurrentCompanyAsync_WhenUnauthenticated_ReturnsNull() + { + await using var context = CreateContext(); + var userManager = CreateUserManagerMock(); + var accessor = CreateHttpContextAccessor(new ClaimsPrincipal(new ClaimsIdentity())); + var tenantContext = new TenantContext(accessor.Object, userManager.Object, context); + + var currentCompany = await tenantContext.GetCurrentCompanyAsync(); + + Assert.Null(currentCompany); + } + + private static Mock CreateHttpContextAccessor(ClaimsPrincipal principal, ISession? session = null) + { + var httpContext = new Mock(); + httpContext.SetupGet(x => x.User).Returns(principal); + httpContext.SetupGet(x => x.Session).Returns(session ?? new TestSession()); + + var accessor = new Mock(); + accessor.SetupGet(x => x.HttpContext).Returns(httpContext.Object); + return accessor; + } + + private static ClaimsPrincipal CreatePrincipal( + bool isAuthenticated, + string? name = null, + int? companyIdClaim = null, + string[]? roles = null) + { + if (!isAuthenticated) + { + return new ClaimsPrincipal(new ClaimsIdentity()); + } + + var claims = new List(); + if (!string.IsNullOrWhiteSpace(name)) + { + claims.Add(new Claim(ClaimTypes.Name, name)); + } + + if (companyIdClaim.HasValue) + { + claims.Add(new Claim("CompanyId", companyIdClaim.Value.ToString())); + } + + foreach (var role in roles ?? []) + { + claims.Add(new Claim(ClaimTypes.Role, role)); + } + + var identity = new ClaimsIdentity(claims, "TestAuth", ClaimTypes.Name, ClaimTypes.Role); + return new ClaimsPrincipal(identity); + } + + private static Mock> CreateUserManagerMock() + { + var store = new Mock>(); + return new Mock>( + store.Object, + null!, + null!, + null!, + null!, + null!, + null!, + null!, + null!); + } + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new ApplicationDbContext(options); + } + + private sealed class TestSession : ISession + { + private readonly Dictionary _values = new(); + + public IEnumerable Keys => _values.Keys; + public string Id => "test-session"; + public bool IsAvailable => true; + + public void Clear() => _values.Clear(); + + public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public void Remove(string key) => _values.Remove(key); + + public void Set(string key, byte[] value) => _values[key] = value; + + public bool TryGetValue(string key, out byte[] value) => _values.TryGetValue(key, out value!); + } +} diff --git a/tests/PowderCoating.UnitTests/UsageQuotaControllerTests.cs b/tests/PowderCoating.UnitTests/UsageQuotaControllerTests.cs new file mode 100644 index 0000000..65b2761 --- /dev/null +++ b/tests/PowderCoating.UnitTests/UsageQuotaControllerTests.cs @@ -0,0 +1,329 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; +using PowderCoating.Infrastructure.Data; +using PowderCoating.Web.Controllers; + +namespace PowderCoating.UnitTests; + +public class UsageQuotaControllerTests +{ + [Fact] + public async Task Index_UsesOverridesAndCountsOnlyActiveResources() + { + await using var context = CreateContext(); + SeedPlan(context, plan: 1, maxUsers: 10, maxJobs: 5, maxCustomers: 10, maxQuotes: 10, maxCatalogItems: 10); + SeedCompany(context, companyId: 1, plan: 1, maxUsersOverride: 5); + SeedLookupRows(context, companyId: 1); + + context.Users.AddRange( + CreateUser("u1", 1), + CreateUser("u2", 1), + CreateUser("u3", 1), + CreateUser("u4", 1)); + + context.Customers.AddRange( + new Customer { Id = 1, CompanyId = 1, CompanyName = "Cust 1" }, + new Customer { Id = 2, CompanyId = 1, CompanyName = "Cust 2" }); + + context.Jobs.AddRange( + new Job { Id = 1, CompanyId = 1, CustomerId = 1, Description = "Active", JobNumber = "JOB-1", JobStatusId = 10, JobPriorityId = 1 }, + new Job { Id = 2, CompanyId = 1, CustomerId = 1, Description = "Done", JobNumber = "JOB-2", JobStatusId = 11, JobPriorityId = 1 }); + + context.Quotes.AddRange( + new Quote { Id = 1, CompanyId = 1, QuoteNumber = "Q-1", QuoteStatusId = 10 }, + new Quote { Id = 2, CompanyId = 1, QuoteNumber = "Q-2", QuoteStatusId = 11 }, + new Quote { Id = 3, CompanyId = 1, QuoteNumber = "Q-3", QuoteStatusId = 12 }); + + context.CatalogItems.AddRange( + new CatalogItem { Id = 1, CompanyId = 1, Name = "Wheel", CategoryId = 1 }, + new CatalogItem { Id = 2, CompanyId = 1, Name = "Frame", CategoryId = 1 }); + + await context.SaveChangesAsync(); + + var controller = new UsageQuotaController(context); + + var result = await controller.Index(null, null, null, null); + + var view = Assert.IsType(result); + var row = Assert.Single(Assert.IsAssignableFrom>(view.Model)); + Assert.Equal(4, row.Users); + Assert.Equal(5, row.MaxUsers); + Assert.Equal(1, row.ActiveJobs); + Assert.Equal(1, row.ActiveQuotes); + Assert.Equal(2, row.Customers); + Assert.Equal(2, row.CatalogItems); + Assert.True(row.IsNearLimit); + Assert.False(row.IsAtLimit); + Assert.Equal(0, controller.ViewBag.AtLimitCount); + Assert.Equal(1, controller.ViewBag.NearLimitCount); + } + + [Fact] + public async Task Index_ForCompedCompany_ReturnsUnlimitedLimitsWithoutFlags() + { + await using var context = CreateContext(); + SeedPlan(context, plan: 2, maxUsers: 1, maxJobs: 1, maxCustomers: 1, maxQuotes: 1, maxCatalogItems: 1); + SeedCompany(context, companyId: 2, plan: 2, isComped: true); + SeedLookupRows(context, companyId: 2); + + context.Users.AddRange(CreateUser("u1", 2), CreateUser("u2", 2)); + context.Customers.Add(new Customer { Id = 10, CompanyId = 2, CompanyName = "Comped Customer" }); + context.Jobs.Add(new Job { Id = 10, CompanyId = 2, CustomerId = 10, Description = "Active", JobNumber = "JOB-C", JobStatusId = 20, JobPriorityId = 2 }); + context.Quotes.Add(new Quote { Id = 10, CompanyId = 2, QuoteNumber = "Q-C", QuoteStatusId = 20 }); + context.CatalogItems.Add(new CatalogItem { Id = 10, CompanyId = 2, Name = "Rack", CategoryId = 1 }); + await context.SaveChangesAsync(); + + var controller = new UsageQuotaController(context); + + var result = await controller.Index(null, null, null, null); + + var view = Assert.IsType(result); + var row = Assert.Single(Assert.IsAssignableFrom>(view.Model)); + Assert.True(row.IsComped); + Assert.Equal(-1, row.MaxUsers); + Assert.Equal(-1, row.MaxActiveJobs); + Assert.Equal(-1, row.MaxCustomers); + Assert.Equal(-1, row.MaxActiveQuotes); + Assert.Equal(-1, row.MaxCatalogItems); + Assert.False(row.IsNearLimit); + Assert.False(row.IsAtLimit); + } + + [Fact] + public async Task Index_ConcernFilters_SeparateNearAndAtLimitRows() + { + await using var context = CreateContext(); + SeedPlan(context, plan: 3, maxUsers: 5, maxJobs: 5, maxCustomers: 5, maxQuotes: 5, maxCatalogItems: 5); + SeedCompany(context, companyId: 3, plan: 3, companyName: "Near Co"); + SeedCompany(context, companyId: 4, plan: 3, companyName: "At Co"); + SeedCompany(context, companyId: 5, plan: 3, companyName: "Safe Co"); + SeedLookupRows(context, 3); + SeedLookupRows(context, 4); + SeedLookupRows(context, 5); + + context.Users.AddRange( + CreateUser("n1", 3), CreateUser("n2", 3), CreateUser("n3", 3), CreateUser("n4", 3), + CreateUser("a1", 4), CreateUser("a2", 4), CreateUser("a3", 4), CreateUser("a4", 4), CreateUser("a5", 4), + CreateUser("s1", 5)); + await context.SaveChangesAsync(); + + var controller = new UsageQuotaController(context); + + var limitResult = await controller.Index(null, null, null, "limit"); + var limitRows = Assert.IsAssignableFrom>(Assert.IsType(limitResult).Model); + Assert.Equal(2, limitRows.Count); + Assert.Contains(limitRows, r => r.CompanyName == "Near Co" && r.IsNearLimit); + Assert.Contains(limitRows, r => r.CompanyName == "At Co" && r.IsAtLimit); + + var atLimitResult = await controller.Index(null, null, null, "atlimit"); + var atLimitRows = Assert.IsAssignableFrom>(Assert.IsType(atLimitResult).Model); + var atLimitRow = Assert.Single(atLimitRows); + Assert.Equal("At Co", atLimitRow.CompanyName); + Assert.True(atLimitRow.IsAtLimit); + } + + [Fact] + public async Task Index_AppliesSearchStatusAndPlanFilters() + { + await using var context = CreateContext(); + SeedPlan(context, plan: 6, maxUsers: 10, maxJobs: 10, maxCustomers: 10, maxQuotes: 10, maxCatalogItems: 10, displayName: "Plan Six"); + SeedPlan(context, plan: 7, maxUsers: 10, maxJobs: 10, maxCustomers: 10, maxQuotes: 10, maxCatalogItems: 10, displayName: "Plan Seven"); + SeedCompany(context, companyId: 6, plan: 6, companyName: "Acme Powder", status: SubscriptionStatus.Active); + SeedCompany(context, companyId: 7, plan: 6, companyName: "Beta Powder", status: SubscriptionStatus.Expired); + SeedCompany(context, companyId: 8, plan: 7, companyName: "Acme East", status: SubscriptionStatus.Active); + await context.SaveChangesAsync(); + + var controller = new UsageQuotaController(context); + + var result = await controller.Index("Acme", nameof(SubscriptionStatus.Active), "6", null); + + var view = Assert.IsType(result); + var row = Assert.Single(Assert.IsAssignableFrom>(view.Model)); + Assert.Equal("Acme Powder", row.CompanyName); + Assert.Equal(nameof(SubscriptionStatus.Active), controller.ViewBag.StatusFilter); + Assert.Equal("6", controller.ViewBag.PlanFilter); + } + + [Fact] + public async Task Index_WhenUsageIsExactlyEightyPercent_MarksRowNearLimit() + { + await using var context = CreateContext(); + SeedPlan(context, plan: 8, maxUsers: 5, maxJobs: 10, maxCustomers: 10, maxQuotes: 10, maxCatalogItems: 10); + SeedCompany(context, companyId: 9, plan: 8, companyName: "Threshold Co"); + await context.SaveChangesAsync(); + + context.Users.AddRange( + CreateUser("t1", 9), + CreateUser("t2", 9), + CreateUser("t3", 9), + CreateUser("t4", 9)); + await context.SaveChangesAsync(); + + var controller = new UsageQuotaController(context); + + var result = await controller.Index(null, null, null, null); + + var view = Assert.IsType(result); + var row = Assert.Single(Assert.IsAssignableFrom>(view.Model)); + Assert.Equal(4, row.Users); + Assert.Equal(5, row.MaxUsers); + Assert.True(row.IsNearLimit); + Assert.False(row.IsAtLimit); + } + + [Fact] + public async Task Index_WhenFiltersAreInvalid_IgnoresThem() + { + await using var context = CreateContext(); + SeedPlan(context, plan: 9, maxUsers: 10, maxJobs: 10, maxCustomers: 10, maxQuotes: 10, maxCatalogItems: 10); + SeedPlan(context, plan: 10, maxUsers: 10, maxJobs: 10, maxCustomers: 10, maxQuotes: 10, maxCatalogItems: 10); + SeedCompany(context, companyId: 10, plan: 9, companyName: "Alpha Co", status: SubscriptionStatus.Active); + SeedCompany(context, companyId: 11, plan: 10, companyName: "Beta Co", status: SubscriptionStatus.Expired); + await context.SaveChangesAsync(); + + var controller = new UsageQuotaController(context); + + var result = await controller.Index(null, "NotARealStatus", "not-a-plan", null); + + var view = Assert.IsType(result); + var rows = Assert.IsAssignableFrom>(view.Model); + Assert.Equal(2, rows.Count); + Assert.Equal(2, controller.ViewBag.TotalCount); + Assert.Equal("NotARealStatus", controller.ViewBag.StatusFilter); + Assert.Equal("not-a-plan", controller.ViewBag.PlanFilter); + } + + private static ApplicationUser CreateUser(string id, int companyId) + { + return new ApplicationUser + { + Id = id, + CompanyId = companyId, + UserName = $"{id}@example.com", + Email = $"{id}@example.com", + FirstName = "Test", + LastName = "User" + }; + } + + private static void SeedPlan( + ApplicationDbContext context, + int plan, + int maxUsers, + int maxJobs, + int maxCustomers, + int maxQuotes, + int maxCatalogItems, + string? displayName = null) + { + context.SubscriptionPlanConfigs.Add(new SubscriptionPlanConfig + { + Id = plan, + CompanyId = 0, + Plan = plan, + DisplayName = displayName ?? $"Plan {plan}", + SortOrder = plan, + IsActive = true, + MaxUsers = maxUsers, + MaxActiveJobs = maxJobs, + MaxCustomers = maxCustomers, + MaxQuotes = maxQuotes, + MaxCatalogItems = maxCatalogItems + }); + } + + private static void SeedCompany( + ApplicationDbContext context, + int companyId, + int plan, + string? companyName = null, + SubscriptionStatus status = SubscriptionStatus.Active, + bool isComped = false, + int? maxUsersOverride = null) + { + context.Companies.Add(new Company + { + Id = companyId, + CompanyId = companyId, + CompanyName = companyName ?? $"Company {companyId}", + PrimaryContactName = "Owner", + PrimaryContactEmail = $"owner{companyId}@example.com", + SubscriptionPlan = plan, + SubscriptionStatus = status, + IsComped = isComped, + MaxUsersOverride = maxUsersOverride, + IsActive = true + }); + } + + private static void SeedLookupRows(ApplicationDbContext context, int companyId) + { + context.JobPriorityLookups.Add(new JobPriorityLookup + { + Id = companyId, + CompanyId = companyId, + PriorityCode = "NORMAL", + DisplayName = "Normal", + DisplayOrder = 1 + }); + + context.JobStatusLookups.AddRange( + new JobStatusLookup + { + Id = companyId * 10, + CompanyId = companyId, + StatusCode = "ACTIVE", + DisplayName = "Active", + DisplayOrder = 1, + IsTerminalStatus = false + }, + new JobStatusLookup + { + Id = companyId * 10 + 1, + CompanyId = companyId, + StatusCode = "DONE", + DisplayName = "Done", + DisplayOrder = 2, + IsTerminalStatus = true + }); + + context.QuoteStatusLookups.AddRange( + new QuoteStatusLookup + { + Id = companyId * 10, + CompanyId = companyId, + StatusCode = "PENDING", + DisplayName = "Pending", + DisplayOrder = 1 + }, + new QuoteStatusLookup + { + Id = companyId * 10 + 1, + CompanyId = companyId, + StatusCode = "REJECTED", + DisplayName = "Rejected", + DisplayOrder = 2, + IsRejectedStatus = true + }, + new QuoteStatusLookup + { + Id = companyId * 10 + 2, + CompanyId = companyId, + StatusCode = "CONVERTED", + DisplayName = "Converted", + DisplayOrder = 3, + IsConvertedStatus = true + }); + } + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new ApplicationDbContext(options); + } +}