TASK-38: Deduce from balance after order

This commit is contained in:
Kacper Donat 2024-04-14 21:15:42 +02:00
parent e223ddfb4f
commit cbfb10ec10
13 changed files with 232 additions and 69 deletions

View File

@ -4,7 +4,7 @@ Feature:
Background: Background:
Given there exist following clients: Given there exist following clients:
| clientId | name | initialBalance | currentBalance | | clientId | name | initialBalance | currentBalance |
| 018edd2e-894a-78d7-b10c-16e05ca933a3 | Kacper | 10000 | 5000 | | 018edd2e-894a-78d7-b10c-16e05ca933a3 | Kacper | 10000 | 200000 |
Scenario: Frontend is able to send new orders Scenario: Frontend is able to send new orders
Given the request has the following body: Given the request has the following body:
@ -23,6 +23,7 @@ Feature:
""" """
When I send a POST request to "/orders" When I send a POST request to "/orders"
Then the response status should be 200 Then the response status should be 200
And client with id "018edd2e-894a-78d7-b10c-16e05ca933a3" should have balance of 71000
Scenario: Frontend must send properly formatted order request Scenario: Frontend must send properly formatted order request
Given the request has the following body: Given the request has the following body:

View File

@ -2,14 +2,14 @@
namespace App\Aggregate; namespace App\Aggregate;
use App\Contract\OrderDto; use App\Contract\Order;
use App\Entity\Client; use App\Entity\Client;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
readonly class AcceptOrderCommand readonly class AcceptOrderCommand
{ {
public function __construct( public function __construct(
public OrderDto $order, public Order $order,
public Client $client, public Client $client,
) { ) {
} }

View File

@ -2,14 +2,16 @@
namespace App\Contract; namespace App\Contract;
use App\Service\PricingStrategy;
use App\Service\TrivialPricingStrategy;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
class OrderDto class Order
{ {
public function __construct( public function __construct(
private Uuid $orderId, private Uuid $orderId,
private Uuid $clientId, private Uuid $clientId,
/** @var ProductDto[] */ /** @var ProductEntry[] */
private array $products, private array $products,
) { ) {
} }
@ -42,18 +44,28 @@ class OrderDto
public function setProducts(array $products): void public function setProducts(array $products): void
{ {
$this->products = array_combine( $this->products = array_combine(
array_map(fn (ProductDto $product) => $product->getProductId(), $products), array_map(fn (ProductEntry $product) => $product->getProductId(), $products),
array_values($products) array_values($products)
); );
} }
public function addProduct(ProductDto $product): void public function addProduct(ProductEntry $product): void
{ {
$this->products[$product->getProductId()] = $product; $this->products[$product->getProductId()] = $product;
} }
public function removeProduct(ProductDto $product): void public function removeProduct(ProductEntry $product): void
{ {
$this->products = array_filter($this->products, fn (ProductDto $other) => $other->getProductId() != $product->getProductId()); $this->products = array_filter($this->products, fn (ProductEntry $other) => $other->getProductId() != $product->getProductId());
}
public function getTotalWeight(): float
{
return array_sum(array_map(fn (ProductEntry $product) => $product->getTotalWeight(), $this->products));
}
public function getTotalPrice(PricingStrategy $strategy): int
{
return $strategy->calculateTotalPriceOfOrder($this);
} }
} }

View File

@ -2,7 +2,10 @@
namespace App\Contract; namespace App\Contract;
class ProductDto use App\Service\PricingStrategy;
use App\Service\TrivialPricingStrategy;
class ProductEntry
{ {
public function __construct( public function __construct(
private string $productId, private string $productId,
@ -37,6 +40,11 @@ class ProductDto
return $this->price; return $this->price;
} }
public function getPriceAsInt(): int
{
return round($this->getPrice() * 100);
}
public function setPrice(float $price): void public function setPrice(float $price): void
{ {
$this->price = $price; $this->price = $price;
@ -51,4 +59,14 @@ class ProductDto
{ {
$this->weight = $weight; $this->weight = $weight;
} }
public function getTotalWeight(): float
{
return $this->getWeight() * $this->getQuantity();
}
public function getTotalPrice(PricingStrategy $strategy): float
{
return $strategy->calculateTotalPriceOfProductEntry($this);
}
} }

View File

@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Aggregate\AcceptOrderCommand; use App\Aggregate\AcceptOrderCommand;
use App\Contract\OrderDto; use App\Contract\Order;
use App\Exception\ClientNotExistsException; use App\Exception\ClientNotExistsException;
use App\Service\ClientService; use App\Service\ClientService;
use App\Service\OrderService; use App\Service\OrderService;
@ -26,7 +26,7 @@ readonly class OrderController
} }
#[Route(name: 'accept', methods: ['POST'])] #[Route(name: 'accept', methods: ['POST'])]
public function accept(#[MapRequestPayload] OrderDto $orderDto): Response public function accept(#[MapRequestPayload] Order $orderDto): Response
{ {
try { try {
$command = new AcceptOrderCommand( $command = new AcceptOrderCommand(

View File

@ -56,4 +56,9 @@ class Client
{ {
return $this->currentBalance; return $this->currentBalance;
} }
public function deduceFromBalance(int $amount): void
{
$this->currentBalance -= $amount;
}
} }

View File

@ -43,4 +43,10 @@ class ClientService
return $this->clientRepository->save($client); return $this->clientRepository->save($client);
} }
public function save(Client $client): void
{
$this->clientRepository->save($client);
}
} }

View File

@ -6,11 +6,13 @@ use App\Aggregate\AcceptOrderCommand;
use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
class OrderService readonly class OrderService
{ {
public function __construct( public function __construct(
private readonly HttpClientInterface $crmClient, private HttpClientInterface $crmClient,
private readonly SerializerInterface $serializer, private SerializerInterface $serializer,
private PricingStrategy $pricingStrategy,
private ClientService $clientService,
) { ) {
} }
@ -19,5 +21,13 @@ class OrderService
$this->crmClient->request('POST', '/order', [ $this->crmClient->request('POST', '/order', [
'body' => $this->serializer->serialize($command->order, format: 'json') 'body' => $this->serializer->serialize($command->order, format: 'json')
]); ]);
$totalPrice = $command->order->getTotalPrice($this->pricingStrategy);
$command->client->deduceFromBalance($totalPrice);
$this->clientService->save($command->client);
} }
} }

View File

@ -0,0 +1,15 @@
<?php
namespace App\Service;
use App\Contract\Order;
use App\Contract\ProductEntry;
interface PricingStrategy
{
public function calculateTotalPriceOfProductEntry(ProductEntry $product): int;
public function calculateTotalPriceOfOrder(Order $order): int;
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Service;
use App\Contract\Order;
use App\Contract\ProductEntry;
class TrivialPricingStrategy implements PricingStrategy
{
public function calculateTotalPriceOfProductEntry(ProductEntry $product): int
{
$priceInBaseUnit = $product->getPriceAsInt();
return $priceInBaseUnit * $product->getQuantity();
}
public function calculateTotalPriceOfOrder(Order $order): int
{
return array_sum(array_map($this->calculateTotalPriceOfProductEntry(...), $order->getProducts()));
}
}

View File

@ -3,8 +3,8 @@
namespace App\Service; namespace App\Service;
use App\Aggregate\AcceptOrderCommand; use App\Aggregate\AcceptOrderCommand;
use App\Contract\OrderDto; use App\Contract\Order;
use App\Contract\ProductDto; use App\Contract\ProductEntry;
use App\Entity\Client; use App\Entity\Client;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Serializer\SerializerInterface;
@ -16,38 +16,6 @@ class OrderServiceTest extends TestCase
private const ORDER_ID = '018eddae-ff52-7813-9f88-ada9e61a76f3'; private const ORDER_ID = '018eddae-ff52-7813-9f88-ada9e61a76f3';
private const CLIENT_ID = '018edd2e-894a-78d7-b10c-16e05ca933a3'; private const CLIENT_ID = '018edd2e-894a-78d7-b10c-16e05ca933a3';
private function createValidOrderDto(): OrderDto
{
return new OrderDto(
orderId: Uuid::fromRfc4122(self::ORDER_ID),
clientId: Uuid::fromRfc4122(self::CLIENT_ID),
products: [
new ProductDto('p1', 1, 100, 100),
new ProductDto('p2', 2, 300, 100),
new ProductDto('p3', 3, 200, 100),
new ProductDto('p4', 4, 400, 700),
new ProductDto('p5', 5, 800, 700),
]
);
}
public function testCrmGetsCalledOnValidOrderRequest()
{
$httpClientMock = $this->getMockBuilder(HttpClientInterface::class)->getMock();
$serializerMock = $this->getMockBuilder(SerializerInterface::class)->getMock();
$sut = new OrderService($httpClientMock, $serializerMock);
$httpClientMock->expects($this->once())->method('request')->with('POST', '/order');
$order = new AcceptOrderCommand(
$this->createValidOrderDto(),
$this->createClient(balance: 10000)
);
$sut->acceptOrder($order);
}
private function createClient(int $balance): Client private function createClient(int $balance): Client
{ {
return new Client( return new Client(
@ -58,4 +26,85 @@ class OrderServiceTest extends TestCase
); );
} }
private function createValidOrderDto(): Order
{
return new Order(
orderId: Uuid::fromRfc4122(self::ORDER_ID),
clientId: Uuid::fromRfc4122(self::CLIENT_ID),
products: [
new ProductEntry('p1', 1, 100.00, 100),
new ProductEntry('p2', 2, 300.00, 100),
new ProductEntry('p3', 3, 200.00, 100),
new ProductEntry('p4', 4, 400.00, 700),
new ProductEntry('p5', 5, 800.00, 700),
]
);
}
private function createOrderService(
?HttpClientInterface $httpClientMock = null,
?SerializerInterface $serializerMock = null,
?PricingStrategy $pricingStrategyMock = null,
?ClientService $clientServiceMock = null,
) {
$httpClientMock ??= $this->createMock(HttpClientInterface::class);
$serializerMock ??= $this->createMock(SerializerInterface::class);
$pricingStrategyMock ??= $this->createMock(PricingStrategy::class);
$clientServiceMock ??= $this->createMock(ClientService::class);
return new OrderService($httpClientMock, $serializerMock, $pricingStrategyMock, $clientServiceMock);
}
public function testCrmGetsCalledOnValidOrderRequest()
{
$httpClientMock = $this->createMock(HttpClientInterface::class);
$httpClientMock->expects($this->once())->method('request')->with('POST', '/order');
$order = new AcceptOrderCommand(
$this->createValidOrderDto(),
$this->createClient(balance: 100_00)
);
$sut = $this->createOrderService(httpClientMock: $httpClientMock);
$sut->acceptOrder($order);
}
public function testTotalPriceIsDeducedFromClientBalance()
{
$initialBalance = 10000_00;
$price = 6900_00;
$command = new AcceptOrderCommand(
$order = $this->createValidOrderDto(),
$client = $this->createClient($initialBalance),
);
$pricingStrategyMock = $this->createMock(PricingStrategy::class);
$pricingStrategyMock
->expects($this->atLeastOnce())
->method('calculateTotalPriceOfOrder')
->with($order)
->willReturn($price);
$sut = $this->createOrderService(pricingStrategyMock: $pricingStrategyMock);
$sut->acceptOrder($command);
$this->assertEquals($initialBalance, $client->getInitialBalance(), 'Initial balance got changed after placing order');
$this->assertEquals($initialBalance - $price, $client->getBalance(), 'Current balance not changed after placing order');
}
public function testClientIsUpdatedAfterPlacingOrder()
{
$command = new AcceptOrderCommand(
$this->createValidOrderDto(),
$client = $this->createClient(10000_00),
);
$clientServiceMock = $this->createMock(ClientService::class);
$clientServiceMock->expects($this->once())->method('save')->with($client);
$sut = $this->createOrderService(clientServiceMock: $clientServiceMock);
$sut->acceptOrder($command);
}
} }

View File

@ -0,0 +1,46 @@
<?php
namespace App\Service;
use App\Contract\Order;
use App\Contract\ProductEntry;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Uid\Uuid;
class TrivialPricingStrategyTest extends TestCase
{
public function testCalculatingSingularProductPrice()
{
$product = new ProductEntry(productId: 'p1', quantity: 1, price: 2.00, weight: 100);
$sut = new TrivialPricingStrategy();
$this->assertEquals(200, $sut->calculateTotalPriceOfProductEntry($product));
}
public function testCalculatingMultipleProductPrice()
{
$product = new ProductEntry(productId: 'p1', quantity: 5, price: 2.00, weight: 100);
$sut = new TrivialPricingStrategy();
$this->assertEquals(1000, $sut->calculateTotalPriceOfProductEntry($product));
}
public function testCalculatingOrderPrice()
{
$order = new Order(
orderId: Uuid::v4(),
clientId: Uuid::v4(),
products: [
new ProductEntry(productId: 'p1', quantity: 5, price: 2.00, weight: 100),
new ProductEntry(productId: 'p2', quantity: 2, price: 1.25, weight: 100),
]
);
$sut = new TrivialPricingStrategy();
$this->assertEquals(1250, $sut->calculateTotalPriceOfOrder($order));
}
}

View File

@ -1,20 +0,0 @@
<?php
namespace Behat;
use App\Entity\Client;
use App\Repository\ClientRepository;
use Behat\Behat\Context\Context;
use Behat\Behat\Tester\Exception\PendingException;
use Behat\Gherkin\Node\TableNode;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\Uid\Uuid;
class CrmContext implements Context
{
public function __construct(
protected readonly MockHttpClient $crmClient,
) {}
}