using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Moq; using PowderCoating.Application.DTOs.Quote; using PowderCoating.Application.Interfaces; using PowderCoating.Application.Services; using PowderCoating.Core.Entities; using PowderCoating.Infrastructure.Data; using PowderCoating.Infrastructure.Repositories; namespace PowderCoating.UnitTests; public class QuotePricingAssemblyServiceTests { [Fact] public void ApplyPricingSnapshot_CopiesAllTotalsToQuote() { var service = CreateService(CreateContext(), Mock.Of()); var quote = new Quote(); var pricing = new QuotePricingResult { MaterialCosts = 10m, LaborCosts = 20m, EquipmentCosts = 30m, ItemsSubtotal = 40m, OvenBatchCost = 50m, ShopSuppliesAmount = 60m, ShopSuppliesPercent = 7m, OverheadCosts = 80m, OverheadPercent = 9m, ProfitMargin = 100m, ProfitPercent = 11m, SubtotalBeforeDiscount = 120m, DiscountPercent = 13m, DiscountAmount = 14m, RushFee = 15m, TaxAmount = 16m, Total = 17m }; service.ApplyPricingSnapshot(quote, pricing); Assert.Equal(10m, quote.MaterialCosts); Assert.Equal(20m, quote.LaborCosts); Assert.Equal(30m, quote.EquipmentCosts); Assert.Equal(40m, quote.ItemsSubtotal); Assert.Equal(50m, quote.OvenBatchCost); Assert.Equal(60m, quote.ShopSuppliesAmount); Assert.Equal(7m, quote.ShopSuppliesPercent); Assert.Equal(80m, quote.OverheadAmount); Assert.Equal(9m, quote.OverheadPercent); Assert.Equal(100m, quote.ProfitMargin); Assert.Equal(11m, quote.ProfitPercent); Assert.Equal(120m, quote.SubTotal); Assert.Equal(13m, quote.DiscountPercent); Assert.Equal(14m, quote.DiscountAmount); Assert.Equal(15m, quote.RushFee); Assert.Equal(16m, quote.TaxAmount); Assert.Equal(17m, quote.Total); } [Fact] public async Task CreateQuoteItemsAsync_PreservesManualAndCalculatedPricingPaths() { await using var context = CreateContext(); context.AiItemPredictions.Add(new AiItemPrediction { Id = 91, CompanyId = 1, PredictedSurfaceAreaSqFt = 4m, PredictedUnitPrice = 100m, PredictedMinutes = 15, PredictedComplexity = "Moderate", Confidence = "High" }); await context.SaveChangesAsync(); var pricingService = new Mock(); pricingService .Setup(x => x.CalculateQuoteItemPriceAsync( It.Is(i => i.Description == "Custom frame"), 1, null)) .ReturnsAsync(new QuoteItemPricingResult { UnitPrice = 77m, TotalPrice = 154m, MaterialCost = 22m, LaborCost = 33m, EquipmentCost = 11m }); pricingService .Setup(x => x.CalculateCoatPriceAsync( It.IsAny(), 12m, 2m, 0, 25, 1)) .ReturnsAsync(new QuoteItemCoatPricingResult { CoatMaterialCost = 5m, CoatLaborCost = 6m, CoatTotalCost = 11m }); var service = CreateService(context, pricingService.Object); var items = await service.CreateQuoteItemsAsync( [ new CreateQuoteItemDto { Description = "AI wheel", Quantity = 2m, SurfaceAreaSqFt = 5m, EstimatedMinutes = 20, IsAiItem = true, ManualUnitPrice = 123m, AiPredictionId = 91 }, new CreateQuoteItemDto { Description = "Shop tumbler", Quantity = 3m, IsSalesItem = true, Sku = "TMB-20", ManualUnitPrice = 18m, IncludePrepCost = false }, new CreateQuoteItemDto { Description = "Custom frame", Quantity = 2m, SurfaceAreaSqFt = 12m, EstimatedMinutes = 25, RequiresSandblasting = true, Notes = "Calculated path", Coats = [ new CreateQuoteItemCoatDto { CoatName = "Top Coat", Sequence = 1, ColorName = "Black", CoverageSqFtPerLb = 30m, TransferEfficiency = 65m } ], PrepServices = [ new CreateQuoteItemPrepServiceDto { PrepServiceId = 7, EstimatedMinutes = 15, BlastSetupId = 44 } ] } ], quoteId: 55, companyId: 1, ovenRateOverride: null, createdAtUtc: new DateTime(2026, 5, 9, 15, 0, 0, DateTimeKind.Utc)); Assert.Equal(3, items.Count); var aiItem = items.Single(i => i.Description == "AI wheel"); Assert.Equal(123m, aiItem.UnitPrice); Assert.Equal(246m, aiItem.TotalPrice); var salesItem = items.Single(i => i.Description == "Shop tumbler"); Assert.True(salesItem.IsSalesItem); Assert.Equal("TMB-20", salesItem.Sku); Assert.False(salesItem.IncludePrepCost); Assert.Equal(18m, salesItem.UnitPrice); Assert.Equal(54m, salesItem.TotalPrice); var customItem = items.Single(i => i.Description == "Custom frame"); Assert.Equal(77m, customItem.UnitPrice); Assert.Equal(154m, customItem.TotalPrice); Assert.Equal(22m, customItem.ItemMaterialCost); Assert.Equal(33m, customItem.ItemLaborCost); Assert.Equal(11m, customItem.ItemEquipmentCost); var customPrep = Assert.Single(customItem.PrepServices); Assert.Equal(44, customPrep.BlastSetupId); var customCoat = Assert.Single(customItem.Coats); Assert.Equal(11m, customCoat.CoatTotalCost); var prediction = await context.AiItemPredictions.SingleAsync(); Assert.True(prediction.UserOverrodeEstimate); } [Fact] public async Task CreateQuoteItemsAsync_CatalogItemWithoutCoats_UsesCatalogDefaultPrice() { await using var context = CreateContext(); context.CatalogItems.Add(new CatalogItem { Id = 22, CompanyId = 1, Name = "Gate Hinge", DefaultPrice = 42.5m }); await context.SaveChangesAsync(); var service = CreateService(context, Mock.Of()); var item = Assert.Single(await service.CreateQuoteItemsAsync( [ new CreateQuoteItemDto { Description = "Catalog hinge", Quantity = 4m, CatalogItemId = 22 } ], quoteId: 1, companyId: 1, ovenRateOverride: null, createdAtUtc: DateTime.UtcNow)); Assert.Equal(42.5m, item.UnitPrice); Assert.Equal(170m, item.TotalPrice); } [Fact] public async Task CreateQuoteItemsAsync_AddAsIncoming_CreatesInventoryItemAndLinksCoat() { await using var context = CreateContext(); context.Set().Add(new PowderCatalogItem { Id = 5, VendorName = "Prismatic Powders", Sku = "P-1001", ColorName = "Candy Red", UnitPrice = 19.5m, CoverageSqFtPerLb = 85m, TransferEfficiency = 70m }); await context.SaveChangesAsync(); var pricingService = new Mock(); pricingService .Setup(x => x.CalculateQuoteItemPriceAsync(It.IsAny(), 1, null)) .ReturnsAsync(new QuoteItemPricingResult { UnitPrice = 50m, TotalPrice = 50m, MaterialCost = 10m, LaborCost = 20m, EquipmentCost = 5m }); pricingService .Setup(x => x.CalculateCoatPriceAsync(It.IsAny(), 6m, 1m, 0, 10, 1)) .ReturnsAsync(new QuoteItemCoatPricingResult { CoatMaterialCost = 3m, CoatLaborCost = 4m, CoatTotalCost = 7m }); var service = CreateService(context, pricingService.Object); var dto = new CreateQuoteItemDto { Description = "Incoming powder item", Quantity = 1m, SurfaceAreaSqFt = 6m, EstimatedMinutes = 10, Coats = [ new CreateQuoteItemCoatDto { CoatName = "Base", Sequence = 1, CatalogItemId = 5, AddAsIncoming = true, PowderCostPerLb = 22m } ] }; var item = Assert.Single(await service.CreateQuoteItemsAsync( [dto], quoteId: 9, companyId: 1, ovenRateOverride: null, createdAtUtc: DateTime.UtcNow)); var inventoryItem = await context.InventoryItems.SingleAsync(); var coat = Assert.Single(item.Coats); Assert.Equal(inventoryItem.Id, coat.InventoryItemId); Assert.True(inventoryItem.IsIncoming); Assert.Null(dto.Coats[0].PowderCostPerLb); } private static QuotePricingAssemblyService CreateService(ApplicationDbContext context, IPricingCalculationService pricingService) { return new QuotePricingAssemblyService( new UnitOfWork(context), pricingService, Mock.Of(), Mock.Of>()); } private static ApplicationDbContext CreateContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; var identity = new ClaimsIdentity( [new Claim(ClaimTypes.Role, "SuperAdmin")], "Test"); var principal = new ClaimsPrincipal(identity); byte[]? noBytes = null; var sessionMock = new Mock(); sessionMock.Setup(s => s.TryGetValue(It.IsAny(), out noBytes)).Returns(false); var httpContextMock = new Mock(); httpContextMock.SetupGet(c => c.User).Returns(principal); httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object); var accessor = new Mock(); accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object); return new ApplicationDbContext(options, accessor.Object, null!); } }