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