Files
PowderCoatingLogix/tests/PowderCoating.UnitTests/PowderCatalogPropagationTests.cs
T
spouliot 148a3f465e Self-heal inventory catalog links during sync
The sync propagation now also backfills the catalog link: any inventory item
with no PowderCatalogItemId that matches a catalog row by Manufacturer +
ManufacturerPartNumber (the catalog SKU) gets linked and picks up the catalog
price/product data. Only links on a confident match (exact SKU + matching
vendor, or a single unambiguous candidate), so it never mis-links.

This backfills items created before linking existed, automatically, on every
environment (dev and prod) with no manual step or one-off script — legacy items
link on the next sync, new items still link at create time. Cost basis,
quantity, notes, and image remain untouched.

Tests: links an unlinked item by manufacturer+part number; leaves it unlinked
when the part number has no catalog match. Full suite 278 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 08:32:33 -04:00

223 lines
7.9 KiB
C#

using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using PowderCoating.Infrastructure.Services;
namespace PowderCoating.UnitTests;
/// <summary>
/// Verifies that catalog sync propagation updates a linked inventory item's quoting reference price
/// and product data, while never touching the tenant-owned cost basis, quantity, notes, or image.
/// </summary>
public class PowderCatalogPropagationTests
{
[Fact]
public async Task Propagate_UpdatesReferencePriceAndSpecs_ButNotCostQuantityNotesOrImage()
{
await using var context = CreateContext();
var catalog = new PowderCatalogItem
{
VendorName = "Columbia Coatings",
Sku = "CS1693053",
ColorName = "Joker Jewel",
Source = "Columbia Coatings API",
UnitPrice = 28m, // new catalog price
SdsUrl = "https://cc/sds.pdf",
TdsUrl = "https://cc/tds.pdf",
CureTemperatureF = 400m,
CureTimeMinutes = 10,
ColorFamilies = "Green,Purple",
};
context.PowderCatalogItems.Add(catalog);
await context.SaveChangesAsync();
var inv = new InventoryItem
{
CompanyId = 1,
SKU = "POWD-2606-0001",
Name = "Joker Jewel",
PowderCatalogItemId = catalog.Id,
UnitCost = 20m, // what they actually paid
AverageCost = 20m,
LastPurchasePrice = 20m,
QuantityOnHand = 5m,
Notes = "keep my note",
ImageUrl = "my-own-photo.jpg",
CatalogReferencePrice = null, // not yet set
};
context.InventoryItems.Add(inv);
await context.SaveChangesAsync();
var service = new PowderCatalogUpsertService(
new UnitOfWork(context),
Mock.Of<ILogger<PowderCatalogUpsertService>>());
var updated = await service.PropagateToLinkedInventoryAsync();
Assert.Equal(1, updated);
var refreshed = await context.InventoryItems.FindAsync(inv.Id);
Assert.NotNull(refreshed);
// Quoting reference price + product data refreshed from the catalog.
Assert.Equal(28m, refreshed!.CatalogReferencePrice);
Assert.NotNull(refreshed.CatalogPriceUpdatedAt);
Assert.Equal("https://cc/sds.pdf", refreshed.SdsUrl);
Assert.Equal("https://cc/tds.pdf", refreshed.TdsUrl);
Assert.Equal(400m, refreshed.CureTemperatureF);
Assert.Equal("Green,Purple", refreshed.ColorFamilies);
// Tenant-owned fields untouched.
Assert.Equal(20m, refreshed.UnitCost);
Assert.Equal(20m, refreshed.AverageCost);
Assert.Equal(20m, refreshed.LastPurchasePrice);
Assert.Equal(5m, refreshed.QuantityOnHand);
Assert.Equal("keep my note", refreshed.Notes);
Assert.Equal("my-own-photo.jpg", refreshed.ImageUrl);
}
[Fact]
public async Task Propagate_DoesNotSetReferencePrice_WhenCatalogPriceIsZero()
{
await using var context = CreateContext();
var catalog = new PowderCatalogItem
{
VendorName = "Columbia Coatings",
Sku = "X1",
ColorName = "No Price",
Source = "Columbia Coatings API",
UnitPrice = 0m, // unknown price — must not wipe quoting with $0
};
context.PowderCatalogItems.Add(catalog);
await context.SaveChangesAsync();
var inv = new InventoryItem
{
CompanyId = 1,
SKU = "POWD-2606-0002",
Name = "No Price",
PowderCatalogItemId = catalog.Id,
UnitCost = 15m,
CatalogReferencePrice = null,
};
context.InventoryItems.Add(inv);
await context.SaveChangesAsync();
var service = new PowderCatalogUpsertService(
new UnitOfWork(context),
Mock.Of<ILogger<PowderCatalogUpsertService>>());
await service.PropagateToLinkedInventoryAsync();
var refreshed = await context.InventoryItems.FindAsync(inv.Id);
Assert.Null(refreshed!.CatalogReferencePrice); // stays null -> quoting falls back to UnitCost
}
[Fact]
public async Task Propagate_LinksUnlinkedItem_ByManufacturerAndPartNumber()
{
await using var context = CreateContext();
var catalog = new PowderCatalogItem
{
VendorName = "Columbia Coatings",
Sku = "CS1693053",
ColorName = "Joker Jewel",
Source = "Columbia Coatings API",
UnitPrice = 28m,
};
context.PowderCatalogItems.Add(catalog);
await context.SaveChangesAsync();
var inv = new InventoryItem
{
CompanyId = 1,
SKU = "POWD-2606-0009",
Name = "Joker Jewel",
Manufacturer = "Columbia Coatings",
ManufacturerPartNumber = "CS1693053", // matches catalog SKU
PowderCatalogItemId = null, // not linked yet (legacy item)
UnitCost = 20m,
};
context.InventoryItems.Add(inv);
await context.SaveChangesAsync();
var service = new PowderCatalogUpsertService(
new UnitOfWork(context),
Mock.Of<ILogger<PowderCatalogUpsertService>>());
await service.PropagateToLinkedInventoryAsync();
var refreshed = await context.InventoryItems.FindAsync(inv.Id);
Assert.Equal(catalog.Id, refreshed!.PowderCatalogItemId); // self-healed link
Assert.Equal(28m, refreshed.CatalogReferencePrice); // and got the price
Assert.Equal(20m, refreshed.UnitCost); // cost untouched
}
[Fact]
public async Task Propagate_DoesNotLink_WhenPartNumberDoesNotMatch()
{
await using var context = CreateContext();
context.PowderCatalogItems.Add(new PowderCatalogItem
{
VendorName = "Columbia Coatings",
Sku = "CS1693053",
ColorName = "Joker Jewel",
UnitPrice = 28m,
});
await context.SaveChangesAsync();
var inv = new InventoryItem
{
CompanyId = 1,
SKU = "POWD-2606-0010",
Name = "Something Else",
Manufacturer = "Columbia Coatings",
ManufacturerPartNumber = "NOPE-999", // no catalog match
PowderCatalogItemId = null,
};
context.InventoryItems.Add(inv);
await context.SaveChangesAsync();
var service = new PowderCatalogUpsertService(
new UnitOfWork(context),
Mock.Of<ILogger<PowderCatalogUpsertService>>());
await service.PropagateToLinkedInventoryAsync();
var refreshed = await context.InventoryItems.FindAsync(inv.Id);
Assert.Null(refreshed!.PowderCatalogItemId); // stays unlinked
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.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<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!);
}
}