Add unit tests for 9 new services/controllers and expand existing test coverage

116 tests passing: JobPhotoService, MeasurementConversionService, PlatformSettingsService,
QuoteApprovalController, QuotePhotoService, ShopCapabilityCalculator, StorageMigrationService,
TenantContext, UsageQuotaController — plus expanded PricingCalculation, Registration, and
Subscription tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 18:27:30 -04:00
parent edce8e8c4a
commit dbe4170986
13 changed files with 2930 additions and 12 deletions
@@ -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)
{
@@ -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<IAzureBlobStorageService>();
blobService
.Setup(x => x.UploadAsync("jobimages", It.IsAny<string>(), It.IsAny<Stream>(), "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<IAzureBlobStorageService>();
blobService
.Setup(x => x.UploadAsync("jobimages", It.IsAny<string>(), It.IsAny<Stream>(), "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<IAzureBlobStorageService>();
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<IAzureBlobStorageService>();
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<IAzureBlobStorageService>? blobService = null)
{
var settings = Options.Create(new StorageSettings
{
Containers = new StorageContainers
{
JobImages = "jobimages"
}
});
return new JobPhotoService(
(blobService ?? new Mock<IAzureBlobStorageService>()).Object,
settings,
Mock.Of<ILogger<JobPhotoService>>());
}
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);
}
}
@@ -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));
}
}
@@ -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<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
}
@@ -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<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
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<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(true);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
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<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
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<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
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<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
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<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var item = new CreateQuoteItemDto
{
Description = "Bracket with prep",
CatalogItemId = 51,
Quantity = 2m,
IncludePrepCost = true,
PrepServices = new List<CreateQuoteItemPrepServiceDto>
{
new() { PrepServiceId = 1, EstimatedMinutes = 30 }
},
Coats = new List<CreateQuoteItemCoatDto>
{
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<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var item = new CreateQuoteItemDto
{
Description = "Complex fabricated part",
Quantity = 1m,
SurfaceAreaSqFt = 10m,
EstimatedMinutes = 60,
Complexity = "Moderate",
Coats = new List<CreateQuoteItemCoatDto>
{
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<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var items = new List<CreateQuoteItemDto>
{
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<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var items = new List<CreateQuoteItemDto>
{
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<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var items = new List<CreateQuoteItemDto>
{
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<IUnitOfWork> CreateUnitOfWorkMock(CompanyOperatingCosts costs)
private static Mock<IUnitOfWork> CreateUnitOfWorkMock(
CompanyOperatingCosts? costs,
InventoryItem? inventoryItem = null,
CatalogItem? catalogItem = null)
{
var unitOfWork = new Mock<IUnitOfWork>();
var companyOperatingCostsRepo = new Mock<IRepository<CompanyOperatingCosts>>();
companyOperatingCostsRepo
.Setup(x => x.FindAsync(It.IsAny<System.Linq.Expressions.Expression<Func<CompanyOperatingCosts, bool>>>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<CompanyOperatingCosts, object>>[]>()))
.ReturnsAsync(new[] { costs });
.ReturnsAsync(costs != null ? new[] { costs } : Array.Empty<CompanyOperatingCosts>());
var inventoryRepo = new Mock<IRepository<InventoryItem>>();
inventoryRepo
.Setup(x => x.GetByIdAsync(It.IsAny<int>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<InventoryItem, object>>[]>()))
.ReturnsAsync((InventoryItem?)null);
.ReturnsAsync(inventoryItem);
var catalogRepo = new Mock<IRepository<CatalogItem>>();
catalogRepo
.Setup(x => x.GetByIdAsync(It.IsAny<int>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<CatalogItem, object>>[]>()))
.ReturnsAsync((CatalogItem?)null);
.ReturnsAsync(catalogItem);
var customerRepo = new Mock<IRepository<Customer>>();
customerRepo
@@ -0,0 +1,449 @@
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Web.Controllers;
using PowderCoating.Web.Hubs;
using PowderCoating.Web.ViewModels;
namespace PowderCoating.UnitTests;
public class QuoteApprovalControllerTests
{
[Fact]
public async Task ShowApprovalPage_WhenTokenExpired_ReturnsTokenExpiredView()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: 10, token: "expired-token", expiresAt: DateTime.UtcNow.AddMinutes(-1)));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.ShowApprovalPage("expired-token");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("TokenExpired", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.Equal("expired-token", model.Token);
}
[Fact]
public async Task ShowApprovalPage_WhenQuoteAlreadyInTerminalStatus_ReturnsAlreadyActedView()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: 10, token: "approved-token", statusId: 2, declineReason: "Old decline"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.ShowApprovalPage("approved-token");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("AlreadyActed", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.Equal("Approved", model.CurrentStatus);
Assert.Equal("Old decline", model.DeclineReason);
}
[Fact]
public async Task Approve_WhenQuoteIsProspect_ReturnsConfirmDetailsView()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: null, token: "prospect-token"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.Approve("prospect-token");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("ConfirmDetails", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.True(model.IsProspect);
}
[Fact]
public async Task SubmitDetails_WhenRequiredFieldsMissing_ReturnsConfirmDetailsWithError()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: null, token: "missing-details"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.SubmitDetails(
"missing-details",
contactName: " ",
email: " prospect@example.com ",
phone: null,
companyName: " Prospect Co ",
address: " 123 Main ",
city: " Akron ",
state: " OH ",
zipCode: " 44301 ");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("ConfirmDetails", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.Equal("Please enter your name and at least one contact method (email or phone).", model.DeclineError);
Assert.Equal(" prospect@example.com ", model.ProspectEmail);
Assert.Equal(" Prospect Co ", model.ProspectCompanyName);
}
[Fact]
public async Task SubmitDetails_WhenValidProspect_ApprovesQuoteAndTrimsFields()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: null, token: "prospect-approve"));
await context.SaveChangesAsync();
var notifications = new Mock<INotificationService>();
notifications
.Setup(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny<Quote>(), true, null))
.Returns(Task.CompletedTask);
var inApp = new Mock<IInAppNotificationService>();
inApp.Setup(x => x.CreateAsync(1, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<int?>()))
.Returns(Task.CompletedTask);
var clientProxy = new Mock<IClientProxy>();
clientProxy
.Setup(x => x.SendCoreAsync(It.IsAny<string>(), It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var controller = CreateController(
context,
notifications: notifications,
inApp: inApp,
clientProxy: clientProxy);
var result = await controller.SubmitDetails(
"prospect-approve",
contactName: " Pat Prospect ",
email: " prospect@example.com ",
phone: " 555-0100 ",
companyName: " Prospect Co ",
address: " 123 Main ",
city: " Akron ",
state: " OH ",
zipCode: " 44301 ");
var redirect = Assert.IsType<RedirectResult>(result);
Assert.Equal("/quote-approval/prospect-approve/confirmation?action=approved", redirect.Url);
var quote = await context.Quotes.IgnoreQueryFilters().SingleAsync();
Assert.Equal(2, quote.QuoteStatusId);
Assert.Equal("Pat Prospect", quote.ProspectContactName);
Assert.Equal("prospect@example.com", quote.ProspectEmail);
Assert.Equal("555-0100", quote.ProspectPhone);
Assert.Equal("Prospect Co", quote.ProspectCompanyName);
Assert.NotNull(quote.ApprovalTokenUsedAt);
Assert.Single(await context.QuoteChangeHistories.IgnoreQueryFilters().ToListAsync());
notifications.Verify(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny<Quote>(), true, null), Times.Once);
inApp.Verify(x => x.CreateAsync(1, "Quote Approved", It.IsAny<string>(), "QuoteApproved", "/Quotes/Details/1", 1, null, null), Times.Once);
clientProxy.Verify(x => x.SendCoreAsync("QuoteActedByCustomer", It.IsAny<object[]>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Approve_WhenCustomerQuoteRequiresDeposit_GeneratesDepositLinkAndClearsPriorDecline()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1, stripeStatus: StripeConnectStatus.Active);
context.Customers.Add(new Customer
{
Id = 10,
CompanyId = 1,
CompanyName = "Acme Customer"
});
context.Quotes.Add(CreateQuote(
1,
customerId: 10,
token: "deposit-token",
requiresDeposit: true,
depositPercent: 50m,
declineReason: "Need more time"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.Approve("deposit-token");
var redirect = Assert.IsType<RedirectResult>(result);
Assert.Equal("/quote-approval/deposit-token/confirmation?action=approved", redirect.Url);
var quote = await context.Quotes.IgnoreQueryFilters().SingleAsync();
Assert.Equal(2, quote.QuoteStatusId);
Assert.Null(quote.DeclineReason);
Assert.NotNull(quote.DepositPaymentLinkToken);
Assert.True(quote.DepositPaymentLinkExpiresAt > DateTime.UtcNow.AddDays(6));
var history = await context.QuoteChangeHistories.IgnoreQueryFilters().SingleAsync();
Assert.Contains("previously declined", history.ChangeDescription);
}
[Fact]
public async Task Approve_WhenTokenAlreadyUsed_ReturnsAlreadyActedView()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(
1,
customerId: 10,
token: "used-token",
approvalUsedAt: DateTime.UtcNow.AddMinutes(-5)));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.Approve("used-token");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("AlreadyActed", view.ViewName);
}
[Fact]
public async Task Decline_WhenReasonBlank_ReturnsApprovalPageWithError()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: 10, token: "blank-decline"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.Decline("blank-decline", " ");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("ApprovalPage", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.Equal("Please enter a reason for declining.", model.DeclineError);
}
[Fact]
public async Task Decline_UsesRejectedStatusCodeFallbackAndTruncatesStoredReason()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1, useRejectedFlag: false);
context.Quotes.Add(CreateQuote(1, customerId: 10, token: "decline-token"));
await context.SaveChangesAsync();
var notifications = new Mock<INotificationService>();
notifications
.Setup(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny<Quote>(), false, It.IsAny<string>()))
.Returns(Task.CompletedTask);
var reason = $" {new string('x', 1005)} ";
var controller = CreateController(
context,
notifications: notifications,
remoteIpAddress: IPAddress.Parse("203.0.113.9"));
var result = await controller.Decline("decline-token", reason);
var redirect = Assert.IsType<RedirectResult>(result);
Assert.Equal("/quote-approval/decline-token/confirmation?action=declined", redirect.Url);
var quote = await context.Quotes.IgnoreQueryFilters().SingleAsync();
Assert.Equal(3, quote.QuoteStatusId);
Assert.Equal(1000, quote.DeclineReason!.Length);
Assert.Equal("203.0.113.9", quote.DeclinedByIp);
Assert.NotNull(quote.ApprovalTokenUsedAt);
Assert.Single(await context.QuoteChangeHistories.IgnoreQueryFilters().ToListAsync());
notifications.Verify(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny<Quote>(), false, It.IsAny<string>()), Times.Once);
}
[Fact]
public async Task Confirmation_HidesExpiredDepositLink()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(
1,
customerId: 10,
token: "confirm-token",
requiresDeposit: true,
depositPercent: 25m,
depositLinkToken: "expired-link",
depositLinkExpiresAt: DateTime.UtcNow.AddMinutes(-2),
total: 120m));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.Confirmation("confirm-token", "APPROVED");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("Confirmation", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.Null(model.DepositPaymentLinkToken);
Assert.Equal(30m, model.DepositAmount);
Assert.Equal("approved", controller.ViewBag.Action);
}
private static QuoteApprovalController CreateController(
ApplicationDbContext context,
Mock<INotificationService>? notifications = null,
Mock<IInAppNotificationService>? inApp = null,
Mock<IClientProxy>? clientProxy = null,
IPAddress? remoteIpAddress = null)
{
notifications ??= new Mock<INotificationService>();
notifications.Setup(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny<Quote>(), It.IsAny<bool>(), It.IsAny<string?>()))
.Returns(Task.CompletedTask);
inApp ??= new Mock<IInAppNotificationService>();
inApp.Setup(x => x.CreateAsync(It.IsAny<int>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<int?>()))
.Returns(Task.CompletedTask);
clientProxy ??= new Mock<IClientProxy>();
clientProxy.Setup(x => x.SendCoreAsync(It.IsAny<string>(), It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var hubClients = new Mock<IHubClients>();
hubClients.Setup(x => x.Group(It.IsAny<string>())).Returns(clientProxy.Object);
var hubContext = new Mock<IHubContext<NotificationHub>>();
hubContext.SetupGet(x => x.Clients).Returns(hubClients.Object);
var controller = new QuoteApprovalController(
context,
notifications.Object,
inApp.Object,
Mock.Of<IStripeConnectService>(),
Mock.Of<ILogger<QuoteApprovalController>>(),
new ConfigurationBuilder().Build(),
hubContext.Object);
var httpContext = new DefaultHttpContext();
if (remoteIpAddress != null)
{
httpContext.Connection.RemoteIpAddress = remoteIpAddress;
}
controller.ControllerContext = new ControllerContext
{
HttpContext = httpContext
};
return controller;
}
private static void SeedCompanyAndStatuses(
ApplicationDbContext context,
int companyId,
StripeConnectStatus stripeStatus = StripeConnectStatus.NotConnected,
bool useRejectedFlag = true)
{
context.Companies.Add(new Company
{
Id = companyId,
CompanyId = companyId,
CompanyName = $"Company {companyId}",
Phone = "555-0100",
PrimaryContactName = "Owner",
PrimaryContactEmail = $"owner{companyId}@example.com",
StripeConnectStatus = stripeStatus
});
context.CompanyPreferences.Add(new CompanyPreferences
{
Id = companyId,
CompanyId = companyId,
EmailFromAddress = $"quotes{companyId}@example.com"
});
context.QuoteStatusLookups.AddRange(
new QuoteStatusLookup
{
Id = 1,
CompanyId = companyId,
StatusCode = "PENDING",
DisplayName = "Pending",
DisplayOrder = 1
},
new QuoteStatusLookup
{
Id = 2,
CompanyId = companyId,
StatusCode = "APPROVED",
DisplayName = "Approved",
DisplayOrder = 2,
IsApprovedStatus = true
},
new QuoteStatusLookup
{
Id = 3,
CompanyId = companyId,
StatusCode = "REJECTED",
DisplayName = "Rejected",
DisplayOrder = 3,
IsRejectedStatus = useRejectedFlag
},
new QuoteStatusLookup
{
Id = 4,
CompanyId = companyId,
StatusCode = "CONVERTED",
DisplayName = "Converted",
DisplayOrder = 4,
IsConvertedStatus = true
});
}
private static Quote CreateQuote(
int id,
int? customerId,
string token,
int statusId = 1,
DateTime? expiresAt = null,
DateTime? approvalUsedAt = null,
bool requiresDeposit = false,
decimal depositPercent = 0m,
string? declineReason = null,
string? depositLinkToken = null,
DateTime? depositLinkExpiresAt = null,
decimal total = 100m)
{
return new Quote
{
Id = id,
CompanyId = 1,
QuoteNumber = $"Q-{id:000}",
CustomerId = customerId,
QuoteStatusId = statusId,
ApprovalToken = token,
ApprovalTokenExpiresAt = expiresAt ?? DateTime.UtcNow.AddDays(2),
ApprovalTokenUsedAt = approvalUsedAt,
RequiresDeposit = requiresDeposit,
DepositPercent = depositPercent,
DeclineReason = declineReason,
DepositPaymentLinkToken = depositLinkToken,
DepositPaymentLinkExpiresAt = depositLinkExpiresAt,
Total = total,
SubTotal = total,
QuoteItems = []
};
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
}
@@ -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<IAzureBlobStorageService>();
blobService
.Setup(x => x.UploadAsync("quoteimages", It.IsAny<string>(), It.IsAny<Stream>(), "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<IAzureBlobStorageService>();
blobService
.Setup(x => x.UploadAsync("quoteimages", It.IsAny<string>(), It.IsAny<Stream>(), "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<IAzureBlobStorageService>();
blobService
.Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/"))
.ReturnsAsync(Array.Empty<string>());
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<IAzureBlobStorageService>();
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<byte>(), 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<IAzureBlobStorageService>();
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<string>(), It.IsAny<Stream>(), "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<IAzureBlobStorageService>();
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<string>(), It.IsAny<Stream>(), "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<IAzureBlobStorageService>();
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<byte>(), 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<IAzureBlobStorageService>();
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<IAzureBlobStorageService>? blobService = null)
{
var settings = Options.Create(new StorageSettings
{
Containers = new StorageContainers
{
QuoteImages = "quoteimages"
}
});
return new QuotePhotoService(
(blobService ?? new Mock<IAzureBlobStorageService>()).Object,
settings,
Mock.Of<ILogger<QuotePhotoService>>());
}
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);
}
}
@@ -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<ViewResult>(result);
Assert.Equal("Index", view.ViewName);
Assert.False(controller.ModelState.IsValid);
Assert.Contains(controller.ModelState["Email"]!.Errors, e => e.ErrorMessage.Contains("already exists"));
}
[Fact]
public async Task Create_WhenRegistrationIsClosed_ReturnsIndexWithTempDataError()
{
await using var context = CreateContext();
SeedPlanConfig(context, plan: 1);
context.Companies.Add(new Company
{
Id = 1,
CompanyId = 1,
CompanyName = "Existing Company",
CompanyCode = "EXC",
PrimaryContactName = "Owner",
PrimaryContactEmail = "existing@example.com",
IsActive = true
});
await context.SaveChangesAsync();
var platformSettings = new Mock<IPlatformSettingsService>();
platformSettings
.Setup(x => x.GetAsync(PlatformSettingKeys.MaxTenants))
.ReturnsAsync("1");
platformSettings
.Setup(x => x.GetAsync(It.Is<string>(key => key != PlatformSettingKeys.MaxTenants)))
.ReturnsAsync((string?)null);
var controller = CreateController(context, platformSettings: platformSettings);
var model = new RegisterCompanyDto
{
CompanyName = "New Co",
CompanyPhone = "555-0100",
FirstName = "Pat",
LastName = "Owner",
Email = "owner@example.com",
Plan = 1
};
var result = await controller.Create(model);
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("Index", view.ViewName);
Assert.Equal("Registration is currently closed. Please contact us for more information.", controller.TempData["Error"]);
Assert.False((bool)controller.ViewBag.RegistrationOpen);
}
[Fact]
public async Task Create_WhenTrialsDisabledAndStripeCheckoutStarts_RedirectsAndPersistsPendingSession()
{
await using var context = CreateContext();
SeedPlanConfig(context, plan: 1);
await context.SaveChangesAsync();
var platformSettings = new Mock<IPlatformSettingsService>();
platformSettings.Setup(x => x.GetAsync(PlatformSettingKeys.TrialsEnabled)).ReturnsAsync("false");
platformSettings.Setup(x => x.GetAsync(It.Is<string>(key => key != PlatformSettingKeys.TrialsEnabled))).ReturnsAsync((string?)null);
var stripeService = new Mock<IStripeService>();
stripeService
.Setup(x => x.CreateRegistrationCheckoutSessionAsync(
1, false, "paid@example.com", "Paid Co", It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync("https://checkout.example/session");
var controller = CreateController(
context,
stripeService: stripeService,
platformSettings: platformSettings);
var model = new RegisterCompanyDto
{
CompanyName = "Paid Co",
CompanyPhone = "555-0100",
FirstName = "Pat",
LastName = "Owner",
Email = "paid@example.com",
Plan = 1
};
var result = await controller.Create(model);
var redirect = Assert.IsType<RedirectResult>(result);
Assert.Equal("https://checkout.example/session", redirect.Url);
var pending = await context.PendingRegistrationSessions.SingleAsync();
Assert.Equal("Paid Co", pending.CompanyName);
Assert.Equal("paid@example.com", pending.Email);
Assert.False(pending.IsCompleted);
}
[Fact]
public async Task Create_WhenStripeConfigFails_DoesNotPersistPendingSession()
{
await using var context = CreateContext();
SeedPlanConfig(context, plan: 1);
await context.SaveChangesAsync();
var platformSettings = new Mock<IPlatformSettingsService>();
platformSettings.Setup(x => x.GetAsync(PlatformSettingKeys.TrialsEnabled)).ReturnsAsync("false");
platformSettings.Setup(x => x.GetAsync(It.Is<string>(key => key != PlatformSettingKeys.TrialsEnabled))).ReturnsAsync((string?)null);
var stripeService = new Mock<IStripeService>();
stripeService
.Setup(x => x.CreateRegistrationCheckoutSessionAsync(
It.IsAny<int>(), It.IsAny<bool>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ThrowsAsync(new InvalidOperationException("Stripe prices are not configured."));
var controller = CreateController(
context,
stripeService: stripeService,
platformSettings: platformSettings);
var model = new RegisterCompanyDto
{
CompanyName = "Paid Co",
CompanyPhone = "555-0100",
FirstName = "Pat",
LastName = "Owner",
Email = "paid@example.com",
Plan = 1
};
var result = await controller.Create(model);
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("Index", view.ViewName);
Assert.Empty(context.PendingRegistrationSessions);
Assert.Contains(controller.ModelState[string.Empty]!.Errors, e => e.ErrorMessage.Contains("Stripe prices are not configured."));
}
[Fact]
public async Task PaymentSuccess_WhenRegistrationClosedAfterPayment_ReleasesPendingSessionWithoutCreatingCompany()
{
await using var context = CreateContext();
SeedPlanConfig(context, plan: 1);
context.Companies.Add(new Company
{
Id = 1,
CompanyId = 1,
CompanyName = "At Capacity",
CompanyCode = "CAP",
PrimaryContactName = "Owner",
PrimaryContactEmail = "capacity@example.com",
IsActive = true
});
context.PendingRegistrationSessions.Add(CreatePendingSession("token-closed", "closed@example.com"));
await context.SaveChangesAsync();
var platformSettings = new Mock<IPlatformSettingsService>();
platformSettings
.Setup(x => x.GetAsync(PlatformSettingKeys.MaxTenants))
.ReturnsAsync("1");
platformSettings
.Setup(x => x.GetAsync(It.Is<string>(key => key != PlatformSettingKeys.MaxTenants)))
.ReturnsAsync((string?)null);
var userManager = CreateUserManagerMock();
userManager.Setup(x => x.FindByEmailAsync("closed@example.com")).ReturnsAsync((ApplicationUser?)null);
var stripeService = new Mock<IStripeService>();
stripeService.Setup(x => x.IsRegistrationCheckoutPaidAsync("sess_paid")).ReturnsAsync(true);
var controller = CreateController(
context,
userManager: userManager,
stripeService: stripeService,
platformSettings: platformSettings);
var result = await controller.PaymentSuccess("sess_paid", "token-closed");
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
Assert.Equal("Registration is currently closed. Your payment has been received but no account was created. Please contact support.", controller.TempData["Error"]);
Assert.False((await context.PendingRegistrationSessions.SingleAsync()).IsCompleted);
Assert.Single(context.Companies);
}
[Fact]
public async Task PaymentSuccess_WhenSessionIdMissing_RedirectsToIndex()
{
await using var context = CreateContext();
var controller = CreateController(context);
var result = await controller.PaymentSuccess(null, "token");
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
}
[Fact]
public async Task PaymentSuccess_WhenRegistrationTokenMissing_SetsExpiredError()
{
await using var context = CreateContext();
var controller = CreateController(context);
var result = await controller.PaymentSuccess("sess_123", null);
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
Assert.Equal("Your registration session has expired. Please fill in your details again.", controller.TempData["Error"]);
}
[Fact]
public async Task PaymentSuccess_WhenPendingSessionMissing_SetsNotFoundError()
{
await using var context = CreateContext();
var controller = CreateController(context);
var result = await controller.PaymentSuccess("sess_123", "missing-token");
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
Assert.Equal("Your registration session was not found. Please fill in your details again.", controller.TempData["Error"]);
}
[Fact]
public async Task PaymentSuccess_WhenSessionAlreadyCompletedButUserMissing_ShowsSupportError()
{
await using var context = CreateContext();
context.PendingRegistrationSessions.Add(CreatePendingSession("token-missing-user", "retry@example.com", isCompleted: true));
await context.SaveChangesAsync();
var userManager = CreateUserManagerMock();
userManager.Setup(x => x.FindByEmailAsync("retry@example.com")).ReturnsAsync((ApplicationUser?)null);
var controller = CreateController(context, userManager: userManager);
var result = await controller.PaymentSuccess("sess_done", "token-missing-user");
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
Assert.Contains("couldn't finish signing you in", controller.TempData["Error"]?.ToString());
}
[Fact]
public async Task PaymentCancelled_WhenPendingSessionExists_PrefillsTempDataAndDeletesSession()
{
await using var context = CreateContext();
context.PendingRegistrationSessions.Add(CreatePendingSession("token-cancel", "cancel@example.com"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.PaymentCancelled("token-cancel");
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
var json = Assert.IsType<string>(controller.TempData["PendingRegistrationJson"]);
var model = JsonSerializer.Deserialize<RegisterCompanyDto>(json);
Assert.NotNull(model);
Assert.Equal("Retry Co", model!.CompanyName);
Assert.Equal("cancel@example.com", model.Email);
Assert.Empty(context.PendingRegistrationSessions);
}
private static RegistrationController CreateController(
ApplicationDbContext context,
Mock<UserManager<ApplicationUser>>? userManager = null,
SignInManager<ApplicationUser>? signInManager = null,
Mock<IStripeService>? stripeService = null)
Mock<IStripeService>? stripeService = null,
Mock<IPlatformSettingsService>? platformSettings = null)
{
var unitOfWork = new UnitOfWork(context);
var userManagerMock = userManager ?? CreateUserManagerMock();
var signInManagerInstance = signInManager ?? CreateSignInManagerMock(userManagerMock.Object).Object;
var platformSettings = new Mock<IPlatformSettingsService>();
platformSettings.Setup(x => x.GetAsync(It.IsAny<string>())).ReturnsAsync((string?)null);
var platformSettingsMock = platformSettings ?? new Mock<IPlatformSettingsService>();
if (platformSettings is null)
{
platformSettingsMock.Setup(x => x.GetAsync(It.IsAny<string>())).ReturnsAsync((string?)null);
}
var controller = new RegistrationController(
unitOfWork,
@@ -116,7 +418,7 @@ public class RegistrationControllerTests
Mock.Of<ISeedDataService>(),
Mock.Of<IAdminNotificationService>(),
Mock.Of<IInAppNotificationService>(),
platformSettings.Object,
platformSettingsMock.Object,
(stripeService ?? new Mock<IStripeService>()).Object,
Mock.Of<IEmailService>(),
Mock.Of<ILogger<RegistrationController>>());
@@ -126,6 +428,11 @@ public class RegistrationControllerTests
{
HttpContext = httpContext
};
var urlHelper = new Mock<IUrlHelper>();
urlHelper
.Setup(x => x.Action(It.IsAny<UrlActionContext>()))
.Returns<UrlActionContext>(ctx => $"https://example.test/{ctx.Action}");
controller.Url = urlHelper.Object;
controller.TempData = new TempDataDictionary(httpContext, Mock.Of<ITempDataProvider>());
return controller;
@@ -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
@@ -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);
}
}
@@ -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<IAzureBlobStorageService>();
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<IAzureBlobStorageService>();
blobService.Setup(x => x.ExistsAsync("jobimages", relativePath)).ReturnsAsync(false);
blobService
.Setup(x => x.UploadAsync("jobimages", relativePath, It.IsAny<Stream>(), "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<IAzureBlobStorageService>();
blobService.Setup(x => x.ExistsAsync("companylogos", relativePath)).ReturnsAsync(false);
blobService
.Setup(x => x.UploadAsync("companylogos", relativePath, It.IsAny<Stream>(), "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<IAzureBlobStorageService>();
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<Stream>(), "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<IAzureBlobStorageService>? 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<IAzureBlobStorageService>()).Object,
settings,
Mock.Of<ILogger<StorageMigrationService>>());
}
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);
}
}
}
@@ -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<ApplicationDbContext>()
@@ -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
@@ -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<ApplicationUser>().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<ApplicationUser>().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<IHttpContextAccessor> CreateHttpContextAccessor(ClaimsPrincipal principal, ISession? session = null)
{
var httpContext = new Mock<HttpContext>();
httpContext.SetupGet(x => x.User).Returns(principal);
httpContext.SetupGet(x => x.Session).Returns(session ?? new TestSession());
var accessor = new Mock<IHttpContextAccessor>();
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<Claim>();
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<UserManager<ApplicationUser>> CreateUserManagerMock()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object,
null!,
null!,
null!,
null!,
null!,
null!,
null!,
null!);
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private sealed class TestSession : ISession
{
private readonly Dictionary<string, byte[]> _values = new();
public IEnumerable<string> 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!);
}
}
@@ -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<ViewResult>(result);
var row = Assert.Single(Assert.IsAssignableFrom<List<UsageRow>>(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<ViewResult>(result);
var row = Assert.Single(Assert.IsAssignableFrom<List<UsageRow>>(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<List<UsageRow>>(Assert.IsType<ViewResult>(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<List<UsageRow>>(Assert.IsType<ViewResult>(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<ViewResult>(result);
var row = Assert.Single(Assert.IsAssignableFrom<List<UsageRow>>(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<ViewResult>(result);
var row = Assert.Single(Assert.IsAssignableFrom<List<UsageRow>>(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<ViewResult>(result);
var rows = Assert.IsAssignableFrom<List<UsageRow>>(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<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
}