Files
PowderCoatingLogix/tests/PowderCoating.UnitTests/QuoteAndReworkControllerFlowTests.cs
T
spouliot 0afb474c3e Add Phase B: Inventory COGS auto-posting to GL on JobUsage transactions
When powder is consumed via a job (JobsController) or scan (InventoryController.LogUsage),
debit the item's CogsAccountId and credit its InventoryAccountId for the cost of the
quantity consumed (using AverageCost if available, else UnitCost). No-op when either
GL account is not configured on the InventoryItem.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:39:23 -04:00

409 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>>(),
Mock.Of<IAccountBalanceService>());
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!);
}
}