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 { [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>(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>(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(), Values: optionValues, Rules: null) }; var specs = new[] { new GetProductDetail.SpecDto("Weight", "25 lb", null, null) }; var attributes = new Dictionary { ["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( 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(string slug, CancellationToken cancellationToken) => throw new NotSupportedException("Category client is not required for this test."); public Task GetCategoryTree(int? depth, CancellationToken cancellationToken) => throw new NotSupportedException("Category tree is not required for this test."); public Task GetProductDetail(string slug, CancellationToken cancellationToken) => Task.FromResult(response); public Task Quote(QuotePrice.Request request, CancellationToken cancellationToken) => throw new NotSupportedException("Price quotes are not required for this test."); public Task 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( 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(string slug, CancellationToken cancellationToken) => throw new NotSupportedException("Category client is not required for this test."); public Task GetCategoryTree(int? depth, CancellationToken cancellationToken) => throw new NotSupportedException("Category tree is not required for this test."); public Task GetProductDetail(string slug, CancellationToken cancellationToken) => throw new CatalogNotFoundException("Product", slug); public Task Quote(QuotePrice.Request request, CancellationToken cancellationToken) => throw new NotSupportedException("Price quotes are not required for this test."); public Task QuotePrice(QuotePrice.Request request, CancellationToken cancellationToken) => throw new NotSupportedException("Price quotes are not required for this test."); } }