using System.Security.Claims; using System.Text.Json; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Logging; using Moq; using PowderCoating.Application.DTOs.Invoice; using PowderCoating.Application.DTOs.Quote; using PowderCoating.Application.Interfaces; using PowderCoating.Application.Services; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using PowderCoating.Infrastructure.Data; using PowderCoating.Infrastructure.Repositories; using PowderCoating.Web.Controllers; namespace PowderCoating.UnitTests; /// /// Verifies that quantities, prices, overrides, and charges move correctly through all three /// pricing stages: Quote → Job → Invoice. Each test targets one transition or cross-cutting concern. /// public class PricingStageFlowTests { // ─── Stage 1: QuotePricingAssemblyService.ApplyPricingSnapshot ─────────────── [Fact] public void ApplyPricingSnapshot_StoresAllNewBreakdownFields() { // FacilityOverheadCost, FacilityOverheadRatePerHour, PricingTierDiscount, QuoteDiscount, // and SubtotalAfterDiscount were added in a recent migration. Verify they are all stored. var service = CreateAssemblyService(CreateContext()); var quote = new Quote(); var pricing = new QuotePricingResult { FacilityOverheadCost = 12.50m, FacilityOverheadRatePerHour = 25m, PricingTierDiscountAmount = 5m, PricingTierDiscountPercent = 2m, QuoteDiscountAmount = 10m, QuoteDiscountPercent = 4m, DiscountAmount = 15m, DiscountPercent = 6m, SubtotalAfterDiscount = 235m, RushFee = 20m, TaxAmount = 23.5m, Total = 278.50m, SubtotalBeforeDiscount = 250m, ItemsSubtotal = 200m, OvenBatchCost = 18m, ShopSuppliesAmount = 8m, ShopSuppliesPercent = 4m }; service.ApplyPricingSnapshot(quote, pricing); Assert.Equal(12.50m, quote.FacilityOverheadCost, precision: 2); Assert.Equal(25m, quote.FacilityOverheadRatePerHour, precision: 2); Assert.Equal(5m, quote.PricingTierDiscountAmount, precision: 2); Assert.Equal(2m, quote.PricingTierDiscountPercent, precision: 2); Assert.Equal(10m, quote.QuoteDiscountAmount, precision: 2); Assert.Equal(4m, quote.QuoteDiscountPercent, precision: 2); Assert.Equal(15m, quote.DiscountAmount, precision: 2); Assert.Equal(6m, quote.DiscountPercent, precision: 2); Assert.Equal(235m, quote.SubtotalAfterDiscount, precision: 2); Assert.Equal(20m, quote.RushFee, precision: 2); Assert.Equal(23.5m, quote.TaxAmount, precision: 2); Assert.Equal(278.50m, quote.Total, precision: 2); } // ─── Stage 2: Quote → Job (QuotesController.UpdateQuoteStatus) ──────────────── [Fact] public async Task QuoteToJob_PricingSnapshotCarriesAllCharges() { // Verifies that OvenBatchCost, FacilityOverheadCost, ShopSuppliesAmount, RushFee, // and all discount fields from the approved quote land in Job.PricingBreakdownJson. await using var context = CreateContext(); SeedQuoteWithFullPricing(context); await context.SaveChangesAsync(); var controller = CreateQuotesController(context); var approvedStatusId = context.QuoteStatusLookups.Single(s => s.StatusCode == "APPROVED").Id; var result = await controller.UpdateQuoteStatus(new UpdateQuoteStatusRequest { QuoteId = 1, StatusId = approvedStatusId }); Assert.IsType(result); var job = await context.Jobs.SingleAsync(); Assert.NotNull(job.PricingBreakdownJson); var breakdown = JsonSerializer.Deserialize(job.PricingBreakdownJson!); Assert.NotNull(breakdown); Assert.Equal(150m, breakdown.ItemsSubtotal, precision: 2); Assert.Equal(18m, breakdown.OvenBatchCost, precision: 2); Assert.Equal(12m, breakdown.FacilityOverheadCost, precision: 2); Assert.Equal(6m, breakdown.ShopSuppliesAmount, precision: 2); Assert.Equal(25m, breakdown.RushFee, precision: 2); Assert.Equal(15m, breakdown.DiscountAmount, precision: 2); Assert.Equal(211m, breakdown.Total, precision: 2); } [Fact] public async Task QuoteToJob_ItemPricesAndOverridesTransfer() { // Verifies that UnitPrice, TotalPrice, ManualUnitPrice, PowderCostOverride, // CatalogItemId, and Notes all survive the quote→job item conversion. await using var context = CreateContext(); SeedQuoteWithFullPricing(context); await context.SaveChangesAsync(); var controller = CreateQuotesController(context); var approvedStatusId = context.QuoteStatusLookups.Single(s => s.StatusCode == "APPROVED").Id; await controller.UpdateQuoteStatus(new UpdateQuoteStatusRequest { QuoteId = 1, StatusId = approvedStatusId }); var jobItem = await context.JobItems.SingleAsync(); Assert.Equal(75m, jobItem.UnitPrice, precision: 2); Assert.Equal(150m, jobItem.TotalPrice, precision: 2); Assert.Equal(69m, jobItem.ManualUnitPrice); Assert.Equal(8.50m, jobItem.PowderCostOverride); Assert.Equal(99, jobItem.CatalogItemId); Assert.Equal("Handle carefully — thin walls", jobItem.Notes); } [Fact] public async Task QuoteToJob_CoatInventoryIdAndPowderToOrderTransfer() { // InventoryItemId on coats gates the powder charging logic in PricingCalculationService. // PowderToOrder is the purchase quantity — both must survive quote→job conversion. await using var context = CreateContext(); SeedQuoteWithFullPricing(context); await context.SaveChangesAsync(); var controller = CreateQuotesController(context); var approvedStatusId = context.QuoteStatusLookups.Single(s => s.StatusCode == "APPROVED").Id; await controller.UpdateQuoteStatus(new UpdateQuoteStatusRequest { QuoteId = 1, StatusId = approvedStatusId }); var coat = await context.JobItemCoats.SingleAsync(); Assert.Equal(50, coat.InventoryItemId); Assert.Equal(2.0m, coat.PowderToOrder); Assert.Equal(4.50m, coat.PowderCostPerLb); } // ─── Stage 3: Job → Invoice (InvoicesController.Create GET with jobId) ────────── [Fact] public async Task JobToInvoice_ItemFieldsPopulateCorrectly() { // Notes and CatalogItemId on JobItem must reach InvoiceItem. await using var context = CreateContext(); SeedJobForInvoicing(context, hasSourceQuote: false); await context.SaveChangesAsync(); var controller = CreateInvoicesController(context); var result = await controller.Create(jobId: 1) as ViewResult; Assert.NotNull(result); var dto = Assert.IsType(result.Model); var item = dto.InvoiceItems.First(i => i.SourceJobItemId.HasValue); Assert.Equal(3m, item.Quantity); Assert.Equal(45m, item.UnitPrice, precision: 2); Assert.Equal(135m, item.TotalPrice, precision: 2); Assert.Equal("Gloss Black", item.ColorName); Assert.Equal(99, item.CatalogItemId); Assert.Equal("Watch corners — mask before blasting", item.Notes); } [Fact] public async Task JobToInvoice_DirectJob_AddsOvenShopSuppliesRushFeeLines() { // A job created directly (no source quote) must invoice all three processing charges // separately, reading RushFee and FacilityOverheadCost from PricingBreakdownJson. await using var context = CreateContext(); SeedJobForInvoicing(context, hasSourceQuote: false); await context.SaveChangesAsync(); var controller = CreateInvoicesController(context); var result = await controller.Create(jobId: 1) as ViewResult; Assert.NotNull(result); var dto = Assert.IsType(result.Model); var descriptions = dto.InvoiceItems.Select(i => i.Description).ToList(); Assert.Contains("Oven Processing Fee", descriptions); Assert.Contains("Facility Overhead", descriptions); Assert.Contains("Shop Supplies (4%)", descriptions); Assert.Contains("Rush Fee", descriptions); var oven = dto.InvoiceItems.Single(i => i.Description == "Oven Processing Fee"); var overhead = dto.InvoiceItems.Single(i => i.Description == "Facility Overhead"); var shop = dto.InvoiceItems.Single(i => i.Description == "Shop Supplies (4%)"); var rush = dto.InvoiceItems.Single(i => i.Description == "Rush Fee"); Assert.Equal(18m, oven.TotalPrice, precision: 2); Assert.Equal(12m, overhead.TotalPrice, precision: 2); Assert.Equal(6m, shop.TotalPrice, precision: 2); Assert.Equal(25m, rush.TotalPrice, precision: 2); } [Fact] public async Task JobToInvoice_FromQuote_BundlesAllProcessingFeesIncludingFacilityOverhead() { // When a job came from a quote, all processing charges must be bundled as one line, // including FacilityOverheadCost which was previously missing. await using var context = CreateContext(); SeedJobForInvoicing(context, hasSourceQuote: true); await context.SaveChangesAsync(); var controller = CreateInvoicesController(context); var result = await controller.Create(jobId: 1) as ViewResult; Assert.NotNull(result); var dto = Assert.IsType(result.Model); var processingLine = dto.InvoiceItems.SingleOrDefault(i => i.Description == "Oven & Shop Processing Fees"); Assert.NotNull(processingLine); // OvenBatchCost(18) + FacilityOverheadCost(12) + ShopSuppliesAmount(6) + RushFee(25) = 61 Assert.Equal(61m, processingLine!.TotalPrice, precision: 2); } [Fact] public async Task JobToInvoice_TaxAndDiscountFromQuoteNotRecomputed() { // Invoice must carry the agreed quote TaxPercent and DiscountAmount, // not re-derive from current company defaults. await using var context = CreateContext(); SeedJobForInvoicing(context, hasSourceQuote: true); await context.SaveChangesAsync(); var controller = CreateInvoicesController(context); var result = await controller.Create(jobId: 1) as ViewResult; Assert.NotNull(result); var dto = Assert.IsType(result.Model); Assert.Equal(8.5m, dto.TaxPercent, precision: 2); Assert.Equal(15m, dto.DiscountAmount, precision: 2); } // ─── JobItemAssemblyService: Notes field ────────────────────────────────────── [Fact] public void CreateJobItem_FromDto_PreservesNotes() { var svc = new JobItemAssemblyService(); var dto = new CreateQuoteItemDto { Description = "Part", Notes = "Fragile — no drop" }; var pricing = new QuoteItemPricingResult { UnitPrice = 10m, TotalPrice = 10m }; var item = svc.CreateJobItem(dto, jobId: 1, companyId: 1, pricing, DateTime.UtcNow); Assert.Equal("Fragile — no drop", item.Notes); } [Fact] public void CreateJobItem_FromQuoteItem_PreservesNotes() { var svc = new JobItemAssemblyService(); var quoteItem = new QuoteItem { Description = "Part", Notes = "Do not sandblast" }; var item = svc.CreateJobItem(quoteItem, jobId: 1, companyId: 1, DateTime.UtcNow); Assert.Equal("Do not sandblast", item.Notes); } [Fact] public void CreateJobItem_FromJobItem_PreservesNotes() { var svc = new JobItemAssemblyService(); var source = new JobItem { Description = "Part", Notes = "Carry-over note", LaborCost = 0m }; var item = svc.CreateJobItem(source, jobId: 2, companyId: 1, DateTime.UtcNow); Assert.Equal("Carry-over note", item.Notes); } // ─── LaborCost: must come from pricing engine, not a hardcoded multiplier ───── [Fact] public void CreateJobItem_FromDto_UsesLaborCostFromPricingResult() { var svc = new JobItemAssemblyService(); var dto = new CreateQuoteItemDto { Description = "Rail" }; var pricing = new QuoteItemPricingResult { UnitPrice = 100m, TotalPrice = 200m, LaborCost = 55m }; var item = svc.CreateJobItem(dto, jobId: 1, companyId: 1, pricing, DateTime.UtcNow); Assert.Equal(55m, item.LaborCost, precision: 2); } [Fact] public void CreateJobItem_FromQuoteItem_UsesStoredItemLaborCost() { var svc = new JobItemAssemblyService(); var quoteItem = new QuoteItem { Description = "Rail", UnitPrice = 100m, TotalPrice = 200m, ItemLaborCost = 62m }; var item = svc.CreateJobItem(quoteItem, jobId: 1, companyId: 1, DateTime.UtcNow); Assert.Equal(62m, item.LaborCost, precision: 2); } // ─── Seed helpers ───────────────────────────────────────────────────────────── private static void SeedQuoteWithFullPricing(ApplicationDbContext context) { context.Customers.Add(new Customer { Id = 1, CompanyId = 1, CompanyName = "Test Co" }); context.InventoryItems.Add(new InventoryItem { Id = 50, CompanyId = 1, SKU = "BLK-1", Name = "Gloss Black", ColorCode = "RAL9005", Finish = "Gloss", Category = "Powder", UnitOfMeasure = "lbs" }); context.QuoteStatusLookups.AddRange( new QuoteStatusLookup { Id = 1, CompanyId = 1, StatusCode = "DRAFT", DisplayName = "Draft" }, new QuoteStatusLookup { Id = 2, CompanyId = 1, StatusCode = "APPROVED", DisplayName = "Approved" }, new QuoteStatusLookup { Id = 3, CompanyId = 1, StatusCode = "CONVERTED", DisplayName = "Converted" }); context.JobStatusLookups.Add(new JobStatusLookup { Id = 10, CompanyId = 1, StatusCode = "APPROVED", DisplayName = "Approved" }); context.JobPriorityLookups.AddRange( new JobPriorityLookup { Id = 20, CompanyId = 1, PriorityCode = "NORMAL", DisplayName = "Normal" }, new JobPriorityLookup { Id = 21, CompanyId = 1, PriorityCode = "RUSH", DisplayName = "Rush" }); context.PrepServices.Add(new PrepService { Id = 5, CompanyId = 1, ServiceName = "Sandblast", DisplayOrder = 1, IsActive = true }); context.Quotes.Add(new Quote { Id = 1, CompanyId = 1, QuoteNumber = "Q-2601-0001", CustomerId = 1, QuoteStatusId = 1, IsRushJob = true, ItemsSubtotal = 150m, OvenBatchCost = 18m, FacilityOverheadCost = 12m, ShopSuppliesAmount = 6m, ShopSuppliesPercent = 4m, RushFee = 25m, DiscountAmount = 15m, DiscountPercent = 6m, SubtotalAfterDiscount = 196m, TaxPercent = 8.5m, TaxAmount = 16.66m, Total = 211m }); context.QuoteItems.Add(new QuoteItem { Id = 100, QuoteId = 1, CompanyId = 1, Description = "Powder coat rail", Quantity = 2m, SurfaceAreaSqFt = 20m, CatalogItemId = 99, IsSalesItem = false, ManualUnitPrice = 69m, PowderCostOverride = 8.50m, UnitPrice = 75m, TotalPrice = 150m, ItemLaborCost = 40m, Notes = "Handle carefully — thin walls", IncludePrepCost = true, EstimatedMinutes = 30 }); context.QuoteItemCoats.Add(new QuoteItemCoat { Id = 101, QuoteItemId = 100, CompanyId = 1, CoatName = "Base Coat", Sequence = 1, InventoryItemId = 50, ColorName = "Old Name", CoverageSqFtPerLb = 30m, TransferEfficiency = 65m, PowderCostPerLb = 4.50m, PowderToOrder = 2.0m }); context.QuoteItemPrepServices.Add(new QuoteItemPrepService { Id = 102, QuoteItemId = 100, CompanyId = 1, PrepServiceId = 5, EstimatedMinutes = 10 }); } private static void SeedJobForInvoicing(ApplicationDbContext context, bool hasSourceQuote) { context.Customers.Add(new Customer { Id = 1, CompanyId = 1, CompanyName = "Test Co" }); context.JobStatusLookups.Add(new JobStatusLookup { Id = 1, CompanyId = 1, StatusCode = "COMPLETED", DisplayName = "Completed" }); context.JobPriorityLookups.Add(new JobPriorityLookup { Id = 1, CompanyId = 1, PriorityCode = "NORMAL", DisplayName = "Normal" }); // Serialized breakdown carrying FacilityOverheadCost and RushFee var breakdown = new QuotePricingBreakdownDto { ItemsSubtotal = 135m, OvenBatchCost = 18m, FacilityOverheadCost = 12m, ShopSuppliesAmount = 6m, ShopSuppliesPercent = 4m, RushFee = 25m, TaxPercent = 8.5m, Total = 211m }; Quote? quote = null; if (hasSourceQuote) { quote = new Quote { Id = 1, CompanyId = 1, QuoteNumber = "Q-TEST", CustomerId = 1, QuoteStatusId = 1, OvenBatchCost = 18m, FacilityOverheadCost = 12m, ShopSuppliesAmount = 6m, ShopSuppliesPercent = 4m, RushFee = 25m, DiscountAmount = 15m, TaxPercent = 8.5m, Total = 211m }; context.QuoteStatusLookups.Add(new QuoteStatusLookup { Id = 1, CompanyId = 1, StatusCode = "CONVERTED", DisplayName = "Converted" }); context.Quotes.Add(quote); } context.Jobs.Add(new Job { Id = 1, CompanyId = 1, JobNumber = "JOB-TEST", CustomerId = 1, Description = "Test job", JobStatusId = 1, JobPriorityId = 1, QuoteId = hasSourceQuote ? 1 : null, OvenBatchCost = 18m, ShopSuppliesAmount = 6m, ShopSuppliesPercent = 4m, IsRushJob = true, FinalPrice = 211m, PricingBreakdownJson = JsonSerializer.Serialize(breakdown) }); context.JobItems.Add(new JobItem { Id = 10, JobId = 1, CompanyId = 1, Description = "Powder coat wheel", Quantity = 3m, UnitPrice = 45m, TotalPrice = 135m, ColorName = "Gloss Black", CatalogItemId = 99, Notes = "Watch corners — mask before blasting", EstimatedMinutes = 20, LaborCost = 30m }); } // ─── Controller / service factory helpers ──────────────────────────────────── private static QuotePricingAssemblyService CreateAssemblyService(ApplicationDbContext context) => new(new UnitOfWork(context), Mock.Of(), Mock.Of(), Mock.Of>()); private static QuotesController CreateQuotesController(ApplicationDbContext context) { var lookupCache = new Mock(); lookupCache.Setup(x => x.GetQuoteStatusLookupsAsync(It.IsAny())) .ReturnsAsync(() => context.QuoteStatusLookups.ToList()); return new QuotesController( new UnitOfWork(context), Mock.Of(), Mock.Of(), CreateUserManager().Object, Mock.Of>(), Mock.Of(), CreateTenantContext().Object, Mock.Of(), lookupCache.Object, Mock.Of(), Mock.Of(), new JobItemAssemblyService(), Mock.Of(), new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of()); } private static InvoicesController CreateInvoicesController(ApplicationDbContext context) { var controller = new InvoicesController( new UnitOfWork(context), Mock.Of(), CreateUserManager().Object, Mock.Of>(), Mock.Of(), CreateTenantContext().Object, Mock.Of(), Mock.Of(), Mock.Of()); var identity = new ClaimsIdentity([new Claim(ClaimTypes.Role, "SuperAdmin")], "Test"); var principal = new ClaimsPrincipal(identity); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = principal } }; return controller; } private static Mock> CreateUserManager() { var store = new Mock>(); var mgr = new Mock>( store.Object, null!, null!, null!, null!, null!, null!, null!, null!); mgr.Setup(m => m.GetUserAsync(It.IsAny())) .ReturnsAsync(new ApplicationUser { Id = "user-1", CompanyId = 1, UserName = "testuser", Email = "test@test.com" }); return mgr; } private static Mock CreateTenantContext() { var tc = new Mock(); tc.Setup(x => x.GetCurrentCompanyId()).Returns(1); tc.Setup(x => x.IsSuperAdmin()).Returns(true); tc.Setup(x => x.IsPlatformAdmin()).Returns(true); return tc; } private static ApplicationDbContext CreateContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .ConfigureWarnings(x => x.Ignore(InMemoryEventId.TransactionIgnoredWarning)) .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!); } }