From c068b520e50a4cca2ab0369f1bc77a63cc64bb3e Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Thu, 24 Sep 2026 02:33:39 +0200 Subject: [PATCH] feat: validate basket products against catalog before saving Introduce a Basket API to Catalog Service dependency for architecture review. Amp-Thread-ID: https://ampcode.com/threads/T-01a0d0bb-dbb1-77fc-9712-4aeb9f411d79 Co-authored-by: Amp --- src/Basket.API/Extensions/Extensions.cs | 3 + src/Basket.API/Grpc/BasketService.cs | 35 ++++++- src/Basket.API/Program.cs | 2 +- src/eShop.AppHost/Program.cs | 2 + tests/Basket.UnitTests/BasketServiceTests.cs | 98 ++++++++++++++++++-- 5 files changed, 131 insertions(+), 9 deletions(-) diff --git a/src/Basket.API/Extensions/Extensions.cs b/src/Basket.API/Extensions/Extensions.cs index da1788347..7b18669fb 100644 --- a/src/Basket.API/Extensions/Extensions.cs +++ b/src/Basket.API/Extensions/Extensions.cs @@ -15,6 +15,9 @@ public static void AddApplicationServices(this IHostApplicationBuilder builder) builder.Services.AddSingleton(); + builder.Services.AddHttpClient("catalog", client => + client.BaseAddress = new Uri("https+http://catalog-api")); + builder.AddRabbitMqEventBus("eventbus") .AddSubscription() .ConfigureJsonOptions(options => options.TypeInfoResolverChain.Add(IntegrationEventContext.Default)); diff --git a/src/Basket.API/Grpc/BasketService.cs b/src/Basket.API/Grpc/BasketService.cs index 3f1deda67..8aaa295b8 100644 --- a/src/Basket.API/Grpc/BasketService.cs +++ b/src/Basket.API/Grpc/BasketService.cs @@ -7,7 +7,8 @@ namespace eShop.Basket.API.Grpc; public class BasketService( IBasketRepository repository, - ILogger logger) : Basket.BasketBase + ILogger logger, + IHttpClientFactory httpClientFactory) : Basket.BasketBase { [AllowAnonymous] public override async Task GetBasket(GetBasketRequest request, ServerCallContext context) @@ -46,6 +47,38 @@ public override async Task UpdateBasket(UpdateBasketRequ logger.LogDebug("Begin UpdateBasket call from method {Method} for basket id {Id}", context.Method, userId); } + if (request.Items.Any(item => item.ProductId <= 0)) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, "Product IDs must be positive.")); + } + + if (request.Items.Count > 0) + { + using var catalog = httpClientFactory.CreateClient("catalog"); + try + { + foreach (var productId in request.Items.Select(item => item.ProductId).Distinct()) + { + using var product = await catalog.GetAsync( + $"/api/catalog/items/{productId}?api-version=2.0", context.CancellationToken); + if (product.StatusCode == System.Net.HttpStatusCode.NotFound) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, $"Product {productId} does not exist.")); + } + + product.EnsureSuccessStatusCode(); + } + } + catch (HttpRequestException) + { + throw new RpcException(new Status(StatusCode.Unavailable, "Catalog validation is unavailable. Please retry.")); + } + catch (OperationCanceledException) when (!context.CancellationToken.IsCancellationRequested) + { + throw new RpcException(new Status(StatusCode.Unavailable, "Catalog validation timed out. Please retry.")); + } + } + var customerBasket = MapToCustomerBasket(userId, request); var response = await repository.UpdateBasketAsync(customerBasket); if (response is null) 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/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/BasketServiceTests.cs b/tests/Basket.UnitTests/BasketServiceTests.cs index f49a3454c..6b17932b0 100644 --- a/tests/Basket.UnitTests/BasketServiceTests.cs +++ b/tests/Basket.UnitTests/BasketServiceTests.cs @@ -1,4 +1,6 @@ using System.Security.Claims; +using System.Net.Http; +using System.Linq; using eShop.Basket.API.Repositories; using eShop.Basket.API.Grpc; using eShop.Basket.API.IntegrationEvents.EventHandling; @@ -20,7 +22,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 +38,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 +56,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 +73,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,7 +94,7 @@ await repository.Received(1).UpdateBasketAsync(Arg.Is(basket => public async Task UpdateBasketRejectsAnonymousUser() { var repository = Substitute.For(); - var service = new BasketService(repository, NullLogger.Instance); + var service = CreateService(repository); var exception = await Assert.ThrowsAsync(() => service.UpdateBasket(new UpdateBasketRequest(), CreateContext(null!))); @@ -107,7 +109,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 +121,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 +141,88 @@ public async Task OrderStartedEventRemovesUsersBasket() await repository.Received(1).DeleteBasketAsync("buyer-1"); } + [TestMethod] + [DataRow(System.Net.HttpStatusCode.NotFound, StatusCode.InvalidArgument)] + [DataRow(System.Net.HttpStatusCode.ServiceUnavailable, StatusCode.Unavailable)] + public async Task CatalogFailureDoesNotOverwriteBasket(System.Net.HttpStatusCode status, StatusCode expected) + { + var repository = Substitute.For(); + var paths = new List(); + var service = CreateService(repository, request => + { + paths.Add(request.RequestUri!.PathAndQuery); + return new HttpResponseMessage(paths.Count == 1 ? System.Net.HttpStatusCode.OK : status); + }); + var request = new UpdateBasketRequest(); + request.Items.Add(new eShop.Basket.API.Grpc.BasketItem { ProductId = 42, Quantity = 3 }); + request.Items.Add(new eShop.Basket.API.Grpc.BasketItem { ProductId = 99, Quantity = 1 }); + + var exception = await Assert.ThrowsAsync(() => service.UpdateBasket(request, CreateContext("buyer-1"))); + + Assert.AreEqual(expected, exception.StatusCode); + CollectionAssert.AreEqual(new[] { "/api/catalog/items/42?api-version=2.0", "/api/catalog/items/99?api-version=2.0" }, paths); + await repository.DidNotReceive().UpdateBasketAsync(Arg.Any()); + } + + [TestMethod] + public async Task DuplicateProductsAreValidatedOnceAndQuantitiesPreserved() + { + var repository = Substitute.For(); + repository.UpdateBasketAsync(Arg.Any()).Returns(call => call.Arg()); + var calls = 0; + var service = CreateService(repository, _ => { calls++; return new HttpResponseMessage(System.Net.HttpStatusCode.OK); }); + var request = new UpdateBasketRequest(); + request.Items.Add(new eShop.Basket.API.Grpc.BasketItem { ProductId = 42, Quantity = 3 }); + request.Items.Add(new eShop.Basket.API.Grpc.BasketItem { ProductId = 42, Quantity = 7 }); + + var response = await service.UpdateBasket(request, CreateContext("buyer-1")); + + Assert.AreEqual(1, calls); + CollectionAssert.AreEqual(new[] { 3, 7 }, response.Items.Select(item => item.Quantity).ToArray()); + } + + [TestMethod] + public async Task EmptyBasketDoesNotRequireCatalog() + { + var repository = Substitute.For(); + repository.UpdateBasketAsync(Arg.Any()).Returns(call => call.Arg()); + var service = CreateService(repository, _ => throw new AssertFailedException("Catalog must not be called.")); + + var response = await service.UpdateBasket(new UpdateBasketRequest(), CreateContext("buyer-1")); + + Assert.IsEmpty(response.Items); + await repository.Received(1).UpdateBasketAsync(Arg.Is(basket => basket.Items.Count == 0)); + } + + [TestMethod] + [DataRow(0)] + [DataRow(-1)] + public async Task InvalidProductIdsAreRejectedWithoutCallingCatalog(int productId) + { + var repository = Substitute.For(); + var service = CreateService(repository, _ => throw new AssertFailedException("Catalog must not be called.")); + var request = new UpdateBasketRequest(); + request.Items.Add(new eShop.Basket.API.Grpc.BasketItem { ProductId = productId, Quantity = 1 }); + + var exception = await Assert.ThrowsAsync(() => service.UpdateBasket(request, CreateContext("buyer-1"))); + + Assert.AreEqual(StatusCode.InvalidArgument, exception.StatusCode); + await repository.DidNotReceive().UpdateBasketAsync(Arg.Any()); + } + + private static BasketService CreateService(IBasketRepository repository, Func respond = null) + { + var factory = Substitute.For(); + factory.CreateClient("catalog").Returns(_ => new HttpClient(new CatalogHandler(respond)) { BaseAddress = new Uri("http://catalog-api") }); + return new BasketService(repository, NullLogger.Instance, factory); + } + + private sealed class CatalogHandler(Func respond) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(respond?.Invoke(request) ?? new HttpResponseMessage(System.Net.HttpStatusCode.OK)); + } + private TestServerCallContext CreateContext(string userId) { var context = TestServerCallContext.Create(cancellationToken: TestContext.CancellationToken);