Init
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json;
|
||||
using Prefab.Tests.Infrastructure;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Unit.Modules.Catalog.App.Products;
|
||||
|
||||
[Collection("Prefab.Debug")]
|
||||
[Trait(TraitName.Category, TraitCategory.Integration)]
|
||||
public sealed class CatalogProductDetailNotFoundShould(PrefabCompositeFixture_Debug fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReturnNotFoundProblemDetailsForUnknownSlug()
|
||||
{
|
||||
var client = fixture.CreateHttpClientForWeb();
|
||||
|
||||
using var response = await client.GetAsync("/api/catalog/products/does-not-exist", TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
using var json = JsonDocument.Parse(payload);
|
||||
json.RootElement.GetProperty("title").GetString().ShouldNotBeNull().ToLowerInvariant().ShouldContain("not found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Reflection;
|
||||
using FluentValidation;
|
||||
using Prefab.Catalog.App.Products;
|
||||
using Prefab.Handler;
|
||||
using Prefab.Handler.Decorators;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Unit.Modules.Catalog.App.Products;
|
||||
|
||||
public sealed class QuotePriceShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task RejectRequestsWithoutLookupIdentifiers()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IValidator<QuotePrice.Request>>(_ => CreateValidator());
|
||||
services.AddScoped<IHandler<QuotePrice.Request, QuotePrice.Response>, StubHandler>();
|
||||
services.AddSingleton<IHandlerDecorator, ValidationDecorator>();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var invoker = new HandlerInvoker(provider.GetServices<IHandlerDecorator>(), provider);
|
||||
|
||||
var request = new QuotePrice.Request(null, null, Array.Empty<QuotePrice.Selection>());
|
||||
|
||||
var act = () => invoker.Execute<QuotePrice.Request, QuotePrice.Response>(request, TestContext.Current.CancellationToken);
|
||||
|
||||
var exception = await act.ShouldThrowAsync<ValidationException>();
|
||||
exception.Errors.ShouldContain(failure => failure.ErrorMessage == "Either productId or sku must be provided.");
|
||||
}
|
||||
|
||||
private sealed class StubHandler : IHandler<QuotePrice.Request, QuotePrice.Response>
|
||||
{
|
||||
public Task<QuotePrice.Response> Execute(QuotePrice.Request request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new QuotePrice.Response(0m, Array.Empty<QuotePrice.QuoteBreakdown>()));
|
||||
}
|
||||
|
||||
private static IValidator<QuotePrice.Request> CreateValidator()
|
||||
{
|
||||
var validatorType = typeof(QuotePrice).GetNestedType("Validator", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
validatorType.ShouldNotBeNull();
|
||||
|
||||
return (IValidator<QuotePrice.Request>)Activator.CreateInstance(validatorType!)!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prefab.Catalog.Api.Data;
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using Prefab.Catalog.Domain.Services;
|
||||
using Prefab.Handler;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Unit.Modules.Catalog.Data;
|
||||
|
||||
public sealed class ProductConcurrencyShould : IAsyncLifetime, IDisposable
|
||||
{
|
||||
private readonly HandlerContextAccessor _accessor = new();
|
||||
private readonly DbContextOptions<AppDb> _options;
|
||||
|
||||
public ProductConcurrencyShould()
|
||||
{
|
||||
var databaseName = Guid.NewGuid().ToString();
|
||||
_options = new DbContextOptionsBuilder<AppDb>()
|
||||
.UseInMemoryDatabase(databaseName)
|
||||
.Options;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThrowWhenSavingWithStaleRowVersion()
|
||||
{
|
||||
var checker = new PermissiveUniqueChecker();
|
||||
|
||||
await using (var seedContext = CreateContext())
|
||||
{
|
||||
var product = await Product.CreateModel("Demo Model", "demo-model", "Demo", checker, CancellationToken.None);
|
||||
product.SetBasePrice(10m);
|
||||
product.RowVersion = Guid.NewGuid().ToByteArray();
|
||||
|
||||
seedContext.Products.Add(product);
|
||||
await seedContext.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var writerOne = CreateContext();
|
||||
await using var writerTwo = CreateContext();
|
||||
|
||||
var first = await writerOne.Products.FirstAsync(CancellationToken.None);
|
||||
var second = await writerTwo.Products.FirstAsync(CancellationToken.None);
|
||||
|
||||
first.SetBasePrice(12m);
|
||||
writerOne.Entry(first).Property(p => p.RowVersion).CurrentValue = Guid.NewGuid().ToByteArray();
|
||||
await writerOne.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
second.SetBasePrice(14m);
|
||||
|
||||
await Should.ThrowAsync<DbUpdateConcurrencyException>(
|
||||
() => writerTwo.SaveChangesAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
private AppDb CreateContext() => new(_options, _accessor);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
private sealed class PermissiveUniqueChecker : IUniqueChecker
|
||||
{
|
||||
public Task<bool> CategoryNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductModelNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSlugIsUnique(string? slug, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSkuIsUnique(string sku, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> OptionCodeIsUniqueForProduct(Guid productId, string code, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using Prefab.Catalog.Domain.Events;
|
||||
using Prefab.Catalog.Domain.Services;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Unit.Modules.Catalog.Domain.Events;
|
||||
|
||||
[Trait(TraitName.Category, TraitCategory.Unit)]
|
||||
public class ProductEventsShould
|
||||
{
|
||||
private static readonly CancellationToken Ct = CancellationToken.None;
|
||||
|
||||
[Fact]
|
||||
public async Task EmitProductCreatedWhenCreatingModel()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
|
||||
var product = await Product.CreateModel("Model A", "model-a", "desc", uniqueChecker: checker, cancellationToken: Ct);
|
||||
|
||||
var evt = product.Events.ShouldHaveSingleItem().ShouldBeOfType<ProductCreated>();
|
||||
evt.ProductId.ShouldBe(product.Id);
|
||||
evt.Kind.ShouldBe("Model");
|
||||
evt.Name.ShouldBe("Model A");
|
||||
evt.Slug.ShouldBe("model-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitProductVariantCreatedWhenCreatingVariant()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var parent = await Product.CreateModel("Parent", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
|
||||
var variant = await Product.CreateVariant(parent.Id, "SKU-001", "Variant", 12.5m, uniqueChecker: checker, cancellationToken: Ct);
|
||||
|
||||
var evt = variant.Events.ShouldHaveSingleItem().ShouldBeOfType<ProductVariantCreated>();
|
||||
evt.ProductId.ShouldBe(variant.Id);
|
||||
evt.ParentProductId.ShouldBe(parent.Id);
|
||||
evt.Sku.ShouldBe("SKU-001");
|
||||
evt.Price.ShouldBe(12.5m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitProductPriceChangedWhenSettingBasePrice()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model B", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
product.ClearEvents();
|
||||
|
||||
product.SetBasePrice(19.99m);
|
||||
|
||||
var evt = product.Events.ShouldHaveSingleItem().ShouldBeOfType<ProductPriceChanged>();
|
||||
evt.OldPrice.ShouldBeNull();
|
||||
evt.NewPrice.ShouldBe(19.99m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitProductRenamedWhenRenaming()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model C", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
product.ClearEvents();
|
||||
|
||||
await product.Rename("Model C v2", checker, Ct);
|
||||
|
||||
var evt = product.Events.ShouldHaveSingleItem().ShouldBeOfType<ProductRenamed>();
|
||||
evt.OldName.ShouldBe("Model C");
|
||||
evt.NewName.ShouldBe("Model C v2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnforceUniqueModelNamesWhenRenaming()
|
||||
{
|
||||
var checker = new ConfigurableUniqueChecker();
|
||||
checker.ModelNameUnique = _ => true;
|
||||
|
||||
var existing = await Product.CreateModel("A", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
existing.ShouldNotBeNull();
|
||||
|
||||
var target = await Product.CreateModel("Second", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
target.ClearEvents();
|
||||
|
||||
checker.ModelNameUnique = name => !string.Equals(name, "A", StringComparison.Ordinal);
|
||||
|
||||
var duplicate = await Should.ThrowAsync<Exception>(() => target.Rename("A", checker, Ct));
|
||||
duplicate.GetType().Name.ShouldBe("DuplicateNameException");
|
||||
|
||||
checker.ModelNameUnique = _ => true;
|
||||
|
||||
await target.Rename("B", checker, Ct);
|
||||
|
||||
target.Name.ShouldBe("B");
|
||||
var evt = target.Events.ShouldHaveSingleItem().ShouldBeOfType<ProductRenamed>();
|
||||
evt.OldName.ShouldBe("Second");
|
||||
evt.NewName.ShouldBe("B");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitProductVariantAttachedWhenAttachingVariant()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var parent = await Product.CreateModel("Parent", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
parent.ClearEvents();
|
||||
var variant = await Product.CreateVariant(parent.Id, "SKU-002", "Variant A", 15m, uniqueChecker: checker, cancellationToken: Ct);
|
||||
variant.ClearEvents();
|
||||
|
||||
parent.AttachVariant(variant);
|
||||
|
||||
var evt = parent.Events.ShouldHaveSingleItem().ShouldBeOfType<ProductVariantAttached>();
|
||||
evt.ParentProductId.ShouldBe(parent.Id);
|
||||
evt.VariantProductId.ShouldBe(variant.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitProductAttributeValueUpsertedWhenUpsertingSpec()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Specs", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
product.ClearEvents();
|
||||
var attribute = AttributeDefinition.Create("Depth", AttributeDataType.Number);
|
||||
attribute.ClearEvents();
|
||||
|
||||
product.UpsertSpec(attribute.Id, "12", 12m, "in", null);
|
||||
|
||||
var evt = product.Events.ShouldHaveSingleItem().ShouldBeOfType<ProductAttributeValueUpserted>();
|
||||
evt.ProductId.ShouldBe(product.Id);
|
||||
evt.AttributeDefinitionId.ShouldBe(attribute.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitProductCategoryAssignedWhenAssigningCategory()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Cat", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
product.ClearEvents();
|
||||
var categoryId = Guid.NewGuid();
|
||||
|
||||
product.AssignToCategory(categoryId, true);
|
||||
|
||||
var evt = product.Events.ShouldHaveSingleItem().ShouldBeOfType<ProductCategoryAssigned>();
|
||||
evt.ProductId.ShouldBe(product.Id);
|
||||
evt.CategoryId.ShouldBe(categoryId);
|
||||
evt.IsPrimary.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitProductCategoryUnassignedWhenUnassigningCategory()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Cat", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
var categoryId = Guid.NewGuid();
|
||||
product.AssignToCategory(categoryId, false);
|
||||
product.ClearEvents();
|
||||
|
||||
var removed = product.UnassignFromCategory(categoryId);
|
||||
|
||||
removed.ShouldBeTrue();
|
||||
var evt = product.Events.ShouldHaveSingleItem().ShouldBeOfType<ProductCategoryUnassigned>();
|
||||
evt.CategoryId.ShouldBe(categoryId);
|
||||
}
|
||||
|
||||
private sealed class ConfigurableUniqueChecker : IUniqueChecker
|
||||
{
|
||||
public Func<string, bool> ModelNameUnique { get; set; } = _ => true;
|
||||
|
||||
public Task<bool> ProductModelNameIsUnique(string name, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(ModelNameUnique(name));
|
||||
|
||||
public Task<bool> CategoryNameIsUnique(string name, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSlugIsUnique(string? slug, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSkuIsUnique(string sku, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<bool> OptionCodeIsUniqueForProduct(Guid productId, string code, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
public class OptionDefinitionEventsShould
|
||||
{
|
||||
private static readonly CancellationToken Ct = CancellationToken.None;
|
||||
|
||||
[Fact]
|
||||
public async Task EmitOptionDefinitionCreatedForChoice()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Options", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
|
||||
var option = await OptionDefinition.CreateChoice(product, "color", "Color", checker, isVariantAxis: false, cancellationToken: Ct);
|
||||
|
||||
var evt = option.Events.ShouldHaveSingleItem().ShouldBeOfType<OptionDefinitionCreated>();
|
||||
evt.ProductId.ShouldBe(product.Id);
|
||||
evt.OptionDefinitionId.ShouldBe(option.Id);
|
||||
evt.DataType.ShouldBe("Choice");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitOptionDefinitionCreatedForNumber()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Options", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
|
||||
var option = await OptionDefinition.CreateNumber(product, "lead", "Lead Length", "ft", 1, 100, 1, 0.5m, isVariantAxis: true, uniqueChecker: checker, cancellationToken: Ct);
|
||||
|
||||
var evt = option.Events.ShouldHaveSingleItem().ShouldBeOfType<OptionDefinitionCreated>();
|
||||
evt.DataType.ShouldBe("Number");
|
||||
evt.IsVariantAxis.ShouldBeTrue();
|
||||
option.IsVariantAxis.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitOptionValueAddedWhenAddingValue()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Options", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
var option = await OptionDefinition.CreateChoice(product, "finish", "Finish", checker, isVariantAxis: false, cancellationToken: Ct);
|
||||
option.ClearEvents();
|
||||
|
||||
var value = option.AddValue("ivory", "Ivory", 0.5m, PriceDeltaKind.Absolute);
|
||||
|
||||
var evt = option.Events.ShouldHaveSingleItem().ShouldBeOfType<OptionValueAdded>();
|
||||
evt.OptionValueId.ShouldBe(value.Id);
|
||||
evt.PriceDelta.ShouldBe(0.5m);
|
||||
evt.Kind.ShouldBe(PriceDeltaKind.Absolute);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitOptionValueChangedWhenChangingValue()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Options", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
var option = await OptionDefinition.CreateChoice(product, "shade", "Shade", checker, isVariantAxis: false, cancellationToken: Ct);
|
||||
var value = option.AddValue("warm", "Warm", 0.2m, PriceDeltaKind.Absolute);
|
||||
option.ClearEvents();
|
||||
|
||||
option.ChangeValue(value.Id, "Warm Glow", 0.3m, PriceDeltaKind.Percent);
|
||||
|
||||
var evt = option.Events.ShouldHaveSingleItem().ShouldBeOfType<OptionValueChanged>();
|
||||
evt.OptionValueId.ShouldBe(value.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitOptionValueRemovedWhenRemovingValue()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Options", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
var option = await OptionDefinition.CreateChoice(product, "texture", "Texture", checker, isVariantAxis: false, cancellationToken: Ct);
|
||||
var value = option.AddValue("smooth", "Smooth", null, PriceDeltaKind.Absolute);
|
||||
option.ClearEvents();
|
||||
|
||||
option.RemoveValue(value.Id);
|
||||
|
||||
var evt = option.Events.ShouldHaveSingleItem().ShouldBeOfType<OptionValueRemoved>();
|
||||
evt.OptionValueId.ShouldBe(value.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitOptionTierAddedWhenAddingTier()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Options", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
var option = await OptionDefinition.CreateNumber(product, "length", "Length", "ft", 0, 100, 1, 0.7m, uniqueChecker: checker, cancellationToken: Ct);
|
||||
option.ClearEvents();
|
||||
|
||||
var tier = option.AddTier(10, 20, 0.6m, 5m);
|
||||
|
||||
var evt = option.Events.ShouldHaveSingleItem().ShouldBeOfType<OptionTierAdded>();
|
||||
evt.OptionTierId.ShouldBe(tier.Id);
|
||||
evt.FromInclusive.ShouldBe(10);
|
||||
evt.ToInclusive.ShouldBe(20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitOptionTierChangedWhenChangingTier()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Options", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
var option = await OptionDefinition.CreateNumber(product, "height", "Height", "ft", 0, 100, 1, 0.7m, uniqueChecker: checker, cancellationToken: Ct);
|
||||
var tier = option.AddTier(0, 10, 0.5m, null);
|
||||
option.ClearEvents();
|
||||
|
||||
option.ChangeTier(tier.Id, 5, 15, 0.55m, 2m);
|
||||
|
||||
var evt = option.Events.ShouldHaveSingleItem().ShouldBeOfType<OptionTierChanged>();
|
||||
evt.OptionTierId.ShouldBe(tier.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitOptionTierRemovedWhenRemovingTier()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Options", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
var option = await OptionDefinition.CreateNumber(product, "width", "Width", "ft", 0, 100, 1, 0.7m, uniqueChecker: checker, cancellationToken: Ct);
|
||||
var tier = option.AddTier(0, 5, 0.4m, null);
|
||||
option.ClearEvents();
|
||||
|
||||
option.RemoveTier(tier.Id);
|
||||
|
||||
var evt = option.Events.ShouldHaveSingleItem().ShouldBeOfType<OptionTierRemoved>();
|
||||
evt.OptionTierId.ShouldBe(tier.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public class AttributeDefinitionEventsShould
|
||||
{
|
||||
[Fact]
|
||||
public void EmitAttributeDefinitionCreatedWhenCreatingDefinition()
|
||||
{
|
||||
var attribute = AttributeDefinition.Create("Material", AttributeDataType.Enum);
|
||||
|
||||
var evt = attribute.Events.ShouldHaveSingleItem().ShouldBeOfType<AttributeDefinitionCreated>();
|
||||
evt.AttributeDefinitionId.ShouldBe(attribute.Id);
|
||||
evt.DataType.ShouldBe(AttributeDataType.Enum);
|
||||
}
|
||||
}
|
||||
|
||||
public class CategoryEventsShould
|
||||
{
|
||||
private static readonly CancellationToken Ct = CancellationToken.None;
|
||||
|
||||
[Fact]
|
||||
public async Task EmitCategoryCreatedWhenCreatingCategory()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
|
||||
var category = await Category.Create("Lighting", "Lighting fixtures", check: checker, cancellationToken: Ct);
|
||||
|
||||
var evt = category.Events.ShouldHaveSingleItem().ShouldBeOfType<CategoryCreated>();
|
||||
evt.CategoryId.ShouldBe(category.Id);
|
||||
evt.Name.ShouldBe("Lighting");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitCategoryRenamedWhenRenamingCategory()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var category = await Category.Create("Legacy", "Legacy", check: checker, cancellationToken: Ct);
|
||||
category.ClearEvents();
|
||||
|
||||
await category.Rename("Modern", check: checker, cancellationToken: Ct);
|
||||
|
||||
var evt = category.Events.ShouldHaveSingleItem().ShouldBeOfType<CategoryRenamed>();
|
||||
evt.OldName.ShouldBe("Legacy");
|
||||
evt.NewName.ShouldBe("Modern");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class StubUniqueChecker : IUniqueChecker
|
||||
{
|
||||
public Task<bool> CategoryNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductModelNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSlugIsUnique(string? slug, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSkuIsUnique(string sku, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> OptionCodeIsUniqueForProduct(Guid productId, string code, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
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<AppDb>()
|
||||
.UseInMemoryDatabase(databaseName)
|
||||
.Options;
|
||||
var readOptions = new DbContextOptionsBuilder<AppDbReadOnly>()
|
||||
.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<QuotePrice.Selection>
|
||||
{
|
||||
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<QuotePrice.Selection>
|
||||
{
|
||||
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<QuotePrice.Selection>
|
||||
{
|
||||
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<QuotePrice.Selection>
|
||||
{
|
||||
new(_leadLengthOption.Id, null, 25m)
|
||||
});
|
||||
|
||||
var aboveThreshold = new QuotePrice.Request(
|
||||
_model.Id,
|
||||
null,
|
||||
new List<QuotePrice.Selection>
|
||||
{
|
||||
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<QuotePrice.Selection>
|
||||
{
|
||||
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<QuotePrice.Selection>
|
||||
{
|
||||
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<QuotePrice.Selection>
|
||||
{
|
||||
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<QuotePrice.Selection>
|
||||
{
|
||||
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<QuotePrice.Selection>
|
||||
{
|
||||
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<QuotePrice.Selection>
|
||||
{
|
||||
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<QuotePrice.Selection>
|
||||
{
|
||||
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<bool> CategoryNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductModelNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSlugIsUnique(string? slug, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSkuIsUnique(string sku, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> OptionCodeIsUniqueForProduct(Guid productId, string code, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using Prefab.Catalog.Domain.Services;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Unit.Modules.Catalog.Domain.Services;
|
||||
|
||||
[Trait(TraitName.Category, TraitCategory.Unit)]
|
||||
public sealed class RuleEvaluatorShould
|
||||
{
|
||||
private readonly StubUniqueChecker _checker = new();
|
||||
|
||||
[Fact]
|
||||
public async Task HideOptionsWhenShowRuleNotSatisfied()
|
||||
{
|
||||
var (model, lengthOption, colorOption, redValue) = await BuildModelAsync();
|
||||
|
||||
var showRule = OptionRuleSet.Create(model, OptionRuleTargetKind.OptionDefinition, colorOption.Id, RuleEffect.Show, RuleMode.All);
|
||||
showRule.AddCondition(lengthOption, RuleOperator.GreaterThanOrEqual, rightNumber: 10m);
|
||||
|
||||
var evaluator = new RuleEvaluator();
|
||||
var selections = new[]
|
||||
{
|
||||
new OptionSelection(lengthOption.Id, null, 5m),
|
||||
new OptionSelection(colorOption.Id, redValue.Id, null)
|
||||
};
|
||||
|
||||
var result = evaluator.Evaluate(model, selections);
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Selections.ShouldContain(s => s.OptionDefinitionId == lengthOption.Id);
|
||||
result.Selections.ShouldNotContain(s => s.OptionDefinitionId == colorOption.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectSelectionsForDisabledOptions()
|
||||
{
|
||||
var (model, lengthOption, colorOption, redValue) = await BuildModelAsync();
|
||||
|
||||
var enableRule = OptionRuleSet.Create(model, OptionRuleTargetKind.OptionDefinition, colorOption.Id, RuleEffect.Enable, RuleMode.All);
|
||||
enableRule.AddCondition(lengthOption, RuleOperator.GreaterThanOrEqual, rightNumber: 10m);
|
||||
|
||||
var evaluator = new RuleEvaluator();
|
||||
var selections = new[]
|
||||
{
|
||||
new OptionSelection(lengthOption.Id, null, 5m),
|
||||
new OptionSelection(colorOption.Id, redValue.Id, null)
|
||||
};
|
||||
|
||||
var result = evaluator.Evaluate(model, selections);
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("disabled", StringComparison.OrdinalIgnoreCase));
|
||||
result.Selections.ShouldContain(s => s.OptionDefinitionId == lengthOption.Id);
|
||||
result.Selections.ShouldNotContain(s => s.OptionDefinitionId == colorOption.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureRequiredOptionsAreSelected()
|
||||
{
|
||||
var (model, lengthOption, colorOption, redValue) = await BuildModelAsync();
|
||||
|
||||
var requireRule = OptionRuleSet.Create(model, OptionRuleTargetKind.OptionDefinition, colorOption.Id, RuleEffect.Require, RuleMode.All);
|
||||
requireRule.AddCondition(lengthOption, RuleOperator.GreaterThanOrEqual, rightNumber: 10m);
|
||||
|
||||
var evaluator = new RuleEvaluator();
|
||||
var selections = new[]
|
||||
{
|
||||
new OptionSelection(lengthOption.Id, null, 15m)
|
||||
};
|
||||
|
||||
var result = evaluator.Evaluate(model, selections);
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("required", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HonourValueLevelRequirements()
|
||||
{
|
||||
var (model, lengthOption, colorOption, redValue) = await BuildModelAsync();
|
||||
var blueValue = colorOption.AddValue("blue", "Blue", 0m, PriceDeltaKind.Absolute);
|
||||
|
||||
var requireRule = OptionRuleSet.Create(model, OptionRuleTargetKind.OptionValue, redValue.Id, RuleEffect.Require, RuleMode.All);
|
||||
requireRule.AddCondition(lengthOption, RuleOperator.GreaterThanOrEqual, rightNumber: 10m);
|
||||
|
||||
var evaluator = new RuleEvaluator();
|
||||
var selections = new[]
|
||||
{
|
||||
new OptionSelection(lengthOption.Id, null, 12m),
|
||||
new OptionSelection(colorOption.Id, blueValue.Id, null)
|
||||
};
|
||||
|
||||
var result = evaluator.Evaluate(model, selections);
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("requires a different value", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HideColorWhenOctagonAndRequireDropwireForAdjustable()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var model = await Product.CreateModel(
|
||||
$"rule-hide-{Guid.NewGuid():N}",
|
||||
$"rule-hide-{Guid.NewGuid():N}",
|
||||
description: null,
|
||||
checker,
|
||||
cancellationToken: cancellationToken);
|
||||
model.SetBasePrice(10m);
|
||||
|
||||
var boxShape = await OptionDefinition.CreateChoice(model, "box_shape", "Box Shape", checker, cancellationToken: cancellationToken);
|
||||
var octagon = boxShape.AddValue("octagon", "Octagon", 0m, PriceDeltaKind.Absolute);
|
||||
var square = boxShape.AddValue("square", "Square", 0m, PriceDeltaKind.Absolute);
|
||||
|
||||
var boxColor = await OptionDefinition.CreateChoice(model, "box_color", "Box Color", checker, cancellationToken: cancellationToken);
|
||||
var red = boxColor.AddValue("red", "Red", 0m, PriceDeltaKind.Absolute);
|
||||
|
||||
var bracketType = await OptionDefinition.CreateChoice(model, "bracket_type", "Bracket Type", checker, cancellationToken: cancellationToken);
|
||||
var adjustable = bracketType.AddValue("adjustable", "Adjustable", 0m, PriceDeltaKind.Absolute);
|
||||
|
||||
var dedicatedDropwire = await OptionDefinition.CreateChoice(model, "dedicated_dropwire", "Dedicated Dropwire", checker, cancellationToken: cancellationToken);
|
||||
dedicatedDropwire.AddValue("no", "No", 0m, PriceDeltaKind.Absolute);
|
||||
dedicatedDropwire.AddValue("yes", "Yes", 0m, PriceDeltaKind.Absolute);
|
||||
|
||||
var showRule = OptionRuleSet.Create(model, OptionRuleTargetKind.OptionDefinition, boxColor.Id, RuleEffect.Show, RuleMode.All);
|
||||
showRule.AddCondition(boxShape, RuleOperator.Equal, rightOptionValueId: square.Id);
|
||||
|
||||
var requireRule = OptionRuleSet.Create(model, OptionRuleTargetKind.OptionDefinition, dedicatedDropwire.Id, RuleEffect.Require, RuleMode.All);
|
||||
requireRule.AddCondition(bracketType, RuleOperator.Equal, rightOptionValueId: adjustable.Id);
|
||||
|
||||
var evaluator = new RuleEvaluator();
|
||||
var selections = new[]
|
||||
{
|
||||
new OptionSelection(boxShape.Id, octagon.Id, null),
|
||||
new OptionSelection(boxColor.Id, red.Id, null),
|
||||
new OptionSelection(bracketType.Id, adjustable.Id, null)
|
||||
};
|
||||
|
||||
var result = evaluator.Evaluate(model, selections);
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Selections.ShouldContain(s => s.OptionDefinitionId == boxShape.Id);
|
||||
result.Selections.ShouldNotContain(s => s.OptionDefinitionId == boxColor.Id);
|
||||
result.Errors.ShouldContain(e => e.Contains("required", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private async Task<(Product Model, OptionDefinition Length, OptionDefinition Color, OptionValue Red)> BuildModelAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var model = await Product.CreateModel(
|
||||
$"rule-model-{Guid.NewGuid():N}",
|
||||
$"rule-model-{Guid.NewGuid():N}",
|
||||
description: null,
|
||||
_checker,
|
||||
cancellationToken: cancellationToken);
|
||||
model.SetBasePrice(10m);
|
||||
|
||||
var lengthOption = await OptionDefinition.CreateNumber(
|
||||
model,
|
||||
"length",
|
||||
"Length",
|
||||
"ft",
|
||||
0m,
|
||||
100m,
|
||||
1m,
|
||||
0.5m,
|
||||
_checker,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
var colorOption = await OptionDefinition.CreateChoice(model, "color", "Color", _checker, cancellationToken: cancellationToken);
|
||||
var redValue = colorOption.AddValue("red", "Red", 0m, PriceDeltaKind.Absolute);
|
||||
|
||||
return (model, lengthOption, colorOption, redValue);
|
||||
}
|
||||
|
||||
private sealed class StubUniqueChecker : IUniqueChecker
|
||||
{
|
||||
public Task<bool> CategoryNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductModelNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSlugIsUnique(string? slug, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSkuIsUnique(string sku, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> OptionCodeIsUniqueForProduct(Guid productId, string code, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using Prefab.Catalog.Domain.Services;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Unit.Modules.Catalog.Domain.Services;
|
||||
|
||||
[Trait(TraitName.Category, TraitCategory.Unit)]
|
||||
public sealed class VariantResolverShould
|
||||
{
|
||||
private readonly StubUniqueChecker _checker = new();
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveVariantFromAxisSelections()
|
||||
{
|
||||
var context = await BuildModelWithVariantsAsync();
|
||||
var resolver = new VariantResolver();
|
||||
|
||||
var selections = new[]
|
||||
{
|
||||
new OptionSelection(context.ColorOption.Id, context.RedValue.Id, null),
|
||||
new OptionSelection(context.DepthOption.Id, context.Depth24Value.Id, null)
|
||||
};
|
||||
|
||||
var result = resolver.Resolve(context.Model, selections);
|
||||
|
||||
result.IsSuccess.ShouldBeTrue();
|
||||
result.Variant.ShouldBe(context.Red24Variant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnErrorWhenAxisSelectionMissing()
|
||||
{
|
||||
var context = await BuildModelWithVariantsAsync();
|
||||
var resolver = new VariantResolver();
|
||||
|
||||
var selections = new[]
|
||||
{
|
||||
new OptionSelection(context.ColorOption.Id, context.RedValue.Id, null)
|
||||
};
|
||||
|
||||
var result = resolver.Resolve(context.Model, selections);
|
||||
|
||||
result.IsSuccess.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("axis", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveBySkuWhenProvided()
|
||||
{
|
||||
var context = await BuildModelWithVariantsAsync();
|
||||
var resolver = new VariantResolver();
|
||||
|
||||
var result = resolver.Resolve(context.Model, Array.Empty<OptionSelection>(), context.Blue34Variant.Sku);
|
||||
|
||||
result.IsSuccess.ShouldBeTrue();
|
||||
result.Variant.ShouldBe(context.Blue34Variant);
|
||||
}
|
||||
|
||||
private async Task<ModelContext> BuildModelWithVariantsAsync()
|
||||
{
|
||||
var model = await Product.CreateModel($"variant-model-{Guid.NewGuid():N}", $"variant-model-{Guid.NewGuid():N}", null, _checker);
|
||||
|
||||
var colorOption = await OptionDefinition.CreateChoice(model, "color", "Color", _checker, isVariantAxis: true);
|
||||
var redValue = colorOption.AddValue("red", "Red", 0m, PriceDeltaKind.Absolute);
|
||||
var blueValue = colorOption.AddValue("blue", "Blue", 0m, PriceDeltaKind.Absolute);
|
||||
|
||||
var depthOption = await OptionDefinition.CreateChoice(model, "depth", "Depth", _checker, isVariantAxis: true);
|
||||
var depth24Value = depthOption.AddValue("depth_24", "24\"", 0m, PriceDeltaKind.Absolute);
|
||||
var depth34Value = depthOption.AddValue("depth_34", "34\"", 0m, PriceDeltaKind.Absolute);
|
||||
|
||||
var red24Variant = await Product.CreateVariant(model.Id, $"SKU-{Guid.NewGuid():N}", "Red 24", 20m, _checker);
|
||||
red24Variant.AxisValues.Add(new VariantAxisValue
|
||||
{
|
||||
ProductVariant = red24Variant,
|
||||
ProductVariantId = red24Variant.Id,
|
||||
OptionDefinition = colorOption,
|
||||
OptionDefinitionId = colorOption.Id,
|
||||
OptionValue = redValue,
|
||||
OptionValueId = redValue.Id
|
||||
});
|
||||
red24Variant.AxisValues.Add(new VariantAxisValue
|
||||
{
|
||||
ProductVariant = red24Variant,
|
||||
ProductVariantId = red24Variant.Id,
|
||||
OptionDefinition = depthOption,
|
||||
OptionDefinitionId = depthOption.Id,
|
||||
OptionValue = depth24Value,
|
||||
OptionValueId = depth24Value.Id
|
||||
});
|
||||
|
||||
var blue34Variant = await Product.CreateVariant(model.Id, $"SKU-{Guid.NewGuid():N}", "Blue 34", 22m, _checker);
|
||||
blue34Variant.AxisValues.Add(new VariantAxisValue
|
||||
{
|
||||
ProductVariant = blue34Variant,
|
||||
ProductVariantId = blue34Variant.Id,
|
||||
OptionDefinition = colorOption,
|
||||
OptionDefinitionId = colorOption.Id,
|
||||
OptionValue = blueValue,
|
||||
OptionValueId = blueValue.Id
|
||||
});
|
||||
blue34Variant.AxisValues.Add(new VariantAxisValue
|
||||
{
|
||||
ProductVariant = blue34Variant,
|
||||
ProductVariantId = blue34Variant.Id,
|
||||
OptionDefinition = depthOption,
|
||||
OptionDefinitionId = depthOption.Id,
|
||||
OptionValue = depth34Value,
|
||||
OptionValueId = depth34Value.Id
|
||||
});
|
||||
|
||||
model.AttachVariant(red24Variant);
|
||||
model.AttachVariant(blue34Variant);
|
||||
|
||||
return new ModelContext(
|
||||
model,
|
||||
colorOption,
|
||||
depthOption,
|
||||
redValue,
|
||||
depth24Value,
|
||||
blue34Variant,
|
||||
red24Variant,
|
||||
depth34Value);
|
||||
}
|
||||
|
||||
private sealed record ModelContext(
|
||||
Product Model,
|
||||
OptionDefinition ColorOption,
|
||||
OptionDefinition DepthOption,
|
||||
OptionValue RedValue,
|
||||
OptionValue Depth24Value,
|
||||
Product Blue34Variant,
|
||||
Product Red24Variant,
|
||||
OptionValue Depth34Value);
|
||||
|
||||
private sealed class StubUniqueChecker : IUniqueChecker
|
||||
{
|
||||
public Task<bool> CategoryNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductModelNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSlugIsUnique(string? slug, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSkuIsUnique(string sku, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> OptionCodeIsUniqueForProduct(Guid productId, string code, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user