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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user