diff --git a/src/Basket.API/Extensions/Extensions.cs b/src/Basket.API/Extensions/Extensions.cs index da1788347..0a4e7a112 100644 --- a/src/Basket.API/Extensions/Extensions.cs +++ b/src/Basket.API/Extensions/Extensions.cs @@ -2,6 +2,7 @@ using eShop.Basket.API.Repositories; using eShop.Basket.API.IntegrationEvents.EventHandling; using eShop.Basket.API.IntegrationEvents.EventHandling.Events; +using eShop.Basket.API.Services; namespace eShop.Basket.API.Extensions; @@ -14,6 +15,8 @@ public static void AddApplicationServices(this IHostApplicationBuilder builder) builder.AddRedisClient("redis"); builder.Services.AddSingleton(); + builder.Services.AddHttpClient(client => + client.BaseAddress = new("https+http://catalog-api")); builder.AddRabbitMqEventBus("eventbus") .AddSubscription() diff --git a/src/Basket.API/Grpc/BasketService.cs b/src/Basket.API/Grpc/BasketService.cs index 3f1deda67..1810445e3 100644 --- a/src/Basket.API/Grpc/BasketService.cs +++ b/src/Basket.API/Grpc/BasketService.cs @@ -2,12 +2,16 @@ using eShop.Basket.API.Repositories; using eShop.Basket.API.Extensions; using eShop.Basket.API.Model; +using eShop.Basket.API.Services; +using Polly.CircuitBreaker; +using Polly.Timeout; namespace eShop.Basket.API.Grpc; public class BasketService( IBasketRepository repository, - ILogger logger) : Basket.BasketBase + ILogger logger, + CatalogClient catalog) : Basket.BasketBase { [AllowAnonymous] public override async Task GetBasket(GetBasketRequest request, ServerCallContext context) @@ -46,6 +50,40 @@ public override async Task UpdateBasket(UpdateBasketRequ logger.LogDebug("Begin UpdateBasket call from method {Method} for basket id {Id}", context.Method, userId); } + // Bound the batch lookup and reject ambiguous or invalid basket lines before any I/O. + var productIds = request.Items.Select(item => item.ProductId).ToArray(); + if (request.Items.Count > 100 || + request.Items.Any(item => item.ProductId <= 0 || item.Quantity <= 0) || + productIds.Distinct().Count() != productIds.Length) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, + "A basket supports up to 100 unique products with positive IDs and quantities.")); + } + + // Clearing a basket should remain possible even when Catalog is unavailable. + if (productIds.Length > 0) + { + HashSet existingIds; + try + { + existingIds = await catalog.GetProductIdsAsync(productIds, context.CancellationToken); + } + catch (Exception exception) when (exception is HttpRequestException or JsonException or + TimeoutRejectedException or BrokenCircuitException || + exception is OperationCanceledException && !context.CancellationToken.IsCancellationRequested) + { + logger.LogWarning(exception, "Catalog lookup failed while updating a basket"); + throw new RpcException(new Status(StatusCode.Unavailable, + "Catalog is temporarily unavailable. Your basket has not been changed. Please retry.")); + } + + if (productIds.Any(id => !existingIds.Contains(id))) + { + throw new RpcException(new Status(StatusCode.FailedPrecondition, + "One or more products no longer exist in the catalog. Refresh your basket and try again.")); + } + } + var customerBasket = MapToCustomerBasket(userId, request); var response = await repository.UpdateBasketAsync(customerBasket); if (response is null) diff --git a/src/Basket.API/Model/BasketPreview.cs b/src/Basket.API/Model/BasketPreview.cs new file mode 100644 index 000000000..7b486fe1a --- /dev/null +++ b/src/Basket.API/Model/BasketPreview.cs @@ -0,0 +1,13 @@ +namespace eShop.Basket.API.Model; + +/// A read-only line estimate. Creating a preview never writes a basket or reserves stock. +public record BasketPreview(int ProductId, string ProductName, int Quantity, decimal UnitPrice, decimal TotalPrice) +{ + public static BasketPreview Create(BasketItem item) + { + Validator.ValidateObject(item, new ValidationContext(item), validateAllProperties: true); + + return new BasketPreview(item.ProductId, item.ProductName, item.Quantity, + item.UnitPrice, item.UnitPrice * item.Quantity); + } +} diff --git a/src/Basket.API/Program.cs b/src/Basket.API/Program.cs index a1e0c635d..1a2637c65 100644 --- a/src/Basket.API/Program.cs +++ b/src/Basket.API/Program.cs @@ -1,6 +1,6 @@ var builder = WebApplication.CreateBuilder(args); -builder.AddBasicServiceDefaults(); +builder.AddServiceDefaults(); builder.AddApplicationServices(); builder.Services.AddGrpc(); diff --git a/src/Basket.API/Services/CatalogClient.cs b/src/Basket.API/Services/CatalogClient.cs new file mode 100644 index 000000000..51e84a2de --- /dev/null +++ b/src/Basket.API/Services/CatalogClient.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace eShop.Basket.API.Services; + +public class CatalogClient(HttpClient httpClient) +{ + public async Task> GetProductIdsAsync(IEnumerable productIds, CancellationToken cancellationToken) + { + var query = string.Join("&", productIds.Select(id => $"ids={id}")); + var products = await httpClient.GetFromJsonAsync( + $"/api/catalog/items/by?api-version=2.0&{query}", + CatalogJsonContext.Default.CatalogProductArray, + cancellationToken); + + return products?.Select(product => product.Id).ToHashSet() + ?? throw new JsonException("Catalog returned a null product list."); + } +} + +public record CatalogProduct(int Id); + +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] +[JsonSerializable(typeof(CatalogProduct[]))] +internal partial class CatalogJsonContext : JsonSerializerContext; diff --git a/src/Catalog.API/Apis/CatalogApi.cs b/src/Catalog.API/Apis/CatalogApi.cs index d2450485c..d9589789c 100644 --- a/src/Catalog.API/Apis/CatalogApi.cs +++ b/src/Catalog.API/Apis/CatalogApi.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; +using eShop.Basket.API.Model; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; @@ -38,6 +39,11 @@ public static IEndpointRouteBuilder MapCatalogApi(this IEndpointRouteBuilder app .WithSummary("Get catalog item") .WithDescription("Get an item from the catalog") .WithTags("Items"); + api.MapGet("/items/{id:int}/basket-preview", GetBasketPreview) + .WithName("PreviewBasketItem") + .WithSummary("Preview a basket line") + .WithDescription("Calculate a line estimate using Basket-owned C# validation and pricing code in-process. Does not write a basket or reserve stock.") + .WithTags("Items"); v1.MapGet("/items/by/{name:minlength(1)}", GetItemsByName) .WithName("GetItemsByName") .WithSummary("Get catalog items by name") @@ -235,6 +241,39 @@ public static async Task, NotFound, BadRequest, NotFound, BadRequest>> GetBasketPreview( + [AsParameters] CatalogServices services, + int id, + int quantity = 1) + { + if (id <= 0) + { + return TypedResults.BadRequest(new ProblemDetails { Detail = "Id is not valid" }); + } + + var item = await services.Context.CatalogItems.AsNoTracking().SingleOrDefaultAsync(item => item.Id == id); + if (item is null) + { + return TypedResults.NotFound(); + } + + try + { + // Direct C# call into the Basket assembly, not an HTTP request or an integration event. + return TypedResults.Ok(BasketPreview.Create(new BasketItem + { + ProductId = item.Id, + ProductName = item.Name, + UnitPrice = item.Price, + Quantity = quantity + })); + } + catch (ValidationException exception) + { + return TypedResults.BadRequest(new ProblemDetails { Detail = exception.Message }); + } + } + [ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")] public static async Task>> GetItemsByName( [AsParameters] PaginationRequest paginationRequest, diff --git a/src/Catalog.API/Catalog.API.csproj b/src/Catalog.API/Catalog.API.csproj index 7cfc37162..3ac142dce 100644 --- a/src/Catalog.API/Catalog.API.csproj +++ b/src/Catalog.API/Catalog.API.csproj @@ -37,6 +37,8 @@ + + @@ -50,4 +52,11 @@ + + + + + + + diff --git a/src/Catalog.API/Catalog.API.json b/src/Catalog.API/Catalog.API.json index a12e6ece6..128a3b05f 100644 --- a/src/Catalog.API/Catalog.API.json +++ b/src/Catalog.API/Catalog.API.json @@ -167,6 +167,76 @@ } } }, + "/api/catalog/items/{id}/basket-preview": { + "get": { + "tags": [ + "Items" + ], + "summary": "Preview a basket line", + "description": "Calculate a line estimate using Basket-owned C# validation and pricing code in-process. Does not write a basket or reserve stock.", + "operationId": "PreviewBasketItem", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": "integer", + "format": "int32" + } + }, + { + "name": "quantity", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 1 + } + }, + { + "name": "api-version", + "in": "query", + "description": "The API version, in the format 'major.minor'.", + "required": true, + "schema": { + "type": "string", + "example": "1.0" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BasketPreview" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/catalog/items/{id}/pic": { "get": { "tags": [ @@ -955,6 +1025,56 @@ }, "components": { "schemas": { + "BasketPreview": { + "required": [ + "productId", + "productName", + "quantity", + "unitPrice", + "totalPrice" + ], + "type": "object", + "properties": { + "productId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "productName": { + "type": [ + "null", + "string" + ] + }, + "quantity": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "unitPrice": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + }, + "totalPrice": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + } + } + }, "CatalogBrand": { "required": [ "brand" diff --git a/src/Catalog.API/Catalog.API_v2.json b/src/Catalog.API/Catalog.API_v2.json index 43f5ace7a..fb9f0337e 100644 --- a/src/Catalog.API/Catalog.API_v2.json +++ b/src/Catalog.API/Catalog.API_v2.json @@ -233,6 +233,76 @@ } } }, + "/api/catalog/items/{id}/basket-preview": { + "get": { + "tags": [ + "Items" + ], + "summary": "Preview a basket line", + "description": "Calculate a line estimate using Basket-owned C# validation and pricing code in-process. Does not write a basket or reserve stock.", + "operationId": "PreviewBasketItem", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": "integer", + "format": "int32" + } + }, + { + "name": "quantity", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 1 + } + }, + { + "name": "api-version", + "in": "query", + "description": "The API version, in the format 'major.minor'.", + "required": true, + "schema": { + "type": "string", + "example": "2.0" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BasketPreview" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/catalog/items/{id}/pic": { "get": { "tags": [ @@ -738,6 +808,56 @@ }, "components": { "schemas": { + "BasketPreview": { + "required": [ + "productId", + "productName", + "quantity", + "unitPrice", + "totalPrice" + ], + "type": "object", + "properties": { + "productId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "productName": { + "type": [ + "null", + "string" + ] + }, + "quantity": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "unitPrice": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + }, + "totalPrice": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + } + } + }, "CatalogBrand": { "required": [ "brand" diff --git a/src/eShop.AppHost/Program.cs b/src/eShop.AppHost/Program.cs index 9ae882e3e..e57bc0fbb 100644 --- a/src/eShop.AppHost/Program.cs +++ b/src/eShop.AppHost/Program.cs @@ -38,6 +38,8 @@ .WithReference(rabbitMq).WaitFor(rabbitMq) .WithReference(catalogDb); +basketApi.WithReference(catalogApi).WaitFor(catalogApi); + var orderingApi = builder.AddProject("ordering-api") .WithReference(rabbitMq).WaitFor(rabbitMq) .WithReference(orderDb).WaitFor(orderDb) diff --git a/tests/Basket.UnitTests/BasketPreviewTests.cs b/tests/Basket.UnitTests/BasketPreviewTests.cs new file mode 100644 index 000000000..aa9c87a42 --- /dev/null +++ b/tests/Basket.UnitTests/BasketPreviewTests.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations; +using eShop.Basket.API.Model; + +namespace eShop.Basket.UnitTests; + +[TestClass] +public class BasketPreviewTests +{ + [TestMethod] + public void PreviewUsesBasketQuantityAndDecimalPriceWithoutChangingTheItem() + { + var item = new BasketItem + { + ProductId = 42, + ProductName = "Trail shoes", + Quantity = 3, + UnitPrice = 19.95m + }; + + var preview = BasketPreview.Create(item); + + Assert.AreEqual(new BasketPreview(42, "Trail shoes", 3, 19.95m, 59.85m), preview); + Assert.AreEqual(3, item.Quantity); + Assert.AreEqual(19.95m, item.UnitPrice); + } + + [TestMethod] + [DataRow(0)] + [DataRow(-1)] + public void PreviewReusesBasketItemQuantityValidation(int quantity) + { + var item = new BasketItem { ProductId = 42, Quantity = quantity, UnitPrice = 19.95m }; + + var exception = Assert.Throws(() => BasketPreview.Create(item)); + + Assert.AreEqual("Invalid number of units", exception.Message); + } + + [TestMethod] + public void PreviewAcceptsOneFreeItem() + { + var preview = BasketPreview.Create(new BasketItem + { + ProductId = 7, + ProductName = "Gift", + Quantity = 1, + UnitPrice = 0m + }); + + Assert.AreEqual(new BasketPreview(7, "Gift", 1, 0m, 0m), preview); + } +} diff --git a/tests/Basket.UnitTests/BasketServiceTests.cs b/tests/Basket.UnitTests/BasketServiceTests.cs index f49a3454c..055451d83 100644 --- a/tests/Basket.UnitTests/BasketServiceTests.cs +++ b/tests/Basket.UnitTests/BasketServiceTests.cs @@ -4,6 +4,12 @@ using eShop.Basket.API.IntegrationEvents.EventHandling; using eShop.Basket.API.IntegrationEvents.EventHandling.Events; using eShop.Basket.API.Model; +using eShop.Basket.API.Services; +using System.Linq; +using System.Net; +using System.Net.Http; +using Polly.CircuitBreaker; +using Polly.Timeout; using eShop.Basket.UnitTests.Helpers; using Microsoft.Extensions.Logging.Abstractions; using Grpc.Core; @@ -20,7 +26,7 @@ public class BasketServiceTests public async Task GetBasketReturnsEmptyForNoUser() { var mockRepository = Substitute.For(); - var service = new BasketService(mockRepository, NullLogger.Instance); + var service = CreateService(mockRepository); var serverCallContext = TestServerCallContext.Create(cancellationToken: TestContext.CancellationToken); serverCallContext.SetUserState("__HttpContext", new DefaultHttpContext()); @@ -36,7 +42,7 @@ public async Task GetBasketReturnsItemsForValidUserId() var mockRepository = Substitute.For(); List items = [new BasketItem { Id = "some-id" }]; mockRepository.GetBasketAsync("1").Returns(Task.FromResult(new CustomerBasket { BuyerId = "1", Items = items })); - var service = new BasketService(mockRepository, NullLogger.Instance); + var service = CreateService(mockRepository); var serverCallContext = TestServerCallContext.Create(cancellationToken: TestContext.CancellationToken); var httpContext = new DefaultHttpContext(); httpContext.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim("sub", "1")])); @@ -54,7 +60,7 @@ public async Task GetBasketReturnsEmptyForInvalidUserId() var mockRepository = Substitute.For(); List items = [new BasketItem { Id = "some-id" }]; mockRepository.GetBasketAsync("1").Returns(Task.FromResult(new CustomerBasket { BuyerId = "1", Items = items })); - var service = new BasketService(mockRepository, NullLogger.Instance); + var service = CreateService(mockRepository); var serverCallContext = TestServerCallContext.Create(cancellationToken: TestContext.CancellationToken); var httpContext = new DefaultHttpContext(); serverCallContext.SetUserState("__HttpContext", httpContext); @@ -71,7 +77,7 @@ public async Task UpdateBasketPersistsItemsForAuthenticatedUser() var repository = Substitute.For(); repository.UpdateBasketAsync(Arg.Any()) .Returns(call => call.Arg()); - var service = new BasketService(repository, NullLogger.Instance); + var service = CreateService(repository); var context = CreateContext("buyer-1"); var request = new UpdateBasketRequest(); request.Items.Add(new eShop.Basket.API.Grpc.BasketItem { ProductId = 42, Quantity = 3 }); @@ -92,12 +98,14 @@ await repository.Received(1).UpdateBasketAsync(Arg.Is(basket => public async Task UpdateBasketRejectsAnonymousUser() { var repository = Substitute.For(); - var service = new BasketService(repository, NullLogger.Instance); + using var handler = new CatalogHandler("[]"); + var service = CreateService(repository, handler); var exception = await Assert.ThrowsAsync(() => - service.UpdateBasket(new UpdateBasketRequest(), CreateContext(null!))); + service.UpdateBasket(Request((42, 1)), CreateContext(null!))); Assert.AreEqual(StatusCode.Unauthenticated, exception.StatusCode); + Assert.AreEqual(0, handler.Calls); await repository.DidNotReceive().UpdateBasketAsync(Arg.Any()); } @@ -107,7 +115,7 @@ public async Task UpdateBasketReturnsNotFoundWhenRepositoryCannotPersist() var repository = Substitute.For(); repository.UpdateBasketAsync(Arg.Any()) .Returns(Task.FromResult(null!)); - var service = new BasketService(repository, NullLogger.Instance); + var service = CreateService(repository); var exception = await Assert.ThrowsAsync(() => service.UpdateBasket(new UpdateBasketRequest(), CreateContext("missing"))); @@ -119,7 +127,7 @@ public async Task UpdateBasketReturnsNotFoundWhenRepositoryCannotPersist() public async Task DeleteBasketRemovesAuthenticatedUsersBasket() { var repository = Substitute.For(); - var service = new BasketService(repository, NullLogger.Instance); + var service = CreateService(repository); await service.DeleteBasket(new DeleteBasketRequest(), CreateContext("buyer-1")); @@ -139,6 +147,205 @@ public async Task OrderStartedEventRemovesUsersBasket() await repository.Received(1).DeleteBasketAsync("buyer-1"); } + [TestMethod] + public async Task UpdateBasketChecksEntireBatchBeforePersisting() + { + var repository = Substitute.For(); + repository.UpdateBasketAsync(Arg.Any()).Returns(call => call.Arg()); + using var handler = new CatalogHandler("[{\"id\":7},{\"id\":42}]"); + var service = CreateService(repository, handler); + var request = Request((42, 3), (7, 2)); + + var response = await service.UpdateBasket(request, CreateContext("buyer-1")); + + Assert.AreEqual("/api/catalog/items/by?api-version=2.0&ids=42&ids=7", handler.RequestUri?.PathAndQuery); + Assert.AreEqual(1, handler.Calls); + CollectionAssert.AreEqual(new[] { 42, 7 }, response.Items.Select(item => item.ProductId).ToArray()); + CollectionAssert.AreEqual(new[] { 3, 2 }, response.Items.Select(item => item.Quantity).ToArray()); + await repository.Received(1).UpdateBasketAsync(Arg.Any()); + } + + [TestMethod] + [DataRow("[]")] + [DataRow("[{\"id\":42}]")] + [DataRow("[{\"id\":42},{\"id\":99}]")] + public async Task UpdateBasketRejectsMissingProductsWithoutChangingStoredBasket(string json) + { + var repository = Substitute.For(); + using var handler = new CatalogHandler(json); + var service = CreateService(repository, handler); + + var exception = await Assert.ThrowsAsync(() => + service.UpdateBasket(Request((42, 3), (7, 2)), CreateContext("buyer-1"))); + + Assert.AreEqual(StatusCode.FailedPrecondition, exception.StatusCode); + await repository.DidNotReceive().UpdateBasketAsync(Arg.Any()); + } + + [TestMethod] + [DataRow("[]", 503)] + [DataRow("invalid-json", 200)] + [DataRow("null", 200)] + public async Task CatalogFailureLeavesBasketUnchanged(string json, int status) + { + var repository = Substitute.For(); + using var handler = new CatalogHandler(json, (HttpStatusCode)status); + var service = CreateService(repository, handler); + + var exception = await Assert.ThrowsAsync(() => + service.UpdateBasket(Request((42, 1)), CreateContext("buyer-1"))); + + Assert.AreEqual(StatusCode.Unavailable, exception.StatusCode); + await repository.DidNotReceive().UpdateBasketAsync(Arg.Any()); + } + + [TestMethod] + [DataRow("network")] + [DataRow("http-timeout")] + [DataRow("resilience-timeout")] + [DataRow("circuit-open")] + public async Task CatalogTransportFailuresAreRetryable(string failure) + { + var repository = Substitute.For(); + using var handler = new CatalogHandler("[]") + { + Failure = failure switch + { + "network" => new HttpRequestException(), + "http-timeout" => new TaskCanceledException(), + "resilience-timeout" => new TimeoutRejectedException(), + _ => new BrokenCircuitException() + } + }; + + var exception = await Assert.ThrowsAsync(() => + CreateService(repository, handler).UpdateBasket(Request((42, 1)), CreateContext("buyer-1"))); + + Assert.AreEqual(StatusCode.Unavailable, exception.StatusCode); + await repository.DidNotReceive().UpdateBasketAsync(Arg.Any()); + } + + [TestMethod] + public async Task CallerCancellationDoesNotPersistOrBecomeCatalogUnavailable() + { + using var cancellation = new CancellationTokenSource(); + var context = TestServerCallContext.Create(cancellationToken: cancellation.Token); + context.SetUserState("__HttpContext", new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity([new Claim("sub", "buyer-1")])) + }); + var repository = Substitute.For(); + using var handler = new CatalogHandler("[]") { Cancel = cancellation.Cancel }; + + await Assert.ThrowsAsync(() => + CreateService(repository, handler).UpdateBasket(Request((42, 1)), context)); + + Assert.IsTrue(cancellation.IsCancellationRequested); + await repository.DidNotReceive().UpdateBasketAsync(Arg.Any()); + } + + [TestMethod] + [DataRow(0, 1)] + [DataRow(-1, 1)] + [DataRow(42, 0)] + [DataRow(42, -1)] + public async Task InvalidLinesDoNotCallCatalogOrPersist(int productId, int quantity) + { + await AssertRejectedBeforeLookup(Request((productId, quantity))); + } + + [TestMethod] + public async Task DuplicateProductsDoNotCallCatalogOrPersist() + { + await AssertRejectedBeforeLookup(Request((42, 1), (42, 2))); + } + + [TestMethod] + [DataRow(100, true)] + [DataRow(101, false)] + public async Task BasketSizeLimitBoundsCatalogRequest(int count, bool accepted) + { + var request = Request(Enumerable.Range(1, count).Select(id => (id, 1)).ToArray()); + if (!accepted) + { + await AssertRejectedBeforeLookup(request); + return; + } + + var repository = Substitute.For(); + repository.UpdateBasketAsync(Arg.Any()).Returns(call => call.Arg()); + using var handler = new CatalogHandler("[" + string.Join(",", Enumerable.Range(1, count).Select(id => $"{{\"id\":{id}}}")) + "]"); + var response = await CreateService(repository, handler).UpdateBasket(request, CreateContext("buyer-1")); + + Assert.HasCount(100, response.Items); + Assert.AreEqual(1, handler.Calls); + } + + [TestMethod] + public async Task EmptyBasketCanBeSavedWithoutCatalog() + { + var repository = Substitute.For(); + repository.UpdateBasketAsync(Arg.Any()).Returns(call => call.Arg()); + using var handler = new CatalogHandler("[]", HttpStatusCode.ServiceUnavailable); + + var response = await CreateService(repository, handler).UpdateBasket(Request(), CreateContext("buyer-1")); + + Assert.IsEmpty(response.Items); + Assert.AreEqual(0, handler.Calls); + await repository.Received(1).UpdateBasketAsync(Arg.Is(basket => basket.Items.Count == 0)); + } + + private async Task AssertRejectedBeforeLookup(UpdateBasketRequest request) + { + var repository = Substitute.For(); + using var handler = new CatalogHandler("[]"); + var exception = await Assert.ThrowsAsync(() => + CreateService(repository, handler).UpdateBasket(request, CreateContext("buyer-1"))); + + Assert.AreEqual(StatusCode.InvalidArgument, exception.StatusCode); + Assert.AreEqual(0, handler.Calls); + await repository.DidNotReceive().UpdateBasketAsync(Arg.Any()); + } + + private static UpdateBasketRequest Request(params (int Id, int Quantity)[] items) + { + var request = new UpdateBasketRequest(); + request.Items.AddRange(items.Select(item => new eShop.Basket.API.Grpc.BasketItem + { + ProductId = item.Id, + Quantity = item.Quantity + })); + return request; + } + + private static BasketService CreateService(IBasketRepository repository, CatalogHandler handler = null) => + new(repository, NullLogger.Instance, + new CatalogClient(new HttpClient(handler ?? new CatalogHandler("[{\"id\":42}]")) + { + BaseAddress = new Uri("http://catalog-api") + })); + + private sealed class CatalogHandler(string json, HttpStatusCode status = HttpStatusCode.OK) : HttpMessageHandler + { + public Uri RequestUri { get; private set; } + public int Calls { get; private set; } + public Exception Failure { get; init; } + public Action Cancel { get; init; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Calls++; + RequestUri = request.RequestUri; + Cancel?.Invoke(); + cancellationToken.ThrowIfCancellationRequested(); + if (Failure is not null) + { + return Task.FromException(Failure); + } + return Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(json) }); + } + } + private TestServerCallContext CreateContext(string userId) { var context = TestServerCallContext.Create(cancellationToken: TestContext.CancellationToken); diff --git a/tests/Catalog.FunctionalTests/CatalogApiFixture.cs b/tests/Catalog.FunctionalTests/CatalogApiFixture.cs index dca044f8f..70eba5f5f 100644 --- a/tests/Catalog.FunctionalTests/CatalogApiFixture.cs +++ b/tests/Catalog.FunctionalTests/CatalogApiFixture.cs @@ -1,4 +1,5 @@ using System.Reflection; +using eShop.Catalog.API.Model; using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; @@ -7,7 +8,8 @@ namespace eShop.Catalog.FunctionalTests; -public sealed class CatalogApiFixture : WebApplicationFactory, IAsyncLifetime +// Select the Catalog assembly explicitly: the referenced Basket executable also has a Program type. +public sealed class CatalogApiFixture : WebApplicationFactory, IAsyncLifetime { private readonly IHost _app; diff --git a/tests/Catalog.FunctionalTests/CatalogApiTests.cs b/tests/Catalog.FunctionalTests/CatalogApiTests.cs index fbb2ecb27..a58f9352a 100644 --- a/tests/Catalog.FunctionalTests/CatalogApiTests.cs +++ b/tests/Catalog.FunctionalTests/CatalogApiTests.cs @@ -10,7 +10,7 @@ namespace eShop.Catalog.FunctionalTests; public sealed class CatalogApiTests : IClassFixture { - private readonly WebApplicationFactory _webApplicationFactory; + private readonly WebApplicationFactory _webApplicationFactory; private readonly JsonSerializerOptions _jsonSerializerOptions = new(JsonSerializerDefaults.Web); public CatalogApiTests(CatalogApiFixture fixture) @@ -44,6 +44,72 @@ public async Task GetCatalogItemsRespectsPageSize(double version) Assert.Equal(5, result.PageSize); } + [Theory] + [InlineData(1.0)] + [InlineData(2.0)] + public async Task BasketPreviewUsesBasketCodeWithoutModifyingCatalog(double version) + { + using var client = CreateHttpClient(new ApiVersion(version)); + var cancellationToken = TestContext.Current.CancellationToken; + var item = await client.GetFromJsonAsync("/api/catalog/items/2", cancellationToken); + + using var response = await client.GetAsync("/api/catalog/items/2/basket-preview?quantity=3", cancellationToken); + + response.EnsureSuccessStatusCode(); + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + var preview = json.RootElement; + Assert.Equal(2, preview.GetProperty("productId").GetInt32()); + Assert.Equal(item.Name, preview.GetProperty("productName").GetString()); + Assert.Equal(3, preview.GetProperty("quantity").GetInt32()); + Assert.Equal(item.Price, preview.GetProperty("unitPrice").GetDecimal()); + Assert.Equal(item.Price * 3, preview.GetProperty("totalPrice").GetDecimal()); + var unchanged = await client.GetFromJsonAsync("/api/catalog/items/2", cancellationToken); + Assert.Equal(item.AvailableStock, unchanged.AvailableStock); + Assert.Equal(item.Price, unchanged.Price); + } + + [Theory] + [InlineData(1.0)] + [InlineData(2.0)] + public async Task BasketPreviewDefaultsToOneItem(double version) + { + using var client = CreateHttpClient(new ApiVersion(version)); + using var response = await client.GetAsync("/api/catalog/items/2/basket-preview", TestContext.Current.CancellationToken); + + response.EnsureSuccessStatusCode(); + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal(1, json.RootElement.GetProperty("quantity").GetInt32()); + Assert.Equal(json.RootElement.GetProperty("unitPrice").GetDecimal(), json.RootElement.GetProperty("totalPrice").GetDecimal()); + } + + [Theory] + [InlineData(1.0, 0)] + [InlineData(1.0, -1)] + [InlineData(2.0, 0)] + [InlineData(2.0, -1)] + public async Task BasketPreviewReturnsBasketValidationError(double version, int quantity) + { + using var client = CreateHttpClient(new ApiVersion(version)); + using var response = await client.GetAsync($"/api/catalog/items/2/basket-preview?quantity={quantity}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal("Invalid number of units", json.RootElement.GetProperty("detail").GetString()); + } + + [Theory] + [InlineData(1.0, 0, HttpStatusCode.BadRequest)] + [InlineData(2.0, 0, HttpStatusCode.BadRequest)] + [InlineData(1.0, int.MaxValue, HttpStatusCode.NotFound)] + [InlineData(2.0, int.MaxValue, HttpStatusCode.NotFound)] + public async Task BasketPreviewRejectsInvalidOrMissingProduct(double version, int id, HttpStatusCode expected) + { + using var client = CreateHttpClient(new ApiVersion(version)); + using var response = await client.GetAsync($"/api/catalog/items/{id}/basket-preview?quantity=3", TestContext.Current.CancellationToken); + + Assert.Equal(expected, response.StatusCode); + } + [Theory] [InlineData(1.0)] [InlineData(2.0)]