Latest
This commit is contained in:
@@ -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>
|
||||
{
|
||||
}
|
||||
|
||||
165
Prefab.Tests/Integration/Web/Gateway/ProductDetailShould.cs
Normal file
165
Prefab.Tests/Integration/Web/Gateway/ProductDetailShould.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
128
Prefab.Tests/Web.Client/Pages/ProductPageTests.cs
Normal file
128
Prefab.Tests/Web.Client/Pages/ProductPageTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user