This commit is contained in:
2025-10-27 17:39:18 -04:00
commit 31f723bea4
1579 changed files with 642409 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Prefab.Catalog.Api.Data;
using Prefab.Catalog.Data;
using Prefab.Catalog.Data.Services;
using Prefab.Catalog.Domain.Entities;
using Prefab.Catalog.Domain.Services;
using Prefab.Data;
using Prefab.Handler;
using Shouldly;
namespace Prefab.Tests.Unit.Catalog;
public sealed class CatalogSeederShould
{
[Fact]
public async Task PopulateCatalogDataInSqliteDatabase()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync(TestContext.Current.CancellationToken);
var services = new ServiceCollection();
services.AddSingleton<IHandlerContextAccessor, HandlerContextAccessor>();
services.AddDbContext<TestCatalogDb>(options =>
{
options.UseSqlite(connection);
});
services.AddDbContextFactory<TestCatalogDb>(options =>
{
options.UseSqlite(connection);
}, ServiceLifetime.Scoped);
services.AddScoped<AppDb>(sp => sp.GetRequiredService<TestCatalogDb>());
services.AddScoped<IPrefabDb>(sp => sp.GetRequiredService<TestCatalogDb>());
services.AddScoped<IModuleDb>(sp => sp.GetRequiredService<TestCatalogDb>());
services.AddScoped<IModuleDbReadOnly>(sp => sp.GetRequiredService<TestCatalogDb>());
services.AddScoped<ICatalogDbContextFactory, TestCatalogDbFactory>();
services.AddScoped<IUniqueChecker, UniqueChecker>();
services.AddSingleton<ILogger<Seeder>>(_ => NullLogger<Seeder>.Instance);
services.AddScoped<Seeder>();
await using var provider = services.BuildServiceProvider();
await using (var scope = provider.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<TestCatalogDb>();
await db.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken);
}
await using (var scope = provider.CreateAsyncScope())
{
var seeder = scope.ServiceProvider.GetRequiredService<Seeder>();
await seeder.Execute(scope.ServiceProvider, TestContext.Current.CancellationToken);
}
await using (var scope = provider.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<TestCatalogDb>();
var allCategories = await db.Categories
.Where(c => c.DeletedOn == null)
.ToListAsync(TestContext.Current.CancellationToken);
allCategories.ShouldNotBeEmpty("Catalog seeder should create categories.");
var categories = allCategories
.Select(c => c.Slug)
.Where(slug => !string.IsNullOrWhiteSpace(slug))
.Select(slug => slug!.Trim())
.ToList();
var expectedSlugs = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"ceiling-supports",
"boxes-and-covers",
"prefab-assemblies",
"rod-strut-hardware"
};
categories.ShouldBeSubsetOf(expectedSlugs, "Seeded catalog should only include the expected root categories.");
categories.Count.ShouldBe(expectedSlugs.Count, "Seeded catalog should include all expected root categories.");
var products = await db.Products
.Where(p => p.DeletedOn == null && p.Kind == ProductKind.Model)
.Include(p => p.Variants)
.Select(p => new { p.Slug, p.Name, VariantCount = p.Variants.Count })
.ToListAsync(TestContext.Current.CancellationToken);
products.ShouldNotBeEmpty("Catalog seeder should introduce product models for listing.");
products.Any(p => string.Equals(p.Slug, "ceiling-tbar-box-assembly", StringComparison.OrdinalIgnoreCase)).ShouldBeTrue();
products.Any(p => string.Equals(p.Slug, "fan-hanger-assembly", StringComparison.OrdinalIgnoreCase)).ShouldBeTrue();
products.Any(p => p.VariantCount > 0).ShouldBeTrue("Seed should include at least one model with variants.");
}
}
private sealed class TestCatalogDb : AppDb
{
public TestCatalogDb(DbContextOptions<TestCatalogDb> options, IHandlerContextAccessor accessor)
: base(options, accessor)
{
}
protected override void PrefabOnModelCreating(ModelBuilder builder)
{
base.PrefabOnModelCreating(builder);
foreach (var entity in builder.Model.GetEntityTypes())
{
var rowVersionProperty = entity.FindProperty("RowVersion");
if (rowVersionProperty is not null)
{
rowVersionProperty.IsNullable = true;
}
}
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
foreach (var entry in ChangeTracker.Entries().Where(e => e.State == EntityState.Added))
{
var rowVersion = entry.Properties.FirstOrDefault(p => string.Equals(p.Metadata.Name, "RowVersion", StringComparison.Ordinal));
if (rowVersion is not null && rowVersion.CurrentValue is null)
{
rowVersion.CurrentValue = Guid.NewGuid().ToByteArray();
}
}
return base.SaveChangesAsync(cancellationToken);
}
}
private sealed class TestCatalogDbFactory(IDbContextFactory<TestCatalogDb> dbFactory) : ICatalogDbContextFactory
{
public async ValueTask<IModuleDb> CreateWritableAsync(CancellationToken cancellationToken = default) =>
await dbFactory.CreateDbContextAsync(cancellationToken);
public async ValueTask<IModuleDbReadOnly> CreateReadOnlyAsync(CancellationToken cancellationToken = default) =>
await dbFactory.CreateDbContextAsync(cancellationToken);
}
}

View File

@@ -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");
}
}

View File

@@ -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!)!;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,153 @@
using FluentValidation;
using Moq;
using Prefab.Shared.Catalog;
using Prefab.Shared.Catalog.Products;
using Prefab.Tests;
using Prefab.Web.Client.Models.Catalog;
using Prefab.Web.Client.Models.Home;
using Prefab.Web.Client.Models.Shared;
using Prefab.Web.Gateway;
using Shouldly;
using UrlPolicy = Prefab.Web.UrlPolicy.UrlPolicy;
namespace Prefab.Tests.Unit.Web.Gateway;
[Trait(TraitName.Category, TraitCategory.Unit)]
public sealed class HomeServiceShould
{
[Fact]
public async Task AggregateFeaturedProductsAndCategories()
{
var (service, productClient) = CreateService();
var featured = CreateNode("Featured Leaf", "featured-leaf", displayOrder: 2, isFeatured: true);
var secondary = CreateNode("Secondary Leaf", "secondary-leaf", displayOrder: 1, isFeatured: false);
productClient
.Setup(client => client.GetCategoryTree(3, It.IsAny<CancellationToken>()))
.ReturnsAsync(new GetCategoryTree.Response(new[] { featured, secondary }));
productClient
.Setup(client => client.GetCategoryModels("featured-leaf", It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateListingResponse("featured-leaf", total: 3, cards: new[]
{
CreateProductCardDto("Featured 1", "featured-1", 19m),
CreateProductCardDto("Duplicate", "duplicate", 21m)
}));
productClient
.Setup(client => client.GetCategoryModels("secondary-leaf", It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateListingResponse("secondary-leaf", total: 2, cards: new[]
{
CreateProductCardDto("Secondary 1", "secondary-1", 15m),
CreateProductCardDto("Duplicate", "duplicate", 21m)
}));
var result = await service.Get(CancellationToken.None);
result.IsSuccess.ShouldBeTrue();
var payload = result.Value.ShouldNotBeNull();
payload.LatestCategories.Count.ShouldBe(2);
payload.LatestCategories.Select(category => category.Title).ShouldBe(["Featured Leaf", "Secondary Leaf"]);
payload.FeaturedProducts.Count.ShouldBe(3);
payload.FeaturedProducts.Select(product => product.Url).ShouldBe([
UrlPolicy.BrowseProduct("featured-1"),
UrlPolicy.BrowseProduct("duplicate"),
UrlPolicy.BrowseProduct("secondary-1")
]);
}
[Fact]
public async Task SkipCategoriesWithoutProducts()
{
var (service, productClient) = CreateService();
var empty = CreateNode("Empty Leaf", "empty-leaf", displayOrder: 0, isFeatured: true);
var populated = CreateNode("Populated Leaf", "populated-leaf", displayOrder: 1, isFeatured: false);
productClient
.Setup(client => client.GetCategoryTree(3, It.IsAny<CancellationToken>()))
.ReturnsAsync(new GetCategoryTree.Response(new[] { empty, populated }));
productClient
.Setup(client => client.GetCategoryModels("empty-leaf", It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateListingResponse("empty-leaf", total: 0, cards: Array.Empty<GetCategoryModels.ProductCardDto>()));
productClient
.Setup(client => client.GetCategoryModels("populated-leaf", It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateListingResponse("populated-leaf", total: 1, cards: new[]
{
CreateProductCardDto("Populated", "populated-1", 32m)
}));
var result = await service.Get(CancellationToken.None);
result.IsSuccess.ShouldBeTrue();
var payload = result.Value.ShouldNotBeNull();
payload.LatestCategories.Count.ShouldBe(1);
payload.LatestCategories.Single().Title.ShouldBe("Populated Leaf");
payload.FeaturedProducts.Single().Url.ShouldBe(UrlPolicy.BrowseProduct("populated-1"));
}
private static (Home.Service Service, Mock<IProductClient> ProductClient) CreateService()
{
var moduleClient = new Mock<IModuleClient>();
var productClient = new Mock<IProductClient>();
moduleClient.SetupGet(client => client.Product).Returns(productClient.Object);
var listingService = new Products.ListingService(moduleClient.Object);
var service = new Home.Service(moduleClient.Object, listingService);
return (service, productClient);
}
private static GetCategoryModels.CategoryNodeDto CreateNode(string name, string slug, int displayOrder, bool isFeatured) =>
new(
Guid.NewGuid(),
name,
slug,
Description: null,
HeroImageUrl: null,
Icon: null,
DisplayOrder: displayOrder,
IsFeatured: isFeatured,
IsLeaf: true,
Children: Array.Empty<GetCategoryModels.CategoryNodeDto>());
private static GetCategoryModels.Response CreateListingResponse(
string slug,
int total,
IReadOnlyList<GetCategoryModels.ProductCardDto> cards)
{
var category = new GetCategoryModels.CategoryDetailDto(
Guid.NewGuid(),
slug,
slug,
Description: null,
HeroImageUrl: null,
Icon: null,
IsLeaf: true,
Children: Array.Empty<GetCategoryModels.CategoryNodeDto>());
var response = new GetCategoryModels.CategoryProductsResponse(
category,
cards,
total,
Page: 1,
PageSize: Math.Max(1, cards.Count));
return new GetCategoryModels.Response(response);
}
private static GetCategoryModels.ProductCardDto CreateProductCardDto(string name, string slug, decimal price) =>
new(
Guid.NewGuid(),
name,
slug,
FromPrice: price,
PrimaryImageUrl: null,
LastModifiedOn: DateTimeOffset.UtcNow);
}

View File

@@ -0,0 +1,181 @@
using FluentValidation;
using FluentValidation.Results;
using Moq;
using Prefab.Shared.Catalog;
using Prefab.Shared.Catalog.Products;
using Prefab.Web.Gateway.Shared;
using Shouldly;
namespace Prefab.Tests.Unit.Web.Gateway;
[Trait(TraitName.Category, TraitCategory.Unit)]
public sealed class NavMenuServiceShould
{
[Fact]
public async Task ReturnOnlyFeaturedChildrenWhenAvailable()
{
var moduleClient = new Mock<IModuleClient>();
var productClient = new Mock<IProductClient>();
moduleClient.SetupGet(c => c.Product).Returns(productClient.Object);
var featuredChild = CreateNode(
name: "Featured",
slug: "featured",
displayOrder: 2,
isFeatured: true,
isLeaf: true);
var secondaryChild = CreateNode(
name: "Secondary",
slug: "secondary",
displayOrder: 1,
isFeatured: false,
isLeaf: true);
var root = CreateNode(
name: "Root",
slug: "root",
displayOrder: 0,
isFeatured: false,
isLeaf: false,
children: new[] { secondaryChild, featuredChild });
productClient
.Setup(p => p.GetCategoryTree(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new GetCategoryTree.Response(new[] { root }));
var sut = new NavMenu.Service(moduleClient.Object);
var result = await sut.Get(2, CancellationToken.None);
result.IsSuccess.ShouldBeTrue();
var model = result.Value.ShouldNotBeNull();
model.Depth.ShouldBe(2);
model.Columns.Count.ShouldBe(1);
var column = model.Columns.Single();
column.Root.Name.ShouldBe("Root");
column.Root.Url.ShouldBe("/catalog/category?category-slug=root");
column.Items.Count.ShouldBe(1);
var item = column.Items.Single();
item.Name.ShouldBe("Featured");
item.Url.ShouldBe("/catalog/products?category-slug=featured");
column.HasMore.ShouldBeFalse();
column.SeeAllUrl.ShouldBe("/catalog/category?category-slug=root");
}
[Fact]
public async Task FallBackToAllChildrenWhenNoFeatured()
{
var moduleClient = new Mock<IModuleClient>();
var productClient = new Mock<IProductClient>();
moduleClient.SetupGet(c => c.Product).Returns(productClient.Object);
var firstChild = CreateNode(
name: "Alpha",
slug: "alpha",
displayOrder: 3,
isFeatured: false,
isLeaf: true);
var secondChild = CreateNode(
name: "Beta",
slug: "beta",
displayOrder: 1,
isFeatured: false,
isLeaf: false);
var thirdChild = CreateNode(
name: "Gamma",
slug: "gamma",
displayOrder: 2,
isFeatured: false,
isLeaf: true);
var root = CreateNode(
name: "Root",
slug: "root",
displayOrder: 0,
isFeatured: false,
isLeaf: false,
children: new[] { firstChild, secondChild, thirdChild });
productClient
.Setup(p => p.GetCategoryTree(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new GetCategoryTree.Response(new[] { root }));
var sut = new NavMenu.Service(moduleClient.Object);
var result = await sut.Get(1, CancellationToken.None);
result.IsSuccess.ShouldBeTrue();
var model = result.Value.ShouldNotBeNull();
model.Depth.ShouldBe(1);
var names = model.Columns.Single().Items.Select(i => i.Name).ToList();
names.ShouldBe(["Beta", "Gamma", "Alpha"]);
}
[Fact]
public async Task ClampDepthBeforeCallingCatalog()
{
var moduleClient = new Mock<IModuleClient>();
var productClient = new Mock<IProductClient>();
moduleClient.SetupGet(c => c.Product).Returns(productClient.Object);
var root = CreateNode("Root", "root", 0, false, false);
productClient
.Setup(p => p.GetCategoryTree(2, It.IsAny<CancellationToken>()))
.ReturnsAsync(new GetCategoryTree.Response(new[] { root }));
var sut = new NavMenu.Service(moduleClient.Object);
await sut.Get(10, CancellationToken.None);
productClient.Verify(p => p.GetCategoryTree(2, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ReturnProblemWhenCatalogValidationFails()
{
var moduleClient = new Mock<IModuleClient>();
var productClient = new Mock<IProductClient>();
moduleClient.SetupGet(c => c.Product).Returns(productClient.Object);
productClient
.Setup(p => p.GetCategoryTree(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new ValidationException([new ValidationFailure("Depth", "invalid")]));
var sut = new NavMenu.Service(moduleClient.Object);
var result = await sut.Get(-1, CancellationToken.None);
result.IsSuccess.ShouldBeFalse();
result.Value.ShouldBeNull();
var problem = result.Problem.ShouldNotBeNull();
problem.StatusCode.ShouldBe(400);
problem.Detail.ShouldBe("Request did not satisfy validation rules.");
problem.Errors.ShouldNotBeNull();
problem.Errors!.ShouldContainKey("Depth");
problem.Errors["Depth"].ShouldContain("invalid");
}
private static GetCategoryModels.CategoryNodeDto CreateNode(
string name,
string slug,
int displayOrder,
bool isFeatured,
bool isLeaf,
IReadOnlyList<GetCategoryModels.CategoryNodeDto>? children = null)
{
return new GetCategoryModels.CategoryNodeDto(
Guid.NewGuid(),
name,
slug,
null,
null,
null,
displayOrder,
isFeatured,
isLeaf,
children ?? []);
}
}

View File

@@ -0,0 +1,121 @@
using Moq;
using Prefab.Shared.Catalog;
using Prefab.Shared.Catalog.Products;
using Prefab.Tests;
using Prefab.Web.Client.Models.Catalog;
using Prefab.Web.Gateway;
using Shouldly;
namespace Prefab.Tests.Unit.Web.Gateway;
[Trait(TraitName.Category, TraitCategory.Unit)]
public sealed class ProductListingServiceShould
{
[Fact]
public async Task MapPricesAndImagesFromModuleResponse()
{
var categorySlug = "ceiling-supports";
var moduleClient = new Mock<IModuleClient>();
var productClient = new Mock<IProductClient>();
moduleClient.SetupGet(client => client.Product).Returns(productClient.Object);
var category = new GetCategoryModels.CategoryDetailDto(
Guid.NewGuid(),
"Ceiling Supports",
categorySlug,
Description: "Fixtures for ceiling support assemblies.",
HeroImageUrl: "/media/categories/ceiling.jpg",
Icon: null,
IsLeaf: true,
Children: Array.Empty<GetCategoryModels.CategoryNodeDto>());
var cardId = Guid.NewGuid();
var response = new GetCategoryModels.CategoryProductsResponse(
category,
new[]
{
new GetCategoryModels.ProductCardDto(
cardId,
"Fan Hanger Assembly",
"fan-hanger-assembly",
FromPrice: 129.99m,
PrimaryImageUrl: "/media/products/fan-hanger.jpg",
LastModifiedOn: DateTimeOffset.UtcNow)
},
Total: 1,
Page: 1,
PageSize: 24);
productClient
.Setup(client => client.GetCategoryModels(categorySlug, null, null, null, null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new GetCategoryModels.Response(response));
var service = new Products.ListingService(moduleClient.Object);
var result = await service.GetCategoryProducts(categorySlug, CancellationToken.None);
result.IsSuccess.ShouldBeTrue();
result.Value.ShouldNotBeNull();
var model = result.Value;
model.Category.Slug.ShouldBe(categorySlug);
model.Products.Count.ShouldBe(1);
var card = model.Products.Single();
card.Id.ShouldBe(cardId);
card.Title.ShouldBe("Fan Hanger Assembly");
card.Slug.ShouldBe("fan-hanger-assembly");
card.PrimaryImageUrl.ShouldBe("/media/products/fan-hanger.jpg");
card.IsPriced.ShouldBeTrue();
card.FromPrice.ShouldNotBeNull();
card.FromPrice!.Amount.ShouldBe(129.99m);
}
[Fact]
public async Task MarkProductsWithoutPriceAsUnpriced()
{
var categorySlug = "rod-strut-hardware";
var moduleClient = new Mock<IModuleClient>();
var productClient = new Mock<IProductClient>();
moduleClient.SetupGet(client => client.Product).Returns(productClient.Object);
var category = new GetCategoryModels.CategoryDetailDto(
Guid.NewGuid(),
"Rod & Strut Hardware",
categorySlug,
Description: null,
HeroImageUrl: null,
Icon: null,
IsLeaf: true,
Children: Array.Empty<GetCategoryModels.CategoryNodeDto>());
var response = new GetCategoryModels.CategoryProductsResponse(
category,
new[]
{
new GetCategoryModels.ProductCardDto(
Guid.NewGuid(),
"Unpriced Assembly",
"unpriced-assembly",
FromPrice: null,
PrimaryImageUrl: null,
LastModifiedOn: DateTimeOffset.UtcNow)
},
Total: 1,
Page: 1,
PageSize: 12);
productClient
.Setup(client => client.GetCategoryModels(categorySlug, null, null, null, null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new GetCategoryModels.Response(response));
var service = new Products.ListingService(moduleClient.Object);
var result = await service.GetCategoryProducts(categorySlug, CancellationToken.None);
result.IsSuccess.ShouldBeTrue();
result.Value.ShouldNotBeNull();
var card = result.Value.Products.Single();
card.IsPriced.ShouldBeFalse();
card.FromPrice.ShouldBeNull();
}
}

View File

@@ -0,0 +1,118 @@
using Prefab.Catalog.Domain.Entities;
using Prefab.Catalog.Domain.Services;
using Prefab.Tests;
using Prefab.Web.Pricing;
using Shouldly;
namespace Prefab.Tests.Unit.Web.Pricing;
[Trait(TraitName.Category, TraitCategory.Unit)]
public sealed class FromPriceCalculatorShould
{
private readonly FromPriceCalculator _calculator = new();
private readonly IUniqueChecker _uniqueChecker = new AlwaysUniqueChecker();
[Fact]
public async Task UseModelPriceWhenAvailable()
{
var cancellationToken = TestContext.Current.CancellationToken;
var product = await Product.CreateModel(
name: "Model A",
slug: "model-a",
description: null,
uniqueChecker: _uniqueChecker,
cancellationToken: cancellationToken);
product.SetBasePrice(199.99m);
var variant = await Product.CreateVariant(
product.Id,
sku: "SKU-1",
name: "Variant 1",
price: 149.99m,
uniqueChecker: _uniqueChecker,
cancellationToken: cancellationToken);
product.AttachVariant(variant);
var result = _calculator.Compute(product);
result.IsPriced.ShouldBeTrue();
result.Amount.ShouldBe(199.99m);
result.Currency.ShouldBe("USD");
}
[Fact]
public async Task UseLowestVariantPriceWhenModelPriceMissing()
{
var cancellationToken = TestContext.Current.CancellationToken;
var product = await Product.CreateModel(
name: "Model B",
slug: "model-b",
description: null,
uniqueChecker: _uniqueChecker,
cancellationToken: cancellationToken);
var highVariant = await Product.CreateVariant(
product.Id,
sku: "HIGH",
name: "High Variant",
price: 249.50m,
uniqueChecker: _uniqueChecker,
cancellationToken: cancellationToken);
var lowVariant = await Product.CreateVariant(
product.Id,
sku: "LOW",
name: "Low Variant",
price: 125.25m,
uniqueChecker: _uniqueChecker,
cancellationToken: cancellationToken);
product.AttachVariant(highVariant);
product.AttachVariant(lowVariant);
var result = _calculator.Compute(product);
result.IsPriced.ShouldBeTrue();
result.Amount.ShouldBe(125.25m);
result.Currency.ShouldBe("USD");
}
[Fact]
public async Task ReturnUnpricedWhenNoPricesAvailable()
{
var cancellationToken = TestContext.Current.CancellationToken;
var product = await Product.CreateModel(
name: "Model C",
slug: "model-c",
description: null,
uniqueChecker: _uniqueChecker,
cancellationToken: cancellationToken);
var result = _calculator.Compute(product);
result.IsPriced.ShouldBeFalse();
result.Amount.ShouldBeNull();
result.Currency.ShouldBeNull();
}
private sealed class AlwaysUniqueChecker : 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);
}
}

View File

@@ -0,0 +1,130 @@
using FluentValidation;
using FluentValidation.Results;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.EntityFrameworkCore;
using Prefab.Handler;
using Prefab.Catalog.Domain.Exceptions;
using Prefab.Web.Shared;
using Shouldly;
using System.Text.Json;
namespace Prefab.Tests.Unit.Web.Shared;
public sealed class ExceptionHandlerShould
{
[Fact]
public async Task ReturnBadRequestForValidationFailures()
{
var failure = new ValidationFailure("productId", "Either productId or sku must be provided.");
var exception = new ValidationException(new[] { failure });
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
var services = new ServiceCollection();
services.AddSingleton<IHandlerContextAccessor, HandlerContextAccessor>();
using var provider = services.BuildServiceProvider();
context.RequestServices = provider;
var middleware = new ExceptionHandler(
_ => throw exception,
NullLogger<ExceptionHandler>.Instance,
Options.Create(new JsonOptions()));
await middleware.InvokeAsync(context);
context.Response.StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
context.Response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(context.Response.Body);
var payload = await reader.ReadToEndAsync(TestContext.Current.CancellationToken);
using var document = JsonDocument.Parse(payload);
var root = document.RootElement;
var messages = new List<string>();
if (root.TryGetProperty("errors", out var errorsElement))
{
messages.AddRange(
errorsElement
.EnumerateObject()
.SelectMany(property => property.Value.EnumerateArray())
.Select(element => element.GetString())
.Where(message => !string.IsNullOrWhiteSpace(message))!
.Cast<string>());
}
if (messages.Count == 0 && root.TryGetProperty("detail", out var detailElement))
{
messages.Add(detailElement.GetString() ?? string.Empty);
}
messages.ShouldNotBeEmpty(payload);
messages.ShouldContain(message => message.Equals("Either productId or sku must be provided.", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task ReturnBadRequestForDomainExceptions()
{
var exception = new DomainValidationException("Demo domain failure.");
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
var services = new ServiceCollection();
services.AddSingleton<IHandlerContextAccessor, HandlerContextAccessor>();
using var provider = services.BuildServiceProvider();
context.RequestServices = provider;
var middleware = new ExceptionHandler(
_ => throw exception,
NullLogger<ExceptionHandler>.Instance,
Options.Create(new JsonOptions()));
await middleware.InvokeAsync(context);
context.Response.StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
context.Response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(context.Response.Body);
var payload = await reader.ReadToEndAsync(TestContext.Current.CancellationToken);
using var document = JsonDocument.Parse(payload);
var detail = document.RootElement.GetProperty("detail").GetString();
document.RootElement.GetProperty("type").GetString().ShouldBe("https://prefab.dev/problems/domain-error");
detail.ShouldNotBeNull();
detail!.ShouldContain("Demo domain failure.");
}
[Fact]
public async Task ReturnConflictForConcurrencyExceptions()
{
var exception = new DbUpdateConcurrencyException("Concurrency conflict");
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
var services = new ServiceCollection();
services.AddSingleton<IHandlerContextAccessor, HandlerContextAccessor>();
using var provider = services.BuildServiceProvider();
context.RequestServices = provider;
var middleware = new ExceptionHandler(
_ => throw exception,
NullLogger<ExceptionHandler>.Instance,
Options.Create(new JsonOptions()));
await middleware.InvokeAsync(context);
context.Response.StatusCode.ShouldBe(StatusCodes.Status409Conflict);
context.Response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(context.Response.Body);
var payload = await reader.ReadToEndAsync(TestContext.Current.CancellationToken);
using var document = JsonDocument.Parse(payload);
document.RootElement.GetProperty("type").GetString().ShouldBe("https://prefab.dev/problems/concurrency-conflict");
}
}

View File

@@ -0,0 +1,75 @@
using Prefab.Tests;
using Prefab.Web.UrlPolicy;
using Shouldly;
namespace Prefab.Tests.Unit.Web.Url;
[Trait(TraitName.Category, TraitCategory.Unit)]
public sealed class UrlPolicyShould
{
[Fact]
public void BuildAndParseCategoryRoundTrip()
{
var url = UrlPolicy.Category(
slug: "ceiling-supports",
page: 2,
pageSize: 48,
sort: "price:desc",
view: "list");
var uri = new Uri("https://prefab.test" + url);
UrlPolicy.TryParseCategory(uri, out var slug, out var page, out var pageSize, out var sort, out var view)
.ShouldBeTrue();
slug.ShouldBe("ceiling-supports");
page.ShouldBe(2);
pageSize.ShouldBe(48);
sort.ShouldBe("price:desc");
view.ShouldBe("list");
UrlPolicy.Category(slug, page, pageSize, sort, view).ShouldBe(url);
}
[Fact]
public void ParseCategoryDefaultsWhenMissingOrInvalid()
{
var url = UrlPolicy.Category(
slug: "boxes-and-covers",
page: 0,
pageSize: 500,
sort: null,
view: string.Empty);
UrlPolicy.TryParseCategory(new Uri(url, UriKind.Relative), out var slug, out var page, out var pageSize, out var sort, out var view)
.ShouldBeTrue();
slug.ShouldBe("boxes-and-covers");
page.ShouldBe(UrlPolicy.DefaultPage);
pageSize.ShouldBe(UrlPolicy.DefaultPageSize);
sort.ShouldBe(UrlPolicy.DefaultSort);
view.ShouldBe(UrlPolicy.DefaultView);
}
[Fact]
public void BuildAndParseProductsRoundTrip()
{
var url = UrlPolicy.Products(
page: 3,
pageSize: 12,
sort: "name:desc",
view: "grid");
var uri = new Uri("https://prefab.test" + url);
UrlPolicy.TryParseProducts(uri, out var page, out var pageSize, out var sort, out var view)
.ShouldBeTrue();
page.ShouldBe(3);
pageSize.ShouldBe(12);
sort.ShouldBe("name:desc");
view.ShouldBe("grid");
UrlPolicy.Products(page, pageSize, sort, view).ShouldBe(url);
}
}