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; /// /// 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. /// 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>()); 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>()); 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>()); 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>()); 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() .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(); sessionMock.Setup(s => s.TryGetValue(It.IsAny(), out noBytes)).Returns(false); var httpContextMock = new Mock(); httpContextMock.SetupGet(c => c.User).Returns(principal); httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object); var accessor = new Mock(); accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object); return new ApplicationDbContext(options, accessor.Object, null!); } }