Fix pricing consistency across Quote → Job → Invoice; add stage-flow tests
- Store complete PricingBreakdownJson snapshot on Job at every save point so the Details page reads stored data rather than re-running the pricing engine - Add 7 missing fields to Quote entity (FacilityOverheadCost, tier/quote discounts, SubtotalAfterDiscount) and persist them via ApplyPricingSnapshot - Fix OvenCostId-as-rate bug in JobsController (FK was passed as decimal $/hr) - Fix hardcoded LaborCost * 0.4 multiplier in two JobItemAssemblyService overloads - Fix FacilityOverheadCost dropped from invoices in both quote and direct-job paths - Fix RushFee missing from direct-job invoices (read from PricingBreakdownJson) - Fix Notes and CatalogItemId not copied to InvoiceItem - Add 14 unit tests in PricingStageFlowTests covering all three pricing stages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,576 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<JsonResult>(result);
|
||||
|
||||
var job = await context.Jobs.SingleAsync();
|
||||
Assert.NotNull(job.PricingBreakdownJson);
|
||||
|
||||
var breakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(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<CreateInvoiceDto>(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<CreateInvoiceDto>(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<CreateInvoiceDto>(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<CreateInvoiceDto>(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<IPricingCalculationService>(),
|
||||
Mock.Of<IInventoryAiLookupService>(),
|
||||
Mock.Of<ILogger<QuotePricingAssemblyService>>());
|
||||
|
||||
private static QuotesController CreateQuotesController(ApplicationDbContext context)
|
||||
{
|
||||
var lookupCache = new Mock<ILookupCacheService>();
|
||||
lookupCache.Setup(x => x.GetQuoteStatusLookupsAsync(It.IsAny<int>()))
|
||||
.ReturnsAsync(() => context.QuoteStatusLookups.ToList());
|
||||
|
||||
return new QuotesController(
|
||||
new UnitOfWork(context),
|
||||
Mock.Of<AutoMapper.IMapper>(),
|
||||
Mock.Of<IPricingCalculationService>(),
|
||||
CreateUserManager().Object,
|
||||
Mock.Of<ILogger<QuotesController>>(),
|
||||
Mock.Of<IPdfService>(),
|
||||
CreateTenantContext().Object,
|
||||
Mock.Of<IMeasurementConversionService>(),
|
||||
lookupCache.Object,
|
||||
Mock.Of<INotificationService>(),
|
||||
Mock.Of<ISubscriptionService>(),
|
||||
new JobItemAssemblyService(),
|
||||
Mock.Of<IQuotePricingAssemblyService>(),
|
||||
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
|
||||
Mock.Of<IPlatformSettingsService>(),
|
||||
Mock.Of<IQuotePhotoService>(),
|
||||
Mock.Of<IAiQuoteService>(),
|
||||
Mock.Of<IWebHostEnvironment>(),
|
||||
Mock.Of<IJobPhotoService>(),
|
||||
Mock.Of<IAiUsageLogger>(),
|
||||
Mock.Of<ICompanyLogoService>(),
|
||||
Mock.Of<IInventoryAiLookupService>());
|
||||
}
|
||||
|
||||
private static InvoicesController CreateInvoicesController(ApplicationDbContext context)
|
||||
{
|
||||
var controller = new InvoicesController(
|
||||
new UnitOfWork(context),
|
||||
Mock.Of<AutoMapper.IMapper>(),
|
||||
CreateUserManager().Object,
|
||||
Mock.Of<ILogger<InvoicesController>>(),
|
||||
Mock.Of<IPdfService>(),
|
||||
CreateTenantContext().Object,
|
||||
Mock.Of<INotificationService>(),
|
||||
Mock.Of<IAccountBalanceService>(),
|
||||
Mock.Of<ICompanyLogoService>());
|
||||
|
||||
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<UserManager<ApplicationUser>> CreateUserManager()
|
||||
{
|
||||
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||
var mgr = new Mock<UserManager<ApplicationUser>>(
|
||||
store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
|
||||
mgr.Setup(m => m.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>()))
|
||||
.ReturnsAsync(new ApplicationUser
|
||||
{
|
||||
Id = "user-1",
|
||||
CompanyId = 1,
|
||||
UserName = "testuser",
|
||||
Email = "test@test.com"
|
||||
});
|
||||
return mgr;
|
||||
}
|
||||
|
||||
private static Mock<ITenantContext> CreateTenantContext()
|
||||
{
|
||||
var tc = new Mock<ITenantContext>();
|
||||
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<ApplicationDbContext>()
|
||||
.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<ISession>();
|
||||
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
|
||||
|
||||
var httpContextMock = new Mock<HttpContext>();
|
||||
httpContextMock.SetupGet(c => c.User).Returns(principal);
|
||||
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
|
||||
|
||||
var accessor = new Mock<IHttpContextAccessor>();
|
||||
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
|
||||
|
||||
return new ApplicationDbContext(options, accessor.Object, null!);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user