This commit is contained in:
2025-10-27 21:39:50 -04:00
parent 31f723bea4
commit 2fecd5b315
22 changed files with 1198 additions and 14 deletions

View File

@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Prefab.Handler;
using Prefab.Web.Data;
@@ -50,7 +51,7 @@ public class PrefabCompositeFixture : IAsyncLifetime
catalogProject,
catalogPort,
overrides: null,
configureServices: ConfigureTestAuthentication);
configureServices: ConfigureCatalogServices);
await CatalogApi.InitializeAsync();
catalogAddress = CatalogApi.HttpAddress;
@@ -68,7 +69,7 @@ public class PrefabCompositeFixture : IAsyncLifetime
prefabProject,
prefabPort,
overrides,
ConfigureTestAuthentication);
ConfigureWebServices);
await PrefabWeb.InitializeAsync();
}
@@ -113,7 +114,7 @@ public class PrefabCompositeFixture : IAsyncLifetime
["Prefab__Catalog__Client__BaseAddress"] = catalogAddress?.ToString().TrimEnd('/') ?? string.Empty
};
private void ConfigureTestAuthentication(IServiceCollection services)
protected void ConfigureTestAuthentication(IServiceCollection services)
{
var descriptors = services.Where(d => d.ServiceType == typeof(AuthenticationStateProvider)).ToList();
foreach (var descriptor in descriptors)
@@ -125,6 +126,10 @@ public class PrefabCompositeFixture : IAsyncLifetime
services.AddSingleton<AuthenticationStateProvider>(_ => _authenticationProvider);
}
protected virtual void ConfigureCatalogServices(IServiceCollection services) => ConfigureTestAuthentication(services);
protected virtual void ConfigureWebServices(IServiceCollection services) => ConfigureTestAuthentication(services);
private static PrefabHarnessOptions ResolveDefaultOptions()
{
var modeEnv = Environment.GetEnvironmentVariable("PREFAB_TEST_MODE")?.Trim().ToLowerInvariant();
@@ -190,6 +195,79 @@ public sealed class PrefabCompositeFixture_InProcess : PrefabCompositeFixture
};
}
public sealed class PrefabCompositeFixture_BffProduct : PrefabCompositeFixture
{
private Prefab.Shared.Catalog.Products.IProductClient? _productClient;
private readonly Prefab.Shared.Catalog.Categories.ICategoryClient _categoryClient = new NullCategoryClient();
private readonly Prefab.Shared.Catalog.Products.IPriceQuoteClient _priceQuoteClient = new NullPriceQuoteClient();
public PrefabCompositeFixture_BffProduct()
: base(new PrefabHarnessOptions(
RunMode.DebugPersistent,
EnableAspireDashboard: true,
DisablePortRandomization: true))
{
}
public void UseProductClient(Prefab.Shared.Catalog.Products.IProductClient client)
{
_productClient = client ?? throw new ArgumentNullException(nameof(client));
}
protected override bool ShouldStartCatalog => false;
protected override IDictionary<string, string?> GetWebOverrides(Uri? catalogAddress) =>
new Dictionary<string, string?>
{
["Prefab__Catalog__Client__Transport"] = "InProcess"
};
protected override void ConfigureWebServices(IServiceCollection services)
{
base.ConfigureWebServices(services);
services.RemoveAll<Prefab.Shared.Catalog.IModuleClient>();
services.RemoveAll<Prefab.Shared.Catalog.Categories.ICategoryClient>();
services.RemoveAll<Prefab.Shared.Catalog.Products.IProductClient>();
services.RemoveAll<Prefab.Shared.Catalog.Products.IPriceQuoteClient>();
services.AddScoped<Prefab.Shared.Catalog.Categories.ICategoryClient>(_ => _categoryClient);
services.AddScoped<Prefab.Shared.Catalog.Products.IPriceQuoteClient>(_ => _priceQuoteClient);
services.AddScoped<Prefab.Shared.Catalog.Products.IProductClient>(_ =>
_productClient ?? throw new InvalidOperationException("Test product client has not been configured."));
services.AddScoped<Prefab.Shared.Catalog.IModuleClient>(sp =>
new Prefab.Catalog.ModuleClient(
sp.GetRequiredService<Prefab.Shared.Catalog.Categories.ICategoryClient>(),
sp.GetRequiredService<Prefab.Shared.Catalog.Products.IProductClient>(),
sp.GetRequiredService<Prefab.Shared.Catalog.Products.IPriceQuoteClient>()));
}
private sealed class NullCategoryClient : Prefab.Shared.Catalog.Categories.ICategoryClient
{
public Task<Prefab.Shared.Catalog.Categories.GetCategories.Response> GetCategories(
CancellationToken cancellationToken) =>
throw new NotSupportedException("Category retrieval is not configured for this test.");
public Task<Prefab.Shared.Catalog.Categories.CreateCategory.Response> CreateCategory(
Prefab.Shared.Catalog.Categories.CreateCategory.Request request,
CancellationToken cancellationToken) =>
throw new NotSupportedException("Category creation is not configured for this test.");
}
private sealed class NullPriceQuoteClient : Prefab.Shared.Catalog.Products.IPriceQuoteClient
{
public Task<Prefab.Shared.Catalog.Products.QuotePrice.Response> Quote(
Prefab.Shared.Catalog.Products.QuotePrice.Request request,
CancellationToken cancellationToken) =>
throw new NotSupportedException("Price quote client is not configured for this test.");
public Task<Prefab.Shared.Catalog.Products.QuotePrice.Response> QuotePrice(
Prefab.Shared.Catalog.Products.QuotePrice.Request request,
CancellationToken cancellationToken) =>
throw new NotSupportedException("Price quote client is not configured for this test.");
}
}
[CollectionDefinition("Prefab.Debug")]
public sealed class PrefabDebugCollection : ICollectionFixture<PrefabCompositeFixture_Debug>
{
@@ -204,3 +282,8 @@ public sealed class PrefabEphemeralCollection : ICollectionFixture<PrefabComposi
public sealed class PrefabInProcessCollection : ICollectionFixture<PrefabCompositeFixture_InProcess>
{
}
[CollectionDefinition("Prefab.BffProduct")]
public sealed class PrefabBffProductCollection : ICollectionFixture<PrefabCompositeFixture_BffProduct>
{
}

View File

@@ -0,0 +1,165 @@
using System.Net;
using System.Net.Http.Json;
using Prefab.Catalog.Domain.Exceptions;
using Prefab.Shared.Catalog.Products;
using Prefab.Tests.Infrastructure;
using Prefab.Web.Client.Models.Catalog;
using Prefab.Web.Client.Models.Shared;
using Shouldly;
namespace Prefab.Tests.Integration.Web.Gateway;
[Collection("Prefab.BffProduct")]
[Trait(TraitName.Category, TraitCategory.Integration)]
public sealed class ProductDetailGatewayShould(PrefabCompositeFixture_BffProduct fixture)
: IClassFixture<PrefabCompositeFixture_BffProduct>
{
[Fact]
public async Task ReturnProductModelWhenFound()
{
fixture.UseProductClient(new SuccessProductClient(CreateSampleProductResponse()));
var cancellationToken = TestContext.Current.CancellationToken;
var client = fixture.CreateHttpClientForWeb();
var response = await client.GetAsync("/bff/catalog/products/sample-widget", cancellationToken);
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var payload = await response.Content.ReadFromJsonAsync<Result<ProductDisplayModel>>(cancellationToken: cancellationToken);
payload.ShouldNotBeNull();
payload!.IsSuccess.ShouldBeTrue(payload.Problem?.Detail);
payload.Value.ShouldNotBeNull();
payload.Value!.Slug.ShouldBe("sample-widget");
payload.Value.Name.ShouldBe("Sample Widget");
payload.Value.Sku.ShouldBe("SW-001");
payload.Value.Categories.ShouldNotBeEmpty();
}
[Fact]
public async Task ReturnNotFoundWhenMissing()
{
fixture.UseProductClient(new NotFoundProductClient());
var cancellationToken = TestContext.Current.CancellationToken;
var client = fixture.CreateHttpClientForWeb();
var response = await client.GetAsync("/bff/catalog/products/missing-widget", cancellationToken);
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
var payload = await response.Content.ReadFromJsonAsync<Result<ProductDisplayModel>>(cancellationToken: cancellationToken);
payload.ShouldNotBeNull();
payload!.IsSuccess.ShouldBeFalse();
payload.Problem.ShouldNotBeNull();
payload.Problem!.StatusCode.ShouldBe((int)HttpStatusCode.NotFound);
}
private static GetProductDetail.Response CreateSampleProductResponse()
{
var optionValues = new[]
{
new GetProductDetail.OptionValueDto(Guid.NewGuid(), "red", "Red", 0m, (int)GetProductDetail.PriceDeltaKind.Absolute, null),
new GetProductDetail.OptionValueDto(Guid.NewGuid(), "blue", "Blue", 0m, (int)GetProductDetail.PriceDeltaKind.Absolute, null)
};
var optionDefinitions = new[]
{
new GetProductDetail.OptionDefinitionDto(
Guid.NewGuid(),
"color",
"Color",
(int)GetProductDetail.OptionDataType.Choice,
true,
Unit: null,
Min: null,
Max: null,
Step: null,
PricePerUnit: null,
Tiers: Array.Empty<GetProductDetail.OptionTierDto>(),
Values: optionValues,
Rules: null)
};
var specs = new[]
{
new GetProductDetail.SpecDto("Weight", "25 lb", null, null)
};
var attributes = new Dictionary<string, string>
{
["catalog.product.docs"] = """[{ "title": "Spec Sheet", "url": "https://example.com/spec.pdf" }]"""
};
var categories = new[]
{
new GetProductDetail.CategoryDto(Guid.NewGuid(), "Lighting", "lighting")
};
var product = new GetProductDetail.ProductDetailDto(
Guid.NewGuid(),
"Sample Widget",
"sample-widget",
"Sample description.",
42.50m,
"SW-001",
categories,
optionDefinitions,
specs,
attributes);
return new GetProductDetail.Response(product);
}
private sealed class SuccessProductClient(GetProductDetail.Response response) : IProductClient
{
public Task<GetCategoryModels.Response> GetCategoryModels(
string slug,
int? page,
int? pageSize,
string? sort,
string? direction,
CancellationToken cancellationToken) =>
throw new NotSupportedException("Category models are not required for this test.");
public Task<GetCategory.Response> GetCategory(string slug, CancellationToken cancellationToken) =>
throw new NotSupportedException("Category client is not required for this test.");
public Task<GetCategoryTree.Response> GetCategoryTree(int? depth, CancellationToken cancellationToken) =>
throw new NotSupportedException("Category tree is not required for this test.");
public Task<GetProductDetail.Response> GetProductDetail(string slug, CancellationToken cancellationToken) =>
Task.FromResult(response);
public Task<QuotePrice.Response> Quote(QuotePrice.Request request, CancellationToken cancellationToken) =>
throw new NotSupportedException("Price quotes are not required for this test.");
public Task<QuotePrice.Response> QuotePrice(QuotePrice.Request request, CancellationToken cancellationToken) =>
throw new NotSupportedException("Price quotes are not required for this test.");
}
private sealed class NotFoundProductClient : IProductClient
{
public Task<GetCategoryModels.Response> GetCategoryModels(
string slug,
int? page,
int? pageSize,
string? sort,
string? direction,
CancellationToken cancellationToken) =>
throw new NotSupportedException("Category models are not required for this test.");
public Task<GetCategory.Response> GetCategory(string slug, CancellationToken cancellationToken) =>
throw new NotSupportedException("Category client is not required for this test.");
public Task<GetCategoryTree.Response> GetCategoryTree(int? depth, CancellationToken cancellationToken) =>
throw new NotSupportedException("Category tree is not required for this test.");
public Task<GetProductDetail.Response> GetProductDetail(string slug, CancellationToken cancellationToken) =>
throw new CatalogNotFoundException("Product", slug);
public Task<QuotePrice.Response> Quote(QuotePrice.Request request, CancellationToken cancellationToken) =>
throw new NotSupportedException("Price quotes are not required for this test.");
public Task<QuotePrice.Response> QuotePrice(QuotePrice.Request request, CancellationToken cancellationToken) =>
throw new NotSupportedException("Price quotes are not required for this test.");
}
}

View File

@@ -72,4 +72,11 @@ public sealed class UrlPolicyShould
UrlPolicy.Products(page, pageSize, sort, view).ShouldBe(url);
}
[Fact]
public void BuildBrowseProductUrl()
{
var url = UrlPolicy.BrowseProduct("bolt & nut");
url.ShouldBe("/browse/product?slug=bolt%20%26%20nut");
}
}

View File

@@ -103,7 +103,7 @@ public sealed class ProductCardShould : BunitContext
{
Id = Guid.NewGuid(),
Title = "Aluminum Chandelier",
Url = "/catalog/product/aluminum-chandelier",
Url = "/browse/product?slug=aluminum-chandelier",
Slug = "aluminum-chandelier",
CategoryName = "Chandeliers",
CategoryUrl = "/catalog/chandeliers",

View File

@@ -0,0 +1,128 @@
using System.Net;
using Microsoft.AspNetCore.Components;
using System.Globalization;
using Bunit;
using Microsoft.Extensions.DependencyInjection;
using Prefab.Web.Client.Models.Catalog;
using Prefab.Web.Client.Models.Shared;
using Prefab.Web.Client.Pages.Browse;
using Prefab.Web.Client.Services;
using Shouldly;
namespace Prefab.Tests.Web.Client.Pages;
[Trait(TraitName.Category, TraitCategory.Unit)]
public sealed class ProductPageTests : BunitContext
{
[Fact]
public void ProductPage_Renders_Product_Details_From_Service()
{
var model = CreateSampleProduct();
Services.AddScoped<IProductDisplayService>(_ =>
new FakeProductDisplayService(Result<ProductDisplayModel>.Success(model)));
var navigation = Services.GetRequiredService<NavigationManager>();
navigation.NavigateTo(navigation.GetUriWithQueryParameter("slug", model.Slug));
var component = Render<Product>();
component.Markup.ShouldContain(model.Name);
model.Description.ShouldNotBeNull();
component.Markup.ShouldContain(model.Description!);
component.FindAll("input[type=radio]").Count.ShouldBe(2);
component.Markup.ShouldContain("Quantity");
component.FindAll(".product__add-to-cart").Count.ShouldBe(1);
component.Markup.ShouldContain("Documents");
component.Markup.ShouldContain("Spec Sheet");
component.Markup.ShouldContain("SW-001");
component.Markup.ShouldContain("Lighting");
var expectedPrice = model.BasePrice!.Value.ToString("C", CultureInfo.CurrentCulture);
component.Markup.ShouldContain(expectedPrice);
}
[Fact]
public void ProductPage_Shows_Problem_When_NotFound()
{
Services.AddScoped<IProductDisplayService>(_ =>
new FakeProductDisplayService(Result<ProductDisplayModel>.Failure(new ResultProblem
{
Title = "Product not found.",
Detail = "Product 'unknown' was not found.",
StatusCode = (int)HttpStatusCode.NotFound
})));
var navigation = Services.GetRequiredService<NavigationManager>();
navigation.NavigateTo(navigation.GetUriWithQueryParameter("slug", "unknown"));
var component = Render<Product>();
component.Markup.ShouldContain("Product not found.");
component.FindAll(".alert-warning").Count.ShouldBe(1);
}
private static ProductDisplayModel CreateSampleProduct() => new()
{
Id = Guid.NewGuid(),
Slug = "sample-widget",
Name = "Sample Widget",
Description = "<p>Sample description.</p>",
BasePrice = 42.50m,
Sku = "SW-001",
Categories =
[
new ProductDisplayModel.CategoryRef(Guid.NewGuid(), "Lighting", "lighting")
],
Options =
[
new ProductDisplayModel.OptionDefinition(
Id: Guid.NewGuid(),
Code: "color",
Name: "Color",
DataType: 0,
IsVariantAxis: true,
Unit: null,
Min: null,
Max: null,
Step: null,
PricePerUnit: null,
Tiers: Array.Empty<ProductDisplayModel.Tier>(),
Values:
[
new ProductDisplayModel.OptionValue(Guid.NewGuid(), "red", "Red", 0m, 0),
new ProductDisplayModel.OptionValue(Guid.NewGuid(), "blue", "Blue", 0m, 0)
]),
new ProductDisplayModel.OptionDefinition(
Id: Guid.NewGuid(),
Code: "length",
Name: "Length",
DataType: 1,
IsVariantAxis: false,
Unit: "ft",
Min: 1,
Max: 10,
Step: 1,
PricePerUnit: null,
Tiers:
[
new ProductDisplayModel.Tier(1, 5, 1, null)
],
Values: Array.Empty<ProductDisplayModel.OptionValue>())
],
Specs =
[
new ProductDisplayModel.Spec("Weight", "25 lb", null, null)
],
GenericAttributes = new Dictionary<string, string>
{
["catalog.product.docs"] = """[{ "title": "Spec Sheet", "url": "https://example.com/spec.pdf" }]"""
}
};
private sealed class FakeProductDisplayService(Result<ProductDisplayModel> result) : IProductDisplayService
{
private readonly Result<ProductDisplayModel> _result = result;
public Task<Result<ProductDisplayModel>> Get(string slug, CancellationToken cancellationToken = default) =>
Task.FromResult(_result);
}
}