Skip to content

Commit

Permalink
Remove item (#2)
Browse files Browse the repository at this point in the history
* Remove item

Let the cashier remove/cancel an item from the order
- Add a trash button to the order summary
- Add a new endpoint to cashier API
- Add RemoveItem and Apply (OrderItemRemoved) methods to Order entity
- Add Total property to OrderItem ValueObject and use it to calculate the Order Total instead of Price
- Update AntDesign package to latest version
- Change the Place order modal to use Ant component
- Removed custom Modal component

* Add test case

---------

Co-authored-by: Andre Foresti <[email protected]>
  • Loading branch information
aforesti and Andre Foresti authored May 26, 2024
1 parent 1ff2cd7 commit 0834788
Show file tree
Hide file tree
Showing 13 changed files with 321 additions and 68 deletions.
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.8.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.8.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.8.1" />
<PackageVersion Include="AntDesign" Version="0.15.5" />
<PackageVersion Include="AntDesign" Version="0.19.0" />
<PackageVersion Include="Ardalis.GuardClauses" Version="4.5.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.2" />
<PackageVersion Include="coverlet.msbuild" Version="6.0.2" />
Expand Down
7 changes: 7 additions & 0 deletions src/Cashier/NCafe.Cashier.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@
})
.WithName("AddItemToOrder");

app.MapPost("/orders/remove-item", async (IMediator mediator, RemoveItemFromOrder command) =>
{
await mediator.Send(command);
return Results.Accepted();
})
.WithName("RemoveItemFromOrder");

app.MapPost("/orders/place", async (IMediator mediator, PlaceOrder command) =>
{
await mediator.Send(command);
Expand Down
123 changes: 123 additions & 0 deletions src/Cashier/NCafe.Cashier.Domain.Tests/Commands/RemoveItemTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
using NCafe.Cashier.Domain.Commands;
using NCafe.Cashier.Domain.Entities;
using NCafe.Cashier.Domain.Exceptions;
using NCafe.Cashier.Domain.ValueObjects;
using NCafe.Core.Repositories;
using System.Threading;

namespace NCafe.Cashier.Domain.Tests.Commands;

public class RemoveItemTests
{
private readonly RemoveItemFromOrderHandler _sut;
private readonly IRepository _repository;

public RemoveItemTests()
{
_repository = A.Fake<IRepository>();
_sut = new RemoveItemFromOrderHandler(_repository);
}

[Fact]
public async Task ShouldSaveOrder()
{
// Arrange
var orderId = Guid.NewGuid();
var productId = Guid.NewGuid();
var command = new RemoveItemFromOrder(orderId, productId, Quantity: 1);
var order = new Order(orderId, "cashier-1", DateTimeOffset.UtcNow);
order.AddItem(new OrderItem(productId, "product1", 1, 5));

A.CallTo(() => _repository.GetById<Order>(orderId))
.Returns(Task.FromResult(order));

// Act
await _sut.Handle(command, CancellationToken.None);

// Assert
A.CallTo(() => _repository.Save(
A<Order>.That.Matches(o => o.Id == orderId &&
o.Items.All(i => i.ProductId != productId))))
.MustHaveHappenedOnceExactly();
}

[Fact]
public async Task GivenOrderNotFound_ShouldThrow()
{
// Arrange
var orderId = Guid.NewGuid();
var productId = Guid.NewGuid();
var command = new RemoveItemFromOrder(orderId, productId, Quantity: 1);

// Act
var exception = await Record.ExceptionAsync(() => _sut.Handle(command, CancellationToken.None));

// Assert
exception.ShouldBeOfType<OrderNotFoundException>();
}

[Fact]
public async Task GivenNotNewOrder_ShouldThrow()
{
// Arrange
var orderId = Guid.NewGuid();
var productId = Guid.NewGuid();
var command = new RemoveItemFromOrder(orderId, productId, Quantity: 1);
var order = new Order(orderId, "cashier-1", DateTimeOffset.UtcNow);
order.AddItem(new OrderItem(productId, "product1", quantity: 1, 5));
order.PlaceOrder(new Customer("John Doe"), DateTimeOffset.UtcNow);

A.CallTo(() => _repository.GetById<Order>(orderId))
.Returns(Task.FromResult(order));

// Act
var exception = await Record.ExceptionAsync(() => _sut.Handle(command, CancellationToken.None));

// Assert
exception.ShouldBeOfType<CannotRemoveItemFromOrderException>();
}

[Fact]
public async Task GivenRemovingMoreItemsThanOrdered_ShouldThrow()
{
// Arrange
var orderId = Guid.NewGuid();
var productId = Guid.NewGuid();
var command = new RemoveItemFromOrder(orderId, productId, Quantity: 2);
var order = new Order(orderId, "cashier-1", DateTimeOffset.UtcNow);
order.AddItem(new OrderItem(productId, "product1", 1, 5));

A.CallTo(() => _repository.GetById<Order>(orderId))
.Returns(Task.FromResult(order));

// Act
var exception = await Record.ExceptionAsync(() => _sut.Handle(command, CancellationToken.None));

// Assert
exception.ShouldBeOfType<CannotRemoveMoreItemsThanOrderedException>();
}

[Fact]
public async Task GivenRemovingLessItemsThanOrdered_ShouldSaveOrder()
{
// Arrange
var orderId = Guid.NewGuid();
var productId = Guid.NewGuid();
var command = new RemoveItemFromOrder(orderId, productId, Quantity: 1);
var order = new Order(orderId, "cashier-1", DateTimeOffset.UtcNow);
order.AddItem(new OrderItem(productId, "product1", 2, 5));

A.CallTo(() => _repository.GetById<Order>(orderId))
.Returns(Task.FromResult(order));

// Act
await _sut.Handle(command, CancellationToken.None);

// Assert
A.CallTo(() => _repository.Save(
A<Order>.That.Matches(o => o.Id == orderId &&
o.Items.Any(i => i.ProductId == productId && i.Quantity == 1))))
.MustHaveHappenedOnceExactly();
}

}
25 changes: 25 additions & 0 deletions src/Cashier/NCafe.Cashier.Domain/Commands/RemoveItemFromOrder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using MediatR;
using NCafe.Cashier.Domain.Entities;
using NCafe.Cashier.Domain.Exceptions;
using NCafe.Cashier.Domain.ReadModels;
using NCafe.Cashier.Domain.ValueObjects;
using NCafe.Core.ReadModels;
using NCafe.Core.Repositories;

namespace NCafe.Cashier.Domain.Commands;

public record RemoveItemFromOrder(Guid OrderId, Guid ProductId, int Quantity) : IRequest;

internal sealed class RemoveItemFromOrderHandler(IRepository repository) : IRequestHandler<RemoveItemFromOrder>
{
private readonly IRepository _repository = repository;

public async Task Handle(RemoveItemFromOrder command, CancellationToken cancellationToken)
{
var order = await _repository.GetById<Order>(command.OrderId) ?? throw new OrderNotFoundException(command.OrderId);

order.RemoveItem(command.ProductId, command.Quantity);

await _repository.Save(order);
}
}
45 changes: 44 additions & 1 deletion src/Cashier/NCafe.Cashier.Domain/Entities/Order.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,28 @@ public void AddItem(OrderItem orderItem)
RaiseEvent(new OrderItemAdded(Id, orderItem.ProductId, orderItem.Quantity, orderItem.Name, orderItem.Price));
}

public void RemoveItem(Guid productId, int quantity)
{
if (Status != OrderStatus.New)
{
throw new CannotRemoveItemFromOrderException(Id, productId);
}

var item = Items.FirstOrDefault(i => i.ProductId == productId);

if (item is null)
{
throw new OrderItemNotFoundException(Id, productId);
}

if (item.Quantity < quantity)
{
throw new CannotRemoveMoreItemsThanOrderedException(Id, productId, quantity);
}

RaiseEvent(new OrderItemRemoved(Id, productId, quantity));
}

public void PlaceOrder(Customer customer, DateTimeOffset placedAt)
{
Guard.Against.Null(customer);
Expand Down Expand Up @@ -79,8 +101,29 @@ private void Apply(OrderCreated @event)

private void Apply(OrderItemAdded @event)
{
var item = _items.FirstOrDefault(i => i.ProductId == @event.ProductId);
if (item is not null)
{
item.IncreaseQuantity(@event.Quantity);
Total = Items.Sum(i => i.Total);
return;
}

_items.Add(new OrderItem(@event.ProductId, @event.Name, @event.Quantity, @event.Price));
Total = Items.Sum(i => i.Price);
Total = Items.Sum(i => i.Total);
}

private void Apply(OrderItemRemoved @event)
{
var item = _items.First(i => i.ProductId == @event.ProductId);
if (item.Quantity == @event.Quantity)
{
_items.Remove(item);
Total = Items.Sum(i => i.Total);
return;
}
item.DecreaseQuantity(@event.Quantity);
Total = Items.Sum(i => i.Total);
}

private void Apply(OrderPlaced @event)
Expand Down
16 changes: 16 additions & 0 deletions src/Cashier/NCafe.Cashier.Domain/Events/OrderItemRemoved.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using NCafe.Core.Domain;

namespace NCafe.Cashier.Domain.Events;

public sealed record OrderItemRemoved : Event
{
public OrderItemRemoved(Guid id, Guid productId, int quantity)
{
Id = id;
ProductId = productId;
Quantity = quantity;
}

public Guid ProductId { get; }
public int Quantity { get; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using NCafe.Core.Exceptions;

namespace NCafe.Cashier.Domain.Exceptions;

public class CannotRemoveItemFromOrderException(Guid orderId, Guid productId)
: DomainException($"Cannot remove item '{productId}' from order '{orderId}' when status is not New.")
{
public Guid OrderId { get; } = orderId;
public Guid ProductId { get; } = productId;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using NCafe.Core.Exceptions;

namespace NCafe.Cashier.Domain.Exceptions;

public class CannotRemoveMoreItemsThanOrderedException(Guid orderId, Guid productId, int quantity)
: DomainException($"Cannot remove more items ({quantity}) than existing in the order '{orderId}'.")
{
public Guid OrderId { get; } = orderId;
public Guid ProductId { get; } = productId;
public int Quantity { get; } = quantity;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using NCafe.Core.Exceptions;

namespace NCafe.Cashier.Domain.Exceptions;

public class OrderItemNotFoundException(Guid orderId, Guid productId)
: DomainException($"Order item {productId} was not found in order '{orderId}'.")
{
public Guid OrderId { get; } = orderId;
public Guid ProductId { get; } = productId;
}
15 changes: 14 additions & 1 deletion src/Cashier/NCafe.Cashier.Domain/ValueObjects/OrderItem.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Ardalis.GuardClauses;
using NCafe.Cashier.Domain.Exceptions;

namespace NCafe.Cashier.Domain.ValueObjects;

Expand All @@ -14,6 +15,18 @@ public OrderItem(Guid productId, string name, int quantity, decimal price)

public Guid ProductId { get; }
public string Name { get; }
public int Quantity { get; }
public int Quantity { get; private set; }
public decimal Price { get; }

public decimal Total => Quantity * Price;

public void IncreaseQuantity(int quantity)
{
Quantity += quantity;
}

public void DecreaseQuantity(int quantity)
{
Quantity -= quantity;
}
}
5 changes: 4 additions & 1 deletion src/UI/NCafe.Web/Models/Order.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
namespace NCafe.Web.Models;
using System.ComponentModel.DataAnnotations;

namespace NCafe.Web.Models;

public class Order
{
public Guid Id { get; set; }
[Required]
public string CustomerName { get; set; }
public List<OrderItem> Items { get; set; } = new();
public decimal Total => Items.Sum(x => x.Total);
Expand Down
Loading

0 comments on commit 0834788

Please sign in to comment.