36: Add order validation
This commit is contained in:
parent
5cf26579fc
commit
05c1a270e5
@ -6,6 +6,7 @@ Feature:
|
||||
| clientId | name | initialBalance | currentBalance | locked |
|
||||
| 018edd2e-894a-78d7-b10c-16e05ca933a3 | Kacper | 10000 | 200000 | no |
|
||||
| 018ede1e-a587-76a8-88f3-23987a8dade9 | Jacek | 10000 | 60000000 | yes |
|
||||
| 018ede4c-f5ac-7be9-9f14-7c00d2636812 | Mirek | 10000 | -10000 | no |
|
||||
|
||||
Scenario: Frontend is able to send new orders
|
||||
Given the request has the following body:
|
||||
@ -71,3 +72,67 @@ Feature:
|
||||
"""
|
||||
When I send a POST request to "/orders"
|
||||
Then the response status should be 403
|
||||
|
||||
Scenario: Orders above 24 tons should not be allowed
|
||||
Given the request has the following body:
|
||||
"""
|
||||
{
|
||||
"orderId": "018eddae-ff52-7813-9f88-ada9e61a76f3",
|
||||
"clientId": "018edd2e-894a-78d7-b10c-16e05ca933a3",
|
||||
"products": [
|
||||
{ "productId": "p1", "quantity": 2, "price": 20.0, "weight": 100 },
|
||||
{ "productId": "p2", "quantity": 1, "price": 100.0, "weight": 200 },
|
||||
{ "productId": "p3", "quantity": 5, "price": 200.0, "weight": 100 },
|
||||
{ "productId": "p4", "quantity": 1, "price": 50.0, "weight": 400 },
|
||||
{ "productId": "p5", "quantity": 10, "price": 10.0, "weight": 10000 }
|
||||
]
|
||||
}
|
||||
"""
|
||||
When I send a POST request to "/orders"
|
||||
Then the response status should be 400
|
||||
|
||||
Scenario: Clients with negative balance should not be allowed
|
||||
Given the request has the following body:
|
||||
"""
|
||||
{
|
||||
"orderId": "018eddae-ff52-7813-9f88-ada9e61a76f3",
|
||||
"clientId": "018ede4c-f5ac-7be9-9f14-7c00d2636812",
|
||||
"products": [
|
||||
{ "productId": "p1", "quantity": 2, "price": 20.0, "weight": 100 },
|
||||
{ "productId": "p2", "quantity": 1, "price": 100.0, "weight": 200 },
|
||||
{ "productId": "p3", "quantity": 5, "price": 200.0, "weight": 100 },
|
||||
{ "productId": "p4", "quantity": 1, "price": 50.0, "weight": 400 },
|
||||
{ "productId": "p5", "quantity": 10, "price": 10.0, "weight": 10000 }
|
||||
]
|
||||
}
|
||||
"""
|
||||
When I send a POST request to "/orders"
|
||||
Then the response status should be 400
|
||||
|
||||
Scenario: Orders below 5 products should not be allowed
|
||||
Given the request has the following body:
|
||||
"""
|
||||
{
|
||||
"orderId": "018eddae-ff52-7813-9f88-ada9e61a76f3",
|
||||
"clientId": "018edd2e-894a-78d7-b10c-16e05ca933a3",
|
||||
"products": [
|
||||
{ "productId": "p1", "quantity": 2, "price": 20.0, "weight": 100 }
|
||||
]
|
||||
}
|
||||
"""
|
||||
When I send a POST request to "/orders"
|
||||
Then the response status should be 400
|
||||
|
||||
Scenario: Invalid order should not deduce from balance
|
||||
Given the request has the following body:
|
||||
"""
|
||||
{
|
||||
"orderId": "018eddae-ff52-7813-9f88-ada9e61a76f3",
|
||||
"clientId": "018edd2e-894a-78d7-b10c-16e05ca933a3",
|
||||
"products": [
|
||||
{ "productId": "p1", "quantity": 2, "price": 20.0, "weight": 100 }
|
||||
]
|
||||
}
|
||||
"""
|
||||
When I send a POST request to "/orders"
|
||||
Then client with id "018edd2e-894a-78d7-b10c-16e05ca933a3" should have balance of 200000
|
||||
|
@ -4,11 +4,13 @@ namespace App\Aggregate;
|
||||
|
||||
use App\Contract\Order;
|
||||
use App\Entity\Client;
|
||||
use App\Validator as CustomConstraint;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
readonly class AcceptOrderCommand
|
||||
{
|
||||
public function __construct(
|
||||
#[CustomConstraint\ValidOrder]
|
||||
public Order $order,
|
||||
public Client $client,
|
||||
) {
|
||||
|
@ -8,12 +8,15 @@ use App\Aggregate\AcceptOrderCommand;
|
||||
use App\Contract\Order;
|
||||
use App\Exception\ClientLockedException;
|
||||
use App\Exception\ClientNotExistsException;
|
||||
use App\Exception\OrderInvalidException;
|
||||
use App\Service\ClientService;
|
||||
use App\Service\OrderService;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
#[AsController]
|
||||
#[Route(path: '/orders', name: 'orders_')]
|
||||
@ -22,6 +25,7 @@ readonly class OrderController
|
||||
public function __construct(
|
||||
private ClientService $clientService,
|
||||
private OrderService $orderService,
|
||||
private SerializerInterface $serializer,
|
||||
) {
|
||||
|
||||
}
|
||||
@ -42,6 +46,12 @@ readonly class OrderController
|
||||
return new Response(status: Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
} catch (ClientLockedException) {
|
||||
return new Response(status: Response::HTTP_FORBIDDEN);
|
||||
} catch (OrderInvalidException $orderInvalidException) {
|
||||
return new JsonResponse(
|
||||
$this->serializer->serialize($orderInvalidException->getViolations(), format: 'json'),
|
||||
status: Response::HTTP_BAD_REQUEST,
|
||||
json: true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
28
src/Exception/OrderInvalidException.php
Normal file
28
src/Exception/OrderInvalidException.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exception;
|
||||
|
||||
use App\Contract\Order;
|
||||
use Symfony\Component\Validator\ConstraintViolationListInterface;
|
||||
|
||||
class OrderInvalidException extends \LogicException
|
||||
{
|
||||
private ConstraintViolationListInterface $violations;
|
||||
|
||||
public function getViolations(): ConstraintViolationListInterface
|
||||
{
|
||||
return $this->violations;
|
||||
}
|
||||
|
||||
public static function createFromViolations(Order $order, ConstraintViolationListInterface $violations): self
|
||||
{
|
||||
$exception = new self(sprintf(
|
||||
'Order "%s" is not valid, contains %d errors.',
|
||||
$order->getOrderId()->toRfc4122(),
|
||||
$violations->count()
|
||||
));
|
||||
$exception->violations = $violations;
|
||||
|
||||
return $exception;
|
||||
}
|
||||
}
|
@ -4,7 +4,9 @@ namespace App\Service;
|
||||
|
||||
use App\Aggregate\AcceptOrderCommand;
|
||||
use App\Exception\ClientLockedException;
|
||||
use App\Exception\OrderInvalidException;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
readonly class OrderService
|
||||
@ -14,14 +16,22 @@ readonly class OrderService
|
||||
private SerializerInterface $serializer,
|
||||
private PricingStrategy $pricingStrategy,
|
||||
private ClientService $clientService,
|
||||
private ValidatorInterface $validator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \App\Exception\ClientLockedException
|
||||
* @throws \App\Exception\OrderInvalidException
|
||||
*/
|
||||
public function acceptOrder(AcceptOrderCommand $command)
|
||||
{
|
||||
$violations = $this->validator->validate($command);
|
||||
|
||||
if ($violations->count() > 0) {
|
||||
throw OrderInvalidException::createFromViolations($command->order, $violations);
|
||||
}
|
||||
|
||||
if ($command->client->isLocked()) {
|
||||
throw new ClientLockedException(sprintf("Client '%s' is locked.", $command->client->getName()));
|
||||
}
|
||||
|
14
src/Validator/ValidOrder.php
Normal file
14
src/Validator/ValidOrder.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Validator;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_PROPERTY)]
|
||||
class ValidOrder extends Constraint
|
||||
{
|
||||
public int $minProductCount = 5;
|
||||
|
||||
public float $maxWeight = 24_000;
|
||||
|
||||
}
|
80
src/Validator/ValidOrderValidator.php
Normal file
80
src/Validator/ValidOrderValidator.php
Normal file
@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Validator;
|
||||
|
||||
use App\Aggregate\AcceptOrderCommand;
|
||||
use App\Contract\Order;
|
||||
use App\Entity\Client;
|
||||
use App\Service\ClientService;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedValueException;
|
||||
|
||||
class ValidOrderValidator extends ConstraintValidator
|
||||
{
|
||||
public function __construct(private readonly ClientService $clientService)
|
||||
{
|
||||
}
|
||||
|
||||
public function validate($value, Constraint $constraint)
|
||||
{
|
||||
if (!$value instanceof Order) {
|
||||
throw new UnexpectedValueException($value, Order::class);
|
||||
}
|
||||
|
||||
if (!$constraint instanceof ValidOrder) {
|
||||
throw new UnexpectedValueException($constraint, ValidOrder::class);
|
||||
}
|
||||
|
||||
$client = $this->getClient($value);
|
||||
|
||||
$this->checkClientBalance($client);
|
||||
|
||||
$this->checkMinimumProductCount($value, $constraint);
|
||||
$this->checkMaximumWeight($value, $constraint);
|
||||
}
|
||||
|
||||
private function checkMinimumProductCount(Order $value, ValidOrder $constraint): void
|
||||
{
|
||||
if (count($value->getProducts()) < $constraint->minProductCount) {
|
||||
$this->context
|
||||
->buildViolation('The number of products in order should be no less than {{ min }}')
|
||||
->setParameter('{{ min }}', $constraint->minProductCount)
|
||||
->atPath('products')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
private function checkMaximumWeight(Order $value, ValidOrder $constraint): void
|
||||
{
|
||||
if ($value->getTotalWeight() > $constraint->maxWeight) {
|
||||
$this->context
|
||||
->buildViolation('Total weight of products in order must not exceed {{ max }}kg')
|
||||
->setParameter('{{ max }}', $constraint->maxWeight)
|
||||
->atPath('products')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
private function getClient(Order $value)
|
||||
{
|
||||
$root = $this->context->getRoot();
|
||||
|
||||
if ($root instanceof AcceptOrderCommand) {
|
||||
return $root->client;
|
||||
}
|
||||
|
||||
return $this->clientService->getClient($value->getClientId());
|
||||
}
|
||||
|
||||
private function checkClientBalance(Client $client): void
|
||||
{
|
||||
if ($client->getBalance() < 0) {
|
||||
$this->context
|
||||
->buildViolation('Total balance of the client is negative')
|
||||
->atPath('clientId')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -9,6 +9,7 @@ use App\Entity\Client;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class OrderServiceTest extends TestCase
|
||||
@ -46,13 +47,15 @@ class OrderServiceTest extends TestCase
|
||||
?SerializerInterface $serializerMock = null,
|
||||
?PricingStrategy $pricingStrategyMock = null,
|
||||
?ClientService $clientServiceMock = null,
|
||||
?ValidatorInterface $validatorMock = null,
|
||||
) {
|
||||
$httpClientMock ??= $this->createMock(HttpClientInterface::class);
|
||||
$serializerMock ??= $this->createMock(SerializerInterface::class);
|
||||
$pricingStrategyMock ??= $this->createMock(PricingStrategy::class);
|
||||
$clientServiceMock ??= $this->createMock(ClientService::class);
|
||||
$validatorMock ??= $this->createMock(ValidatorInterface::class);
|
||||
|
||||
return new OrderService($httpClientMock, $serializerMock, $pricingStrategyMock, $clientServiceMock);
|
||||
return new OrderService($httpClientMock, $serializerMock, $pricingStrategyMock, $clientServiceMock, $validatorMock);
|
||||
}
|
||||
|
||||
public function testCrmGetsCalledOnValidOrderRequest()
|
||||
|
Loading…
Reference in New Issue
Block a user