148a3f465e
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>
223 lines
7.9 KiB
C#
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!);
|
|
}
|
|
}
|