edd7389d7d
- IJobItemAssemblyService / IQuotePricingAssemblyService: centralize job item and quote pricing construction that was duplicated across create, rework copy, and quote-to-job conversion paths - BlobFileHelper: single ValidateUpload/GetContentType/SanitizeFileName used by 6 blob services (JobPhoto, QuotePhoto, ProfilePhoto, CompanyLogo, Equipment, Catalog) and BillsController + ExpensesController, removing 8 private copies - PagedResult<T>.From(): static factory eliminates 6-line boilerplate in 11 controllers (Appointments, Customers, Equipment, Inventory, Invoices, Jobs, Maintenance, CompanyUsers, PlatformUsers, Quotes, Vendors) - AccountingDropdownHelper: single LoadAsync() call replaces duplicate vendor/account/job queries in BillsController and ExpensesController - JobTemplateItem: add IsSalesItem + Sku fields with migration; propagate through JobTemplatesController snapshot copy and GetTemplatesJson projection, and JobsController template-application path - Test assertions updated for standardized BlobFileHelper error messages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
408 lines
13 KiB
C#
408 lines
13 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Logging;
|
|
using Moq;
|
|
using PowderCoating.Application.DTOs.Job;
|
|
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;
|
|
using PowderCoating.Web.Hubs;
|
|
|
|
namespace PowderCoating.UnitTests;
|
|
|
|
public class QuoteAndReworkControllerFlowTests
|
|
{
|
|
[Fact]
|
|
public async Task UpdateQuoteStatus_ApprovedQuote_CopiesItemLevelFieldsIntoCreatedJob()
|
|
{
|
|
await using var context = CreateContext();
|
|
SeedQuoteConversionData(context);
|
|
await context.SaveChangesAsync();
|
|
|
|
var quoteStatuses = await context.QuoteStatusLookups.OrderBy(s => s.Id).ToListAsync();
|
|
var lookupCache = new Mock<ILookupCacheService>();
|
|
lookupCache
|
|
.Setup(x => x.GetQuoteStatusLookupsAsync(1))
|
|
.ReturnsAsync(quoteStatuses);
|
|
var tenantContext = CreateTenantContext();
|
|
|
|
var controller = new QuotesController(
|
|
new UnitOfWork(context),
|
|
Mock.Of<AutoMapper.IMapper>(),
|
|
Mock.Of<IPricingCalculationService>(),
|
|
CreateUserManager().Object,
|
|
Mock.Of<ILogger<QuotesController>>(),
|
|
Mock.Of<IPdfService>(),
|
|
tenantContext.Object,
|
|
Mock.Of<IMeasurementConversionService>(),
|
|
lookupCache.Object,
|
|
Mock.Of<INotificationService>(),
|
|
Mock.Of<ISubscriptionService>(),
|
|
new JobItemAssemblyService(),
|
|
Mock.Of<IQuotePricingAssemblyService>(),
|
|
new 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>());
|
|
|
|
var result = await controller.UpdateQuoteStatus(new UpdateQuoteStatusRequest
|
|
{
|
|
QuoteId = 1,
|
|
StatusId = 2
|
|
});
|
|
|
|
Assert.IsType<JsonResult>(result);
|
|
|
|
var quote = await context.Quotes.SingleAsync();
|
|
Assert.Equal(3, quote.QuoteStatusId);
|
|
Assert.True(quote.ConvertedToJobId.HasValue);
|
|
|
|
var job = await context.Jobs.SingleAsync();
|
|
Assert.Equal(quote.Id, job.QuoteId);
|
|
|
|
var jobItem = await context.JobItems.SingleAsync();
|
|
Assert.True(jobItem.IsSalesItem);
|
|
Assert.Equal("MUG-01", jobItem.Sku);
|
|
Assert.False(jobItem.IncludePrepCost);
|
|
|
|
var jobCoat = await context.JobItemCoats.SingleAsync();
|
|
Assert.Equal(1.5m, jobCoat.PowderToOrder);
|
|
Assert.Equal(50, jobCoat.InventoryItemId);
|
|
|
|
var jobItemPrep = await context.JobItemPrepServices.SingleAsync();
|
|
Assert.Equal(77, jobItemPrep.BlastSetupId);
|
|
|
|
var jobPrep = await context.JobPrepServices.SingleAsync();
|
|
Assert.Equal(5, jobPrep.PrepServiceId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddReworkRecord_CopiesFullItemShapeToReworkJob()
|
|
{
|
|
await using var context = CreateContext();
|
|
SeedReworkData(context);
|
|
await context.SaveChangesAsync();
|
|
|
|
var lookupCache = new Mock<ILookupCacheService>();
|
|
lookupCache
|
|
.Setup(x => x.GetJobStatusLookupsAsync(1))
|
|
.ReturnsAsync(await context.JobStatusLookups.ToListAsync());
|
|
lookupCache
|
|
.Setup(x => x.GetJobPriorityLookupsAsync(1))
|
|
.ReturnsAsync(await context.JobPriorityLookups.ToListAsync());
|
|
var tenantContext = CreateTenantContext();
|
|
|
|
var mapper = new Mock<AutoMapper.IMapper>();
|
|
mapper
|
|
.Setup(x => x.Map<ReworkRecordDto>(It.IsAny<object>()))
|
|
.Returns<object>(source =>
|
|
{
|
|
var record = Assert.IsType<ReworkRecord>(source);
|
|
return new ReworkRecordDto
|
|
{
|
|
Id = record.Id,
|
|
JobId = record.JobId,
|
|
JobItemId = record.JobItemId,
|
|
ReworkJobId = record.ReworkJobId
|
|
};
|
|
});
|
|
|
|
var controller = new JobsController(
|
|
new UnitOfWork(context),
|
|
mapper.Object,
|
|
Mock.Of<IJobPhotoService>(),
|
|
CreateUserManager().Object,
|
|
Mock.Of<ILogger<JobsController>>(),
|
|
tenantContext.Object,
|
|
Mock.Of<IMeasurementConversionService>(),
|
|
lookupCache.Object,
|
|
Mock.Of<INotificationService>(),
|
|
Mock.Of<ISubscriptionService>(),
|
|
Mock.Of<IPricingCalculationService>(),
|
|
new JobItemAssemblyService(),
|
|
Mock.Of<IHubContext<NotificationHub>>(),
|
|
Mock.Of<IHubContext<ShopHub>>());
|
|
|
|
var result = await controller.AddReworkRecord(new CreateReworkRecordDto
|
|
{
|
|
JobId = 1,
|
|
JobItemId = 10,
|
|
ReworkType = ReworkType.InternalDefect,
|
|
Reason = ReworkReason.InsufficientCoverage,
|
|
DefectDescription = "Thin coverage on one edge",
|
|
DiscoveredBy = ReworkDiscoveredBy.Internal,
|
|
DiscoveredDate = new DateTime(2026, 5, 9),
|
|
EstimatedReworkCost = 65m
|
|
});
|
|
|
|
Assert.IsType<JsonResult>(result);
|
|
|
|
var reworkJob = await context.Jobs.SingleAsync(j => j.IsReworkJob);
|
|
Assert.Equal(1, reworkJob.OriginalJobId);
|
|
|
|
var reworkItem = await context.JobItems.SingleAsync(i => i.JobId == reworkJob.Id);
|
|
Assert.True(reworkItem.IsSalesItem);
|
|
Assert.Equal("GATE-BRZ", reworkItem.Sku);
|
|
Assert.False(reworkItem.IncludePrepCost);
|
|
Assert.Equal(140m, reworkItem.UnitPrice);
|
|
Assert.Equal(140m, reworkItem.TotalPrice);
|
|
|
|
var reworkCoat = await context.JobItemCoats.SingleAsync(c => c.JobItemId == reworkItem.Id);
|
|
Assert.Equal(2.75m, reworkCoat.PowderToOrder);
|
|
|
|
var reworkPrep = await context.JobItemPrepServices.SingleAsync(p => p.JobItemId == reworkItem.Id);
|
|
Assert.Equal(88, reworkPrep.BlastSetupId);
|
|
}
|
|
|
|
private static void SeedQuoteConversionData(ApplicationDbContext context)
|
|
{
|
|
context.Customers.Add(new Customer
|
|
{
|
|
Id = 1,
|
|
CompanyId = 1,
|
|
CompanyName = "Acme Fabrication"
|
|
});
|
|
|
|
context.InventoryItems.Add(new InventoryItem
|
|
{
|
|
Id = 50,
|
|
CompanyId = 1,
|
|
SKU = "POW-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 = "Sandblasting",
|
|
DisplayOrder = 1,
|
|
IsActive = true,
|
|
RequiresBlastSetup = true
|
|
});
|
|
|
|
context.Quotes.Add(new Quote
|
|
{
|
|
Id = 1,
|
|
CompanyId = 1,
|
|
QuoteNumber = "Q-1001",
|
|
CustomerId = 1,
|
|
QuoteStatusId = 1,
|
|
Total = 50m,
|
|
ShopSuppliesAmount = 2m,
|
|
ShopSuppliesPercent = 4m
|
|
});
|
|
|
|
context.QuoteItems.Add(new QuoteItem
|
|
{
|
|
Id = 100,
|
|
QuoteId = 1,
|
|
CompanyId = 1,
|
|
Description = "Merch mug",
|
|
Quantity = 2m,
|
|
SurfaceAreaSqFt = 10m,
|
|
IsSalesItem = true,
|
|
Sku = "MUG-01",
|
|
UnitPrice = 25m,
|
|
TotalPrice = 50m,
|
|
IncludePrepCost = false,
|
|
EstimatedMinutes = 12
|
|
});
|
|
|
|
context.QuoteItemCoats.Add(new QuoteItemCoat
|
|
{
|
|
Id = 101,
|
|
QuoteItemId = 100,
|
|
CompanyId = 1,
|
|
CoatName = "Top Coat",
|
|
Sequence = 1,
|
|
InventoryItemId = 50,
|
|
ColorName = "Old Name",
|
|
ColorCode = "OLD",
|
|
Finish = "Old",
|
|
CoverageSqFtPerLb = 30m,
|
|
TransferEfficiency = 65m,
|
|
PowderToOrder = 1.5m
|
|
});
|
|
|
|
context.QuoteItemPrepServices.Add(new QuoteItemPrepService
|
|
{
|
|
Id = 102,
|
|
QuoteItemId = 100,
|
|
CompanyId = 1,
|
|
PrepServiceId = 5,
|
|
EstimatedMinutes = 9,
|
|
BlastSetupId = 77
|
|
});
|
|
}
|
|
|
|
private static void SeedReworkData(ApplicationDbContext context)
|
|
{
|
|
context.Customers.Add(new Customer
|
|
{
|
|
Id = 1,
|
|
CompanyId = 1,
|
|
CompanyName = "Acme Fabrication"
|
|
});
|
|
|
|
context.JobStatusLookups.Add(new JobStatusLookup
|
|
{
|
|
Id = 1,
|
|
CompanyId = 1,
|
|
StatusCode = "PENDING",
|
|
DisplayName = "Pending"
|
|
});
|
|
|
|
context.JobPriorityLookups.Add(new JobPriorityLookup
|
|
{
|
|
Id = 2,
|
|
CompanyId = 1,
|
|
PriorityCode = "NORMAL",
|
|
DisplayName = "Normal"
|
|
});
|
|
|
|
context.Jobs.Add(new Job
|
|
{
|
|
Id = 1,
|
|
CompanyId = 1,
|
|
JobNumber = "JOB-2605-0001",
|
|
Description = "Original gate job",
|
|
CustomerId = 1,
|
|
JobStatusId = 1,
|
|
JobPriorityId = 2
|
|
});
|
|
|
|
context.JobItems.Add(new JobItem
|
|
{
|
|
Id = 10,
|
|
JobId = 1,
|
|
CompanyId = 1,
|
|
Description = "Gate panel",
|
|
Quantity = 1m,
|
|
SurfaceArea = 22m,
|
|
SurfaceAreaSqFt = 22m,
|
|
CatalogItemId = 8,
|
|
IsSalesItem = true,
|
|
Sku = "GATE-BRZ",
|
|
UnitPrice = 140m,
|
|
TotalPrice = 140m,
|
|
LaborCost = 56m,
|
|
IncludePrepCost = false,
|
|
EstimatedMinutes = 90,
|
|
ColorName = "Bronze",
|
|
ColorCode = "BZ-22",
|
|
Finish = "Textured"
|
|
});
|
|
|
|
context.JobItemCoats.Add(new JobItemCoat
|
|
{
|
|
Id = 11,
|
|
JobItemId = 10,
|
|
CompanyId = 1,
|
|
CoatName = "Top Coat",
|
|
Sequence = 1,
|
|
CoverageSqFtPerLb = 26m,
|
|
TransferEfficiency = 70m,
|
|
PowderToOrder = 2.75m
|
|
});
|
|
|
|
context.JobItemPrepServices.Add(new JobItemPrepService
|
|
{
|
|
Id = 12,
|
|
JobItemId = 10,
|
|
CompanyId = 1,
|
|
PrepServiceId = 3,
|
|
EstimatedMinutes = 45,
|
|
BlastSetupId = 88
|
|
});
|
|
}
|
|
|
|
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
|
|
{
|
|
var store = new Mock<IUserStore<ApplicationUser>>();
|
|
return new Mock<UserManager<ApplicationUser>>(
|
|
store.Object,
|
|
null!,
|
|
null!,
|
|
null!,
|
|
null!,
|
|
null!,
|
|
null!,
|
|
null!,
|
|
null!);
|
|
}
|
|
|
|
private static Mock<ITenantContext> CreateTenantContext()
|
|
{
|
|
var tenantContext = new Mock<ITenantContext>();
|
|
tenantContext.Setup(x => x.GetCurrentCompanyId()).Returns(1);
|
|
tenantContext.Setup(x => x.IsSuperAdmin()).Returns(true);
|
|
tenantContext.Setup(x => x.IsPlatformAdmin()).Returns(true);
|
|
return tenantContext;
|
|
}
|
|
|
|
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!);
|
|
}
|
|
}
|