Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Basket.API/Extensions/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ public static void AddApplicationServices(this IHostApplicationBuilder builder)

builder.Services.AddSingleton<IBasketRepository, RedisBasketRepository>();

builder.Services.AddHttpClient("catalog", client =>
client.BaseAddress = new Uri("https+http://catalog-api"));

builder.AddRabbitMqEventBus("eventbus")
.AddSubscription<OrderStartedIntegrationEvent, OrderStartedIntegrationEventHandler>()
.ConfigureJsonOptions(options => options.TypeInfoResolverChain.Add(IntegrationEventContext.Default));
Expand Down
35 changes: 34 additions & 1 deletion src/Basket.API/Grpc/BasketService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ namespace eShop.Basket.API.Grpc;

public class BasketService(
IBasketRepository repository,
ILogger<BasketService> logger) : Basket.BasketBase
ILogger<BasketService> logger,
IHttpClientFactory httpClientFactory) : Basket.BasketBase
{
[AllowAnonymous]
public override async Task<CustomerBasketResponse> GetBasket(GetBasketRequest request, ServerCallContext context)
Expand Down Expand Up @@ -46,6 +47,38 @@ public override async Task<CustomerBasketResponse> 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."));
}
Comment on lines +76 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Catch resilience-pipeline timeout exceptions

When the outgoing Catalog request reaches the timeout configured by the newly enabled AddStandardResilienceHandler, Polly throws TimeoutRejectedException, which does not derive from OperationCanceledException. Consequently this catch is skipped and the gRPC call surfaces as an unhandled Unknown/Internal error rather than the promised retryable Unavailable status. Catch the resilience timeout exception explicitly while continuing to let caller-initiated cancellation propagate.

Useful? React with 👍 / 👎.

}

var customerBasket = MapToCustomerBasket(userId, request);
var response = await repository.UpdateBasketAsync(customerBasket);
if (response is null)
Expand Down
2 changes: 1 addition & 1 deletion src/Basket.API/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
var builder = WebApplication.CreateBuilder(args);

builder.AddBasicServiceDefaults();
builder.AddServiceDefaults();
builder.AddApplicationServices();

builder.Services.AddGrpc();
Expand Down
2 changes: 2 additions & 0 deletions src/eShop.AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
.WithReference(rabbitMq).WaitFor(rabbitMq)
.WithReference(catalogDb);

basketApi.WithReference(catalogApi).WaitFor(catalogApi);

var orderingApi = builder.AddProject<Projects.Ordering_API>("ordering-api")
.WithReference(rabbitMq).WaitFor(rabbitMq)
.WithReference(orderDb).WaitFor(orderDb)
Expand Down
98 changes: 91 additions & 7 deletions tests/Basket.UnitTests/BasketServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,7 +22,7 @@ public class BasketServiceTests
public async Task GetBasketReturnsEmptyForNoUser()
{
var mockRepository = Substitute.For<IBasketRepository>();
var service = new BasketService(mockRepository, NullLogger<BasketService>.Instance);
var service = CreateService(mockRepository);
var serverCallContext = TestServerCallContext.Create(cancellationToken: TestContext.CancellationToken);
serverCallContext.SetUserState("__HttpContext", new DefaultHttpContext());

Expand All @@ -36,7 +38,7 @@ public async Task GetBasketReturnsItemsForValidUserId()
var mockRepository = Substitute.For<IBasketRepository>();
List<BasketItem> items = [new BasketItem { Id = "some-id" }];
mockRepository.GetBasketAsync("1").Returns(Task.FromResult(new CustomerBasket { BuyerId = "1", Items = items }));
var service = new BasketService(mockRepository, NullLogger<BasketService>.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")]));
Expand All @@ -54,7 +56,7 @@ public async Task GetBasketReturnsEmptyForInvalidUserId()
var mockRepository = Substitute.For<IBasketRepository>();
List<BasketItem> items = [new BasketItem { Id = "some-id" }];
mockRepository.GetBasketAsync("1").Returns(Task.FromResult(new CustomerBasket { BuyerId = "1", Items = items }));
var service = new BasketService(mockRepository, NullLogger<BasketService>.Instance);
var service = CreateService(mockRepository);
var serverCallContext = TestServerCallContext.Create(cancellationToken: TestContext.CancellationToken);
var httpContext = new DefaultHttpContext();
serverCallContext.SetUserState("__HttpContext", httpContext);
Expand All @@ -71,7 +73,7 @@ public async Task UpdateBasketPersistsItemsForAuthenticatedUser()
var repository = Substitute.For<IBasketRepository>();
repository.UpdateBasketAsync(Arg.Any<CustomerBasket>())
.Returns(call => call.Arg<CustomerBasket>());
var service = new BasketService(repository, NullLogger<BasketService>.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 });
Expand All @@ -92,7 +94,7 @@ await repository.Received(1).UpdateBasketAsync(Arg.Is<CustomerBasket>(basket =>
public async Task UpdateBasketRejectsAnonymousUser()
{
var repository = Substitute.For<IBasketRepository>();
var service = new BasketService(repository, NullLogger<BasketService>.Instance);
var service = CreateService(repository);

var exception = await Assert.ThrowsAsync<RpcException>(() =>
service.UpdateBasket(new UpdateBasketRequest(), CreateContext(null!)));
Expand All @@ -107,7 +109,7 @@ public async Task UpdateBasketReturnsNotFoundWhenRepositoryCannotPersist()
var repository = Substitute.For<IBasketRepository>();
repository.UpdateBasketAsync(Arg.Any<CustomerBasket>())
.Returns(Task.FromResult<CustomerBasket>(null!));
var service = new BasketService(repository, NullLogger<BasketService>.Instance);
var service = CreateService(repository);

var exception = await Assert.ThrowsAsync<RpcException>(() =>
service.UpdateBasket(new UpdateBasketRequest(), CreateContext("missing")));
Expand All @@ -119,7 +121,7 @@ public async Task UpdateBasketReturnsNotFoundWhenRepositoryCannotPersist()
public async Task DeleteBasketRemovesAuthenticatedUsersBasket()
{
var repository = Substitute.For<IBasketRepository>();
var service = new BasketService(repository, NullLogger<BasketService>.Instance);
var service = CreateService(repository);

await service.DeleteBasket(new DeleteBasketRequest(), CreateContext("buyer-1"));

Expand All @@ -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<IBasketRepository>();
var paths = new List<string>();
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<RpcException>(() => 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<CustomerBasket>());
}

[TestMethod]
public async Task DuplicateProductsAreValidatedOnceAndQuantitiesPreserved()
{
var repository = Substitute.For<IBasketRepository>();
repository.UpdateBasketAsync(Arg.Any<CustomerBasket>()).Returns(call => call.Arg<CustomerBasket>());
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<IBasketRepository>();
repository.UpdateBasketAsync(Arg.Any<CustomerBasket>()).Returns(call => call.Arg<CustomerBasket>());
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<CustomerBasket>(basket => basket.Items.Count == 0));
}

[TestMethod]
[DataRow(0)]
[DataRow(-1)]
public async Task InvalidProductIdsAreRejectedWithoutCallingCatalog(int productId)
{
var repository = Substitute.For<IBasketRepository>();
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<RpcException>(() => service.UpdateBasket(request, CreateContext("buyer-1")));

Assert.AreEqual(StatusCode.InvalidArgument, exception.StatusCode);
await repository.DidNotReceive().UpdateBasketAsync(Arg.Any<CustomerBasket>());
}

private static BasketService CreateService(IBasketRepository repository, Func<HttpRequestMessage, HttpResponseMessage> respond = null)
{
var factory = Substitute.For<IHttpClientFactory>();
factory.CreateClient("catalog").Returns(_ => new HttpClient(new CatalogHandler(respond)) { BaseAddress = new Uri("http://catalog-api") });
return new BasketService(repository, NullLogger<BasketService>.Instance, factory);
}

private sealed class CatalogHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> 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);
Expand Down
Loading