using Microsoft.EntityFrameworkCore; using Prefab.Catalog.Domain.Entities; using Prefab.Catalog.Domain.Services; using Prefab.Shared.Catalog.Products; using Prefab.Catalog.Api.Data; using Prefab.Handler; using Shouldly; namespace Prefab.Tests.Unit.Modules.Catalog.Domain.Services; [Trait(TraitName.Category, TraitCategory.Unit)] public sealed class PricingServiceShould : IDisposable { private readonly AppDb _writeDb; private readonly AppDbReadOnly _readDb; private readonly PricingService _service; private readonly Product _model; private readonly OptionDefinition _colorOption; private readonly OptionValue _ivoryOptionValue; private readonly OptionDefinition _leadLengthOption; public PricingServiceShould() { var databaseName = Guid.NewGuid().ToString(); var handlerAccessor = new TestHandlerContextAccessor(); var writeOptions = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName) .Options; var readOptions = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName) .Options; _writeDb = new AppDb(writeOptions, handlerAccessor); _readDb = new AppDbReadOnly(readOptions, handlerAccessor); (_model, _colorOption, _ivoryOptionValue, _leadLengthOption) = SeedCatalogAsync().GetAwaiter().GetResult(); _service = new PricingService(_readDb); } [Fact] public async Task EmitExpectedPriceForChoiceOption() { var request = new QuotePrice.Request( _model.Id, null, new List { new(_colorOption.Id, _ivoryOptionValue.Id, null) }); var response = await _service.QuoteAsync(request, TestContext.Current.CancellationToken); response.UnitPrice.ShouldBe(13.49m); response.Breakdown.ShouldContain(b => b.Option == _colorOption.Name && b.Delta == 0.50m); } [Fact] public async Task EmitExpectedPriceForNumericOption() { var request = new QuotePrice.Request( _model.Id, null, new List { new(_leadLengthOption.Id, null, 18m) }); var response = await _service.QuoteAsync(request, TestContext.Current.CancellationToken); response.UnitPrice.ShouldBe(25.59m); response.Breakdown.ShouldContain(b => b.Option == _leadLengthOption.Name && b.Delta == 12.60m); } [Fact] public async Task ApplyTierPricingWhenThresholdReached() { var request = new QuotePrice.Request( _model.Id, null, new List { new(_leadLengthOption.Id, null, 40m) }); var response = await _service.QuoteAsync(request, TestContext.Current.CancellationToken); response.UnitPrice.ShouldBe(41.99m); response.Breakdown.ShouldContain(b => b.Option == _leadLengthOption.Name && b.Delta == 29.00m); } [Fact] public async Task RespectTierBoundaryAtTwentySixFeet() { var belowThreshold = new QuotePrice.Request( _model.Id, null, new List { new(_leadLengthOption.Id, null, 25m) }); var aboveThreshold = new QuotePrice.Request( _model.Id, null, new List { new(_leadLengthOption.Id, null, 26m) }); var below = await _service.QuoteAsync(belowThreshold, TestContext.Current.CancellationToken); var above = await _service.QuoteAsync(aboveThreshold, TestContext.Current.CancellationToken); below.UnitPrice.ShouldBe(30.49m); // base 12.99 + 25 * 0.70 above.UnitPrice.ShouldBe(33.59m); // base 12.99 + (26 * 0.60) + 5 } [Fact] public async Task ApplyPercentOptionsWithoutCompounding() { var checker = new StubUniqueChecker(); var model = await Product.CreateModel($"Percent Model {Guid.NewGuid():N}", $"percent-model-{Guid.NewGuid():N}", null, checker, TestContext.Current.CancellationToken); model.SetBasePrice(100m); var markupA = await OptionDefinition.CreateChoice(model, "markup_a", "Markup A", checker, isVariantAxis: false, percentScope: null, cancellationToken: TestContext.Current.CancellationToken); var tenPercent = markupA.AddValue("ten_percent", "Ten Percent", 10m, PriceDeltaKind.Percent); var markupB = await OptionDefinition.CreateChoice(model, "markup_b", "Markup B", checker, isVariantAxis: false, percentScope: null, cancellationToken: TestContext.Current.CancellationToken); var twentyPercent = markupB.AddValue("twenty_percent", "Twenty Percent", 20m, PriceDeltaKind.Percent); _writeDb.Products.Add(model); await _writeDb.SaveChangesAsync(TestContext.Current.CancellationToken); var request = new QuotePrice.Request( model.Id, null, new List { new(markupA.Id, tenPercent.Id, null), new(markupB.Id, twentyPercent.Id, null) }); var response = await _service.QuoteAsync(request, TestContext.Current.CancellationToken); response.UnitPrice.ShouldBe(130m); response.Breakdown.ShouldContain(b => b.Option == markupA.Name && b.Delta == 10m); response.Breakdown.ShouldContain(b => b.Option == markupB.Name && b.Delta == 20m); } [Fact] public async Task ApplyNumericOnlyPercentAdjustments() { var (model, lengthOption) = await CreateModelWithLeadLengthAsync(); var checker = new StubUniqueChecker(); var wireSize = await OptionDefinition.CreateChoice( model, "wire_size", "Wire Size", checker, isVariantAxis: false, percentScope: PercentScope.NumericOnly, cancellationToken: TestContext.Current.CancellationToken); var fifteenPercent = wireSize.AddValue("fifteen_percent", "15%", 15m, PriceDeltaKind.Percent); _writeDb.Products.Add(model); await _writeDb.SaveChangesAsync(TestContext.Current.CancellationToken); var request = new QuotePrice.Request( model.Id, null, new List { new(lengthOption.Id, null, 40m), new(wireSize.Id, fifteenPercent.Id, null) }); var response = await _service.QuoteAsync(request, TestContext.Current.CancellationToken); response.UnitPrice.ShouldBe(46.34m); response.Breakdown.ShouldContain(b => b.Option == wireSize.Name && b.Delta == 4.35m); } [Fact] public async Task ApplyBasePlusNumericPercentAdjustments() { var (model, lengthOption) = await CreateModelWithLeadLengthAsync(); var checker = new StubUniqueChecker(); var wireSize = await OptionDefinition.CreateChoice( model, "wire_size_plus", "Wire Size", checker, isVariantAxis: false, percentScope: PercentScope.BasePlusNumeric, cancellationToken: TestContext.Current.CancellationToken); var fifteenPercent = wireSize.AddValue("fifteen_percent_plus", "15%", 15m, PriceDeltaKind.Percent); _writeDb.Products.Add(model); await _writeDb.SaveChangesAsync(TestContext.Current.CancellationToken); var request = new QuotePrice.Request( model.Id, null, new List { new(lengthOption.Id, null, 40m), new(wireSize.Id, fifteenPercent.Id, null) }); var response = await _service.QuoteAsync(request, TestContext.Current.CancellationToken); response.UnitPrice.ShouldBe(48.29m); response.Breakdown.ShouldContain(b => b.Option == wireSize.Name && Math.Abs(b.Delta - 6.30m) < 0.01m); } [Fact] public async Task QuoteCeilingTBarBoxAssemblyScenario() { var checker = new StubUniqueChecker(); var product = await Product.CreateModel( $"ceiling-tbar-{Guid.NewGuid():N}", $"ceiling-tbar-{Guid.NewGuid():N}", null, checker, TestContext.Current.CancellationToken); product.SetBasePrice(39.00m); var blankCover = await OptionDefinition.CreateChoice(product, "blank_cover", "Blank Cover", checker, cancellationToken: TestContext.Current.CancellationToken); var blankYes = blankCover.AddValue("yes", "Yes", 1.25m, PriceDeltaKind.Absolute); var groundTail = await OptionDefinition.CreateChoice(product, "ground_tail", "Ground Tail", checker, cancellationToken: TestContext.Current.CancellationToken); var groundYes = groundTail.AddValue("yes", "Yes", 0.75m, PriceDeltaKind.Absolute); var boxColor = await OptionDefinition.CreateChoice(product, "box_color", "Box Color", checker, cancellationToken: TestContext.Current.CancellationToken); var redColor = boxColor.AddValue("red", "Red", 0.50m, PriceDeltaKind.Absolute); var bracketType = await OptionDefinition.CreateChoice(product, "bracket_type", "Bracket Type", checker, cancellationToken: TestContext.Current.CancellationToken); var adjustableBracket = bracketType.AddValue("adjustable", "Adjustable", 2.00m, PriceDeltaKind.Absolute); var dedicatedDropwire = await OptionDefinition.CreateChoice(product, "dedicated_dropwire", "Dedicated Dropwire", checker, cancellationToken: TestContext.Current.CancellationToken); var dropwireYes = dedicatedDropwire.AddValue("yes", "Yes", 3.00m, PriceDeltaKind.Absolute); var flexWhip = await OptionDefinition.CreateNumber( product, "flex_whip_length", "Flex Whip Length", "ft", 1m, 100m, 1m, 0.85m, checker, cancellationToken: TestContext.Current.CancellationToken); _writeDb.Products.Add(product); await _writeDb.SaveChangesAsync(TestContext.Current.CancellationToken); var request = new QuotePrice.Request( product.Id, null, new List { new(blankCover.Id, blankYes.Id, null), new(groundTail.Id, groundYes.Id, null), new(boxColor.Id, redColor.Id, null), new(bracketType.Id, adjustableBracket.Id, null), new(dedicatedDropwire.Id, dropwireYes.Id, null), new(flexWhip.Id, null, 10m) }); var response = await _service.QuoteAsync(request, TestContext.Current.CancellationToken); response.UnitPrice.ShouldBe(55.00m); } [Fact] public async Task QuoteFanHangerAssemblyScenario() { var checker = new StubUniqueChecker(); var product = await Product.CreateModel( $"fan-hanger-{Guid.NewGuid():N}", $"fan-hanger-{Guid.NewGuid():N}", null, checker, TestContext.Current.CancellationToken); product.SetBasePrice(42.00m); var groundTail = await OptionDefinition.CreateChoice(product, "ground_tail", "Ground Tail", checker, cancellationToken: TestContext.Current.CancellationToken); var groundYes = groundTail.AddValue("yes", "Yes", 0.75m, PriceDeltaKind.Absolute); var blankCover = await OptionDefinition.CreateChoice(product, "blank_cover", "Blank Cover", checker, cancellationToken: TestContext.Current.CancellationToken); var blankYes = blankCover.AddValue("yes", "Yes", 1.25m, PriceDeltaKind.Absolute); var dedicatedDropwire = await OptionDefinition.CreateChoice(product, "dedicated_dropwire", "Dedicated Dropwire", checker, cancellationToken: TestContext.Current.CancellationToken); var dropwireYes = dedicatedDropwire.AddValue("yes", "Yes", 3.00m, PriceDeltaKind.Absolute); _writeDb.Products.Add(product); await _writeDb.SaveChangesAsync(TestContext.Current.CancellationToken); var request = new QuotePrice.Request( product.Id, null, new List { new(groundTail.Id, groundYes.Id, null), new(blankCover.Id, blankYes.Id, null), new(dedicatedDropwire.Id, dropwireYes.Id, null) }); var response = await _service.QuoteAsync(request, TestContext.Current.CancellationToken); response.UnitPrice.ShouldBe(47.00m); } [Fact] public async Task QuoteRaisedDeviceAssemblyScenario() { var checker = new StubUniqueChecker(); var product = await Product.CreateModel( $"raised-device-{Guid.NewGuid():N}", $"raised-device-{Guid.NewGuid():N}", null, checker, TestContext.Current.CancellationToken); product.SetBasePrice(29.00m); var deviceColor = await OptionDefinition.CreateChoice(product, "device_color", "Device Color", checker, cancellationToken: TestContext.Current.CancellationToken); var ivory = deviceColor.AddValue("ivory", "Ivory", 0.50m, PriceDeltaKind.Absolute); var grade = await OptionDefinition.CreateChoice(product, "grade", "Grade", checker, cancellationToken: TestContext.Current.CancellationToken); var spec = grade.AddValue("spec", "Spec", 1.00m, PriceDeltaKind.Absolute); var boxSize = await OptionDefinition.CreateChoice(product, "box_size", "Box Size", checker, cancellationToken: TestContext.Current.CancellationToken); var largeBox = boxSize.AddValue("4_11_16", "4-11/16\" Square", 0.75m, PriceDeltaKind.Absolute); var leadLength = await OptionDefinition.CreateNumber( product, "lead_length", "Lead Length", "ft", 1m, 100m, 1m, 0.70m, checker, cancellationToken: TestContext.Current.CancellationToken); leadLength.AddTier(26m, null, 0.60m, 5.00m); var wireSize = await OptionDefinition.CreateChoice(product, "wire_size", "Wire Size", checker, percentScope: PercentScope.NumericOnly, cancellationToken: TestContext.Current.CancellationToken); _ = wireSize.AddValue("awg14", "AWG 14", 0m, PriceDeltaKind.Percent); var awg12 = wireSize.AddValue("awg12", "AWG 12", 15m, PriceDeltaKind.Percent); var wagos = await OptionDefinition.CreateChoice(product, "wagos", "Wago Connectors", checker, cancellationToken: TestContext.Current.CancellationToken); var wagosYes = wagos.AddValue("yes", "Yes", 2.00m, PriceDeltaKind.Absolute); _writeDb.Products.Add(product); await _writeDb.SaveChangesAsync(TestContext.Current.CancellationToken); var request = new QuotePrice.Request( product.Id, null, new List { new(deviceColor.Id, ivory.Id, null), new(grade.Id, spec.Id, null), new(boxSize.Id, largeBox.Id, null), new(leadLength.Id, null, 30m), new(wireSize.Id, awg12.Id, null), new(wagos.Id, wagosYes.Id, null) }); var response = await _service.QuoteAsync(request, TestContext.Current.CancellationToken); response.UnitPrice.ShouldBe(59.70m); } [Fact] public async Task QuotePigtailedDeviceScenario() { var checker = new StubUniqueChecker(); var product = await Product.CreateModel( $"pigtailed-device-{Guid.NewGuid():N}", $"pigtailed-device-{Guid.NewGuid():N}", null, checker, TestContext.Current.CancellationToken); product.SetBasePrice(24.00m); var deviceType = await OptionDefinition.CreateChoice(product, "device_type", "Device Type", checker, cancellationToken: TestContext.Current.CancellationToken); var gfci15 = deviceType.AddValue("gfci15", "GFCI 15A", 0m, PriceDeltaKind.Absolute); var deviceColor = await OptionDefinition.CreateChoice(product, "device_color", "Device Color", checker, cancellationToken: TestContext.Current.CancellationToken); var ivory = deviceColor.AddValue("ivory", "Ivory", 0.50m, PriceDeltaKind.Absolute); var grade = await OptionDefinition.CreateChoice(product, "grade", "Grade", checker, cancellationToken: TestContext.Current.CancellationToken); var spec = grade.AddValue("spec", "Spec", 1.00m, PriceDeltaKind.Absolute); var leadLength = await OptionDefinition.CreateNumber( product, "lead_length", "Lead Length", "ft", 1m, 100m, 1m, 0.55m, checker, cancellationToken: TestContext.Current.CancellationToken); leadLength.AddTier(50m, null, 0.45m, 4.00m); var wireSize = await OptionDefinition.CreateChoice(product, "wire_size", "Wire Size", checker, percentScope: PercentScope.NumericOnly, cancellationToken: TestContext.Current.CancellationToken); _ = wireSize.AddValue("awg14", "AWG 14", 0m, PriceDeltaKind.Percent); var awg12 = wireSize.AddValue("awg12", "AWG 12", 15m, PriceDeltaKind.Percent); var wagos = await OptionDefinition.CreateChoice(product, "wagos", "Wago Connectors", checker, cancellationToken: TestContext.Current.CancellationToken); var wagosYes = wagos.AddValue("yes", "Yes", 2.00m, PriceDeltaKind.Absolute); _writeDb.Products.Add(product); await _writeDb.SaveChangesAsync(TestContext.Current.CancellationToken); var request = new QuotePrice.Request( product.Id, null, new List { new(deviceType.Id, gfci15.Id, null), new(deviceColor.Id, ivory.Id, null), new(grade.Id, spec.Id, null), new(leadLength.Id, null, 60m), new(wireSize.Id, awg12.Id, null), new(wagos.Id, wagosYes.Id, null) }); var response = await _service.QuoteAsync(request, TestContext.Current.CancellationToken); response.UnitPrice.ShouldBe(63.15m); } public void Dispose() { _writeDb.Dispose(); _readDb.Dispose(); GC.SuppressFinalize(this); } private async Task<(Product Model, OptionDefinition ColorOption, OptionValue IvoryValue, OptionDefinition LeadLengthOption)> SeedCatalogAsync() { var checker = new StubUniqueChecker(); var model = await Product.CreateModel("Prefab ATR Box Light", "prefab-atr-box-light", null, checker, TestContext.Current.CancellationToken); model.SetBasePrice(12.99m); var colorOption = await OptionDefinition.CreateChoice(model, "color", "Color", checker, isVariantAxis: false, cancellationToken: TestContext.Current.CancellationToken); _ = colorOption.AddValue("white", "White", 0m, PriceDeltaKind.Absolute); var ivoryValue = colorOption.AddValue("ivory", "Ivory", 0.50m, PriceDeltaKind.Absolute); var leadLengthOption = await OptionDefinition.CreateNumber( model, "lead_length", "Lead Length", "ft", 1m, 100m, 1m, 0.70m, uniqueChecker: checker, cancellationToken: TestContext.Current.CancellationToken); leadLengthOption.AddTier(26m, null, 0.60m, 5m); _writeDb.Products.Add(model); await _writeDb.SaveChangesAsync(TestContext.Current.CancellationToken); return (model, colorOption, ivoryValue, leadLengthOption); } private async Task<(Product Model, OptionDefinition LeadLengthOption)> CreateModelWithLeadLengthAsync() { var checker = new StubUniqueChecker(); var model = await Product.CreateModel($"Lead Model {Guid.NewGuid():N}", $"lead-model-{Guid.NewGuid():N}", null, checker, TestContext.Current.CancellationToken); model.SetBasePrice(12.99m); var leadLengthOption = await OptionDefinition.CreateNumber( model, $"lead_length_{Guid.NewGuid():N}", "Lead Length", "ft", 1m, 100m, 1m, 0.70m, uniqueChecker: checker, cancellationToken: TestContext.Current.CancellationToken); leadLengthOption.AddTier(26m, null, 0.60m, 5m); return (model, leadLengthOption); } private sealed class TestHandlerContextAccessor : IHandlerContextAccessor { public HandlerContext? Current => null; public IDisposable Set(HandlerContext handlerContext) => new Noop(); private sealed class Noop : IDisposable { public void Dispose() { } } } private sealed class StubUniqueChecker : IUniqueChecker { public Task CategoryNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true); public Task ProductModelNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true); public Task ProductSlugIsUnique(string? slug, CancellationToken cancellationToken = default) => Task.FromResult(true); public Task ProductSkuIsUnique(string sku, CancellationToken cancellationToken = default) => Task.FromResult(true); public Task OptionCodeIsUniqueForProduct(Guid productId, string code, CancellationToken cancellationToken = default) => Task.FromResult(true); } }