diff --git a/src/Services/Ordering/Ordering.API/Application/Commands/CompleteOrderCommand.cs b/src/Services/Ordering/Ordering.API/Application/Commands/CompleteOrderCommand.cs new file mode 100644 index 0000000000..e8ec802539 --- /dev/null +++ b/src/Services/Ordering/Ordering.API/Application/Commands/CompleteOrderCommand.cs @@ -0,0 +1,16 @@ +namespace Microsoft.eShopOnContainers.Services.Ordering.API.Application.Commands; + +public class CompleteOrderCommand : IRequest +{ + + [DataMember] + public int OrderNumber { get; set; } + public CompleteOrderCommand() + { + + } + public CompleteOrderCommand(int orderNumber) + { + OrderNumber = orderNumber; + } +} diff --git a/src/Services/Ordering/Ordering.API/Application/Commands/CompleteOrderCommandHandler.cs b/src/Services/Ordering/Ordering.API/Application/Commands/CompleteOrderCommandHandler.cs new file mode 100644 index 0000000000..0f0a5da181 --- /dev/null +++ b/src/Services/Ordering/Ordering.API/Application/Commands/CompleteOrderCommandHandler.cs @@ -0,0 +1,48 @@ +namespace Microsoft.eShopOnContainers.Services.Ordering.API.Application.Commands; + +// Regular CommandHandler +public class CompleteOrderCommandHandler : IRequestHandler +{ + private readonly IOrderRepository _orderRepository; + + public CompleteOrderCommandHandler(IOrderRepository orderRepository) + { + _orderRepository = orderRepository; + } + + /// + /// Handler which processes the command when + /// customer executes complete order from app + /// + /// + /// + public async Task Handle(CompleteOrderCommand command, CancellationToken cancellationToken) + { + var orderToUpdate = await _orderRepository.GetAsync(command.OrderNumber); + if (orderToUpdate == null) + { + return false; + } + + orderToUpdate.SetCompletedStatus(); + return await _orderRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + } +} + + +// Use for Idempotency in Command process +public class CompleteOrderIdentifiedCommandHandler : IdentifiedCommandHandler +{ + public CompleteOrderIdentifiedCommandHandler( + IMediator mediator, + IRequestManager requestManager, + ILogger> logger) + : base(mediator, requestManager, logger) + { + } + + protected override bool CreateResultForDuplicateRequest() + { + return true; // Ignore duplicate requests for processing order. + } +} diff --git a/src/Services/Ordering/Ordering.API/Application/DomainEventHandlers/OrderCompletedDomainEventHandler.cs b/src/Services/Ordering/Ordering.API/Application/DomainEventHandlers/OrderCompletedDomainEventHandler.cs new file mode 100644 index 0000000000..04cd44fdcd --- /dev/null +++ b/src/Services/Ordering/Ordering.API/Application/DomainEventHandlers/OrderCompletedDomainEventHandler.cs @@ -0,0 +1,33 @@ +namespace Microsoft.eShopOnContainers.Services.Ordering.API.Application.DomainEventHandlers; + +public partial class OrderCompletedDomainEventHandler + : INotificationHandler +{ + private readonly IOrderRepository _orderRepository; + private readonly IBuyerRepository _buyerRepository; + private readonly ILogger _logger; + private readonly IOrderingIntegrationEventService _orderingIntegrationEventService; + + public OrderCompletedDomainEventHandler( + IOrderRepository orderRepository, + ILogger logger, + IBuyerRepository buyerRepository, + IOrderingIntegrationEventService orderingIntegrationEventService) + { + _orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _buyerRepository = buyerRepository ?? throw new ArgumentNullException(nameof(buyerRepository)); + _orderingIntegrationEventService = orderingIntegrationEventService; + } + + public async Task Handle(OrderCompletedDomainEvent domainEvent, CancellationToken cancellationToken) + { + OrderingApiTrace.LogOrderStatusUpdated(_logger, domainEvent.Order.Id, nameof(OrderStatus.Cancelled), OrderStatus.Cancelled.Id); + + var order = await _orderRepository.GetAsync(domainEvent.Order.Id); + var buyer = await _buyerRepository.FindByIdAsync(order.GetBuyerId.Value.ToString()); + + var integrationEvent = new OrderStatusChangedToCompletedIntegrationEvent(order.Id, order.OrderStatus.Name, buyer.Name); + await _orderingIntegrationEventService.AddAndSaveEventAsync(integrationEvent); + } +} diff --git a/src/Services/Ordering/Ordering.API/Application/IntegrationEvents/Events/OrderStatusChangedToCompletedIntegrationEvent.cs b/src/Services/Ordering/Ordering.API/Application/IntegrationEvents/Events/OrderStatusChangedToCompletedIntegrationEvent.cs new file mode 100644 index 0000000000..f68f941ec5 --- /dev/null +++ b/src/Services/Ordering/Ordering.API/Application/IntegrationEvents/Events/OrderStatusChangedToCompletedIntegrationEvent.cs @@ -0,0 +1,15 @@ +namespace Microsoft.eShopOnContainers.Services.Ordering.API.Application.IntegrationEvents.Events; + +public record OrderStatusChangedToCompletedIntegrationEvent : IntegrationEvent +{ + public int OrderId { get; } + public string OrderStatus { get; } + public string BuyerName { get; } + + public OrderStatusChangedToCompletedIntegrationEvent(int orderId, string orderStatus, string buyerName) + { + OrderId = orderId; + OrderStatus = orderStatus; + BuyerName = buyerName; + } +} diff --git a/src/Services/Ordering/Ordering.API/Application/Validations/CompleteOrderCommandValidator.cs b/src/Services/Ordering/Ordering.API/Application/Validations/CompleteOrderCommandValidator.cs new file mode 100644 index 0000000000..31ba48d688 --- /dev/null +++ b/src/Services/Ordering/Ordering.API/Application/Validations/CompleteOrderCommandValidator.cs @@ -0,0 +1,11 @@ +namespace Microsoft.eShopOnContainers.Services.Ordering.API.Application.Validations; + +public class CompleteOrderCommandValidator : AbstractValidator +{ + public CompleteOrderCommandValidator(ILogger logger) + { + RuleFor(order => order.OrderNumber).NotEmpty().WithMessage("No orderId found"); + + logger.LogTrace("INSTANCE CREATED - {ClassName}", GetType().Name); + } +} diff --git a/src/Services/Ordering/Ordering.API/Controllers/OrdersController.cs b/src/Services/Ordering/Ordering.API/Controllers/OrdersController.cs index df7572bb7f..e6843591c1 100644 --- a/src/Services/Ordering/Ordering.API/Controllers/OrdersController.cs +++ b/src/Services/Ordering/Ordering.API/Controllers/OrdersController.cs @@ -55,6 +55,36 @@ public async Task CancelOrderAsync([FromBody] CancelOrderCommand return Ok(); } + [Route("complete")] + [HttpPut] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task CompleteOrderAsync([FromBody] CompleteOrderCommand command, [FromHeader(Name = "x-requestid")] string requestId) + { + bool commandResult = false; + + if (Guid.TryParse(requestId, out Guid guid) && guid != Guid.Empty) + { + var requestCompleteOrder = new IdentifiedCommand(command, guid); + + _logger.LogInformation( + "Sending command: {CommandName} - {IdProperty}: {CommandId} ({@Command})", + requestCompleteOrder.GetGenericTypeName(), + nameof(requestCompleteOrder.Command.OrderNumber), + requestCompleteOrder.Command.OrderNumber, + requestCompleteOrder); + + commandResult = await _mediator.Send(requestCompleteOrder); + } + + if (!commandResult) + { + return BadRequest(); + } + + return Ok(); + } + [Route("ship")] [HttpPut] [ProducesResponseType(StatusCodes.Status200OK)] diff --git a/src/Services/Ordering/Ordering.API/Program.cs b/src/Services/Ordering/Ordering.API/Program.cs index 56d76de33e..cab0e0b95b 100644 --- a/src/Services/Ordering/Ordering.API/Program.cs +++ b/src/Services/Ordering/Ordering.API/Program.cs @@ -23,6 +23,7 @@ // Register the command validators for the validator behavior (validators based on FluentValidation library) services.AddSingleton, CancelOrderCommandValidator>(); +services.AddSingleton, CompleteOrderCommandValidator>(); services.AddSingleton, CreateOrderCommandValidator>(); services.AddSingleton>, IdentifiedCommandValidator>(); services.AddSingleton, ShipOrderCommandValidator>(); diff --git a/src/Services/Ordering/Ordering.Domain/AggregatesModel/OrderAggregate/Order.cs b/src/Services/Ordering/Ordering.Domain/AggregatesModel/OrderAggregate/Order.cs index 8e452c42f5..3a86bc1cf9 100644 --- a/src/Services/Ordering/Ordering.Domain/AggregatesModel/OrderAggregate/Order.cs +++ b/src/Services/Ordering/Ordering.Domain/AggregatesModel/OrderAggregate/Order.cs @@ -156,6 +156,19 @@ public void SetCancelledStatus() AddDomainEvent(new OrderCancelledDomainEvent(this)); } + public void SetCompletedStatus() + { + // make sure it is shipped before completing + if (_orderStatusId == OrderStatus.Shipped.Id) + { + StatusChangeException(OrderStatus.Completed); + } + + _orderStatusId = OrderStatus.Completed.Id; + _description = $"The order is completed."; + AddDomainEvent(new OrderCompletedDomainEvent(this)); // a postponed way to raise domain events + } + public void SetCancelledStatusWhenStockIsRejected(IEnumerable orderStockRejectedItems) { if (_orderStatusId == OrderStatus.AwaitingValidation.Id) diff --git a/src/Services/Ordering/Ordering.Domain/AggregatesModel/OrderAggregate/OrderStatus.cs b/src/Services/Ordering/Ordering.Domain/AggregatesModel/OrderAggregate/OrderStatus.cs index 8c3cc50fb4..91657d6e6d 100644 --- a/src/Services/Ordering/Ordering.Domain/AggregatesModel/OrderAggregate/OrderStatus.cs +++ b/src/Services/Ordering/Ordering.Domain/AggregatesModel/OrderAggregate/OrderStatus.cs @@ -11,6 +11,7 @@ public class OrderStatus public static OrderStatus Paid = new OrderStatus(4, nameof(Paid).ToLowerInvariant()); public static OrderStatus Shipped = new OrderStatus(5, nameof(Shipped).ToLowerInvariant()); public static OrderStatus Cancelled = new OrderStatus(6, nameof(Cancelled).ToLowerInvariant()); + public static OrderStatus Completed = new OrderStatus(7, nameof(Completed).ToLowerInvariant()); public OrderStatus(int id, string name) : base(id, name) @@ -18,7 +19,7 @@ public OrderStatus(int id, string name) } public static IEnumerable List() => - new[] { Submitted, AwaitingValidation, StockConfirmed, Paid, Shipped, Cancelled }; + new[] { Submitted, AwaitingValidation, StockConfirmed, Paid, Shipped, Cancelled, Completed }; public static OrderStatus FromName(string name) { diff --git a/src/Services/Ordering/Ordering.Domain/Events/OrderCompletedDomainEvent.cs b/src/Services/Ordering/Ordering.Domain/Events/OrderCompletedDomainEvent.cs new file mode 100644 index 0000000000..bf7255c6fe --- /dev/null +++ b/src/Services/Ordering/Ordering.Domain/Events/OrderCompletedDomainEvent.cs @@ -0,0 +1,12 @@ +namespace Microsoft.eShopOnContainers.Services.Ordering.Domain.Events; + +public class OrderCompletedDomainEvent : INotification +{ + public Order Order { get; } + + public OrderCompletedDomainEvent(Order order) + { + Order = order; + } +} + diff --git a/src/Services/Ordering/Ordering.FunctionalTests/OrderingScenarioBase.cs b/src/Services/Ordering/Ordering.FunctionalTests/OrderingScenarioBase.cs index f3fba40e3a..422fd1b3ef 100644 --- a/src/Services/Ordering/Ordering.FunctionalTests/OrderingScenarioBase.cs +++ b/src/Services/Ordering/Ordering.FunctionalTests/OrderingScenarioBase.cs @@ -50,6 +50,7 @@ public static class Put { public static string CancelOrder = "api/v1/orders/cancel"; public static string ShipOrder = "api/v1/orders/ship"; + public static string CompleteOrder = "api/v1/orders/complete"; } private class AuthStartupFilter : IStartupFilter diff --git a/src/Services/Ordering/Ordering.FunctionalTests/OrderingScenarios.cs b/src/Services/Ordering/Ordering.FunctionalTests/OrderingScenarios.cs index 85dd4ab3d1..cb21cb400c 100644 --- a/src/Services/Ordering/Ordering.FunctionalTests/OrderingScenarios.cs +++ b/src/Services/Ordering/Ordering.FunctionalTests/OrderingScenarios.cs @@ -19,6 +19,20 @@ public async Task Get_get_all_stored_orders_and_response_ok_status_code() response.EnsureSuccessStatusCode(); } + [Fact] + public async Task Complete_order_no_order_created_bad_request_response() + { + using var server = CreateServer(); + var content = new StringContent(BuildOrder(), UTF8Encoding.UTF8, "application/json") + { + Headers = { { "x-requestid", Guid.NewGuid().ToString() } } + }; + var response = await server.CreateClient() + .PutAsync(Put.CompleteOrder, content); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + [Fact] public async Task Cancel_order_no_order_created_bad_request_response() { @@ -29,8 +43,7 @@ public async Task Cancel_order_no_order_created_bad_request_response() }; var response = await server.CreateClient() .PutAsync(Put.CancelOrder, content); - - var s = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } diff --git a/src/Services/Ordering/Ordering.SignalrHub/IntegrationEvents/EventHandling/OrderStatusChangedToCompletedIntegrationEventHandler.cs b/src/Services/Ordering/Ordering.SignalrHub/IntegrationEvents/EventHandling/OrderStatusChangedToCompletedIntegrationEventHandler.cs new file mode 100644 index 0000000000..7824a26c30 --- /dev/null +++ b/src/Services/Ordering/Ordering.SignalrHub/IntegrationEvents/EventHandling/OrderStatusChangedToCompletedIntegrationEventHandler.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace Microsoft.eShopOnContainers.Services.Ordering.SignalrHub.IntegrationEvents.EventHandling; + +public class OrderStatusChangedToCompletedIntegrationEventHandler : IIntegrationEventHandler +{ + private readonly IHubContext _hubContext; + private readonly ILogger _logger; + + public OrderStatusChangedToCompletedIntegrationEventHandler( + IHubContext hubContext, + ILogger logger) + { + _hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + + public async Task Handle(OrderStatusChangedToCompletedIntegrationEvent @event) + { + using (_logger.BeginScope(new List> { new ("IntegrationEventContext", @event.Id) })) + { + _logger.LogInformation("Handling integration event: {IntegrationEventId} - ({@IntegrationEvent})", @event.Id, @event); + + await _hubContext.Clients + .Group(@event.BuyerName) + .SendAsync("UpdatedOrderState", new { OrderId = @event.OrderId, Status = @event.OrderStatus }); + } + } +} diff --git a/src/Services/Ordering/Ordering.SignalrHub/IntegrationEvents/Events/OrderStatusChangedToCompletedIntegrationEvent.cs b/src/Services/Ordering/Ordering.SignalrHub/IntegrationEvents/Events/OrderStatusChangedToCompletedIntegrationEvent.cs new file mode 100644 index 0000000000..b8113bc695 --- /dev/null +++ b/src/Services/Ordering/Ordering.SignalrHub/IntegrationEvents/Events/OrderStatusChangedToCompletedIntegrationEvent.cs @@ -0,0 +1,16 @@ +namespace Microsoft.eShopOnContainers.Services.Ordering.SignalrHub.IntegrationEvents.Events; + +public record OrderStatusChangedToCompletedIntegrationEvent : IntegrationEvent +{ + public int OrderId { get; } + public string OrderStatus { get; } + public string BuyerName { get; } + + public OrderStatusChangedToCompletedIntegrationEvent(int orderId, string orderStatus, string buyerName) + { + OrderId = orderId; + OrderStatus = orderStatus; + BuyerName = buyerName; + } +} + diff --git a/src/Services/Ordering/Ordering.SignalrHub/Program.cs b/src/Services/Ordering/Ordering.SignalrHub/Program.cs index 1b0abd0994..bfdbadd834 100644 --- a/src/Services/Ordering/Ordering.SignalrHub/Program.cs +++ b/src/Services/Ordering/Ordering.SignalrHub/Program.cs @@ -6,6 +6,7 @@ builder.Services.AddSingleton, OrderStatusChangedToAwaitingValidationIntegrationEventHandler>(); builder.Services.AddSingleton, OrderStatusChangedToCancelledIntegrationEventHandler>(); +builder.Services.AddSingleton, OrderStatusChangedToCompletedIntegrationEventHandler>(); builder.Services.AddSingleton, OrderStatusChangedToPaidIntegrationEventHandler>(); builder.Services.AddSingleton, OrderStatusChangedToShippedIntegrationEventHandler>(); builder.Services.AddSingleton, OrderStatusChangedToStockConfirmedIntegrationEventHandler>(); @@ -24,6 +25,7 @@ eventBus.Subscribe(); eventBus.Subscribe(); eventBus.Subscribe(); +eventBus.Subscribe(); eventBus.Subscribe(); await app.RunAsync(); diff --git a/src/Services/Ordering/Ordering.UnitTests/Application/OrdersWebApiTest.cs b/src/Services/Ordering/Ordering.UnitTests/Application/OrdersWebApiTest.cs index 0235c94b62..5a0948e7ae 100644 --- a/src/Services/Ordering/Ordering.UnitTests/Application/OrdersWebApiTest.cs +++ b/src/Services/Ordering/Ordering.UnitTests/Application/OrdersWebApiTest.cs @@ -48,6 +48,37 @@ public async Task Cancel_order_bad_request() Assert.Equal((int)System.Net.HttpStatusCode.BadRequest, actionResult.StatusCode); } + [Fact] + public async Task Complete_order_with_requestId_success() + { + //Arrange + _mediatorMock.Setup(x => x.Send(It.IsAny>(), default)) + .Returns(Task.FromResult(true)); + + //Act + var orderController = new OrdersController(_mediatorMock.Object, _orderQueriesMock.Object, _identityServiceMock.Object, _loggerMock.Object); + var actionResult = await orderController.CompleteOrderAsync(new CompleteOrderCommand(1), Guid.NewGuid().ToString()) as OkResult; + + //Assert + Assert.Equal((int)System.Net.HttpStatusCode.OK, actionResult.StatusCode); + + } + + [Fact] + public async Task Complete_order_bad_request() + { + //Arrange + _mediatorMock.Setup(x => x.Send(It.IsAny>(), default)) + .Returns(Task.FromResult(true)); + + //Act + var orderController = new OrdersController(_mediatorMock.Object, _orderQueriesMock.Object, _identityServiceMock.Object, _loggerMock.Object); + var actionResult = await orderController.CompleteOrderAsync(new CompleteOrderCommand(1), string.Empty) as BadRequestResult; + + //Assert + Assert.Equal((int)System.Net.HttpStatusCode.BadRequest, actionResult.StatusCode); + } + [Fact] public async Task Ship_order_with_requestId_success() { diff --git a/src/docker-compose.override.yml b/src/docker-compose.override.yml index 0868041255..5f00bc8bb1 100644 --- a/src/docker-compose.override.yml +++ b/src/docker-compose.override.yml @@ -260,7 +260,7 @@ services: webspa: environment: - - ASPNETCORE_ENVIRONMENT=Production + - ASPNETCORE_ENVIRONMENT=Development - ASPNETCORE_URLS=http://0.0.0.0:80 - IdentityUrl=http://${ESHOP_EXTERNAL_DNS_NAME_OR_IP}:5105 - PurchaseUrl=http://${ESHOP_EXTERNAL_DNS_NAME_OR_IP}:5121 diff --git a/src/eShopOnContainers-ServicesAndWebApps.sln b/src/eShopOnContainers-ServicesAndWebApps.sln index d73ec39c46..4fa1ce3171 100644 --- a/src/eShopOnContainers-ServicesAndWebApps.sln +++ b/src/eShopOnContainers-ServicesAndWebApps.sln @@ -443,8 +443,8 @@ Global {F16E3C6A-1C94-4EAB-BE91-099618060B68}.AppStore|x64.Build.0 = Release|Any CPU {F16E3C6A-1C94-4EAB-BE91-099618060B68}.AppStore|x86.ActiveCfg = Release|Any CPU {F16E3C6A-1C94-4EAB-BE91-099618060B68}.AppStore|x86.Build.0 = Release|Any CPU - {F16E3C6A-1C94-4EAB-BE91-099618060B68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F16E3C6A-1C94-4EAB-BE91-099618060B68}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F16E3C6A-1C94-4EAB-BE91-099618060B68}.Debug|Any CPU.ActiveCfg = Release|Any CPU + {F16E3C6A-1C94-4EAB-BE91-099618060B68}.Debug|Any CPU.Build.0 = Release|Any CPU {F16E3C6A-1C94-4EAB-BE91-099618060B68}.Debug|ARM.ActiveCfg = Debug|Any CPU {F16E3C6A-1C94-4EAB-BE91-099618060B68}.Debug|ARM.Build.0 = Debug|Any CPU {F16E3C6A-1C94-4EAB-BE91-099618060B68}.Debug|iPhone.ActiveCfg = Debug|Any CPU