diff --git a/README.md b/README.md
index e6e74af..82f766d 100644
--- a/README.md
+++ b/README.md
@@ -37,8 +37,45 @@ _TBD: make docker installation_
```
## API documentation
- Two end-points are available for getting the API documentation:
+Two end-points are available for getting the API documentation:
- `/api/doc` swagger ui.
- `/api/doc.json`
as above but in Json format
for consuming by [Postman](https://www.postman.com/product/what-is-postman/), for example.
+
+## Order status notifications
+A subscription allows you to get notifications when the order status has been changed.
+
+### Channels and messages
+A notification sends via channel. By default, `http` channel is available.
+`http` does a POST/PUT/PATCH request to a predefined url with json payload.
+
+A message is used to represent data in the channel. There is `simple` message type, which
+will turn an order and payment gateway response into a plain array.
+
+To get a list of available channels and messages, use `subscribe:channels` console command.
+
+If you want to develop your own channel and/or message, add tag `app.subscriber.channel`
+for your custom channel and `app.subscriber.message` for a custom message (take a look
+at `services.yml`).
+
+### Subscribe example
+This is how to subscribe your external service when order status is changed to `payment_received`.
+```shell
+console subscriber:add-http --order-status payment_received --channel-message simple https://backend.my-service.com/api/order-payment
+```
+
+When the order gets `payment_received` status due to payment gateway response,
+a POST http request will be sent to https://backend.my-service.com/api/order-payment end point.
+The request will contain json data like this:
+```json
+{
+ "order_num": "1234567890",
+ "order_status": "payment_received",
+ "success": true,
+ "transaction_id": "1234567890",
+ "response": {
+ // original response from the payment gateway.
+ }
+}
+```
diff --git a/composer.json b/composer.json
index ffb120c..7814666 100644
--- a/composer.json
+++ b/composer.json
@@ -83,6 +83,7 @@
}
},
"require-dev": {
+ "dama/doctrine-test-bundle": "^8.2",
"doctrine/doctrine-fixtures-bundle": "^3.6",
"fakerphp/faker": "^1.23",
"phpunit/phpunit": "^9.5",
diff --git a/composer.lock b/composer.lock
index 48d8bec..7ffd42a 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "a2842e63c541fbc15ba099dc54da74ca",
+ "content-hash": "0ca29996ec9ed4587fff61e9edcc2fe2",
"packages": [
{
"name": "doctrine/cache",
@@ -5902,6 +5902,73 @@
}
],
"packages-dev": [
+ {
+ "name": "dama/doctrine-test-bundle",
+ "version": "v8.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/dmaicher/doctrine-test-bundle.git",
+ "reference": "1f81a280ea63f049d24e9c8ce00e557b18e0ff2f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/dmaicher/doctrine-test-bundle/zipball/1f81a280ea63f049d24e9c8ce00e557b18e0ff2f",
+ "reference": "1f81a280ea63f049d24e9c8ce00e557b18e0ff2f",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/dbal": "^3.3 || ^4.0",
+ "doctrine/doctrine-bundle": "^2.11.0",
+ "php": "^7.4 || ^8.0",
+ "psr/cache": "^1.0 || ^2.0 || ^3.0",
+ "symfony/cache": "^5.4 || ^6.3 || ^7.0",
+ "symfony/framework-bundle": "^5.4 || ^6.3 || ^7.0"
+ },
+ "require-dev": {
+ "behat/behat": "^3.0",
+ "friendsofphp/php-cs-fixer": "^3.27",
+ "phpstan/phpstan": "^1.2",
+ "phpunit/phpunit": "^8.0 || ^9.0 || ^10.0 || ^11.0",
+ "symfony/phpunit-bridge": "^6.3",
+ "symfony/process": "^5.4 || ^6.3 || ^7.0",
+ "symfony/yaml": "^5.4 || ^6.3 || ^7.0"
+ },
+ "type": "symfony-bundle",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "8.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "DAMA\\DoctrineTestBundle\\": "src/DAMA/DoctrineTestBundle"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "David Maicher",
+ "email": "mail@dmaicher.de"
+ }
+ ],
+ "description": "Symfony bundle to isolate doctrine database tests and improve test performance",
+ "keywords": [
+ "doctrine",
+ "isolation",
+ "performance",
+ "symfony",
+ "testing",
+ "tests"
+ ],
+ "support": {
+ "issues": "https://github.com/dmaicher/doctrine-test-bundle/issues",
+ "source": "https://github.com/dmaicher/doctrine-test-bundle/tree/v8.2.0"
+ },
+ "time": "2024-05-28T15:41:06+00:00"
+ },
{
"name": "doctrine/data-fixtures",
"version": "1.7.0",
diff --git a/config/bundles.php b/config/bundles.php
index 37ad000..a4ab00c 100644
--- a/config/bundles.php
+++ b/config/bundles.php
@@ -9,4 +9,5 @@
Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
+ DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
];
diff --git a/config/packages/dama_doctrine_test_bundle.yaml b/config/packages/dama_doctrine_test_bundle.yaml
new file mode 100644
index 0000000..3482cba
--- /dev/null
+++ b/config/packages/dama_doctrine_test_bundle.yaml
@@ -0,0 +1,5 @@
+when@test:
+ dama_doctrine_test:
+ enable_static_connection: true
+ enable_static_meta_data_cache: true
+ enable_static_query_cache: true
diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml
index 60bf931..d8b7dba 100644
--- a/config/packages/messenger.yaml
+++ b/config/packages/messenger.yaml
@@ -5,17 +5,18 @@ framework:
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
- # async: '%env(MESSENGER_TRANSPORT_DSN)%'
+ async: '%env(MESSENGER_TRANSPORT_DSN)%'
# failed: 'doctrine://default?queue_name=failed'
# sync: 'sync://'
routing:
# Route your messages to the transports
+ App\Message\NotifySubscriber: async
-# when@test:
-# framework:
-# messenger:
-# transports:
-# # replace with your transport name here (e.g., my_transport: 'in-memory://')
-# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test
-# async: 'in-memory://'
+when@test:
+ framework:
+ messenger:
+ transports:
+ # replace with your transport name here (e.g., my_transport: 'in-memory://')
+ # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test
+ async: 'in-memory://'
diff --git a/config/services.yaml b/config/services.yaml
index aff476e..2a96faa 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -36,3 +36,11 @@ services:
App\Order\OrderTotalAmountCalculator:
arguments:
$currencyCode: 'UAH'
+
+ App\Subscriber\Channel\HttpNotificationChannel:
+ tags:
+ - { name: 'app.subscriber.channel', type: 'http' }
+
+ App\Subscriber\Channel\SimpleArrayChannelMessage:
+ tags:
+ - { name: 'app.subscriber.message', type: 'simple' }
diff --git a/migrations/Version20240901095458.php b/migrations/Version20240901095458.php
new file mode 100644
index 0000000..1efec21
--- /dev/null
+++ b/migrations/Version20240901095458.php
@@ -0,0 +1,44 @@
+addSql('CREATE TABLE subscribers (
+ id INT AUTO_INCREMENT NOT NULL,
+ uuid BINARY(16) NOT NULL COMMENT \'(DC2Type:uuid)\',
+ hash VARCHAR(255) NOT NULL,
+ channel_type VARCHAR(255) NOT NULL,
+ order_status VARCHAR(255) NOT NULL,
+ channel_message VARCHAR(255) NOT NULL,
+ params JSON NOT NULL,
+ created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\',
+ UNIQUE INDEX UNIQ_2FCD16ACD17F50A6 (uuid),
+ UNIQUE INDEX UNIQ_2FCD16ACD1B862B8 (hash),
+ INDEX IDX_2FCD16ACB88F75C9 (order_status),
+ PRIMARY KEY(id)
+ ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
+ }
+
+ public function down(Schema $schema): void
+ {
+ // this down() migration is auto-generated, please modify it to your needs
+ $this->addSql('DROP TABLE subscribers');
+ }
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index c76a655..01988de 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -34,5 +34,6 @@
+
diff --git a/src/Command/PaymentGatewaysCommand.php b/src/Command/PaymentGatewaysCommand.php
index 437caed..0330a70 100644
--- a/src/Command/PaymentGatewaysCommand.php
+++ b/src/Command/PaymentGatewaysCommand.php
@@ -17,7 +17,7 @@
name: 'payment:gateways',
description: 'List of available payment gateways',
)]
-class PaymentGatewaysCommand extends Command
+final class PaymentGatewaysCommand extends Command
{
public function __construct(
private readonly PaymentGatewayRegistryInterface $paymentGateways,
diff --git a/src/Command/SubscriberAddHttpCommand.php b/src/Command/SubscriberAddHttpCommand.php
new file mode 100644
index 0000000..b8ea197
--- /dev/null
+++ b/src/Command/SubscriberAddHttpCommand.php
@@ -0,0 +1,88 @@
+addArgument('url', InputArgument::REQUIRED, 'Url to call.')
+ ->addOption(
+ 'http-method',
+ 'X',
+ InputOption::VALUE_OPTIONAL,
+ 'Http request method',
+ HttpNotificationChannel::DEFAULT_HTTP_METHOD,
+ )
+ ->addOption(
+ 'order-status',
+ 's',
+ InputOption::VALUE_REQUIRED,
+ 'Expected order status: ' . OrderStatus::formattedString(),
+ ''
+ )
+ ->addOption(
+ 'channel-message',
+ 'm',
+ InputOption::VALUE_REQUIRED,
+ 'Channel message type (see output of "subscriber:channels" for available message types)',
+ ''
+ )
+ ;
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+
+ $url = $input->getArgument('url');
+ $httpMethod = $input->getOption('http-method');
+ $orderStatusValue = $input->getOption('order-status');
+ $channelMessage = $input->getOption('channel-message');
+
+ try {
+ if (! ($orderStatus = OrderStatus::tryFrom($orderStatusValue))) {
+ throw new Exception(
+ sprintf('Invalid order status: "%s". Expected: %s.',
+ $orderStatusValue,
+ OrderStatus::formattedString()
+ )
+ );
+ }
+
+ $subscriber = $this->addHttpSubscriberAction->add($orderStatus, $url, $channelMessage, $httpMethod);
+
+ $io->success("Subscriber \"{$subscriber->getUuid()}\" has been added.");
+ } catch (\Throwable $e) {
+ $io->error($e->getMessage());
+ return Command::FAILURE;
+ }
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Command/SubscriberChannelsCommand.php b/src/Command/SubscriberChannelsCommand.php
new file mode 100644
index 0000000..604e165
--- /dev/null
+++ b/src/Command/SubscriberChannelsCommand.php
@@ -0,0 +1,49 @@
+channelCollection->getNotificationChannelTypes();
+ $messageTypes = $this->channelCollection->getMessageTypes();
+
+ if ($channelTypes) {
+ $io->title('Notification channels:');
+ $io->block($channelTypes);
+ } else {
+ $io->warning('No subscriber notification channels found.');
+ }
+
+ if ($messageTypes) {
+ $io->title('Channel messages:');
+ $io->block($messageTypes);
+ } else {
+ $io->warning('No channel messages found.');
+ }
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Command/SubscriberGetCommand.php b/src/Command/SubscriberGetCommand.php
new file mode 100644
index 0000000..51f3d34
--- /dev/null
+++ b/src/Command/SubscriberGetCommand.php
@@ -0,0 +1,99 @@
+addArgument(
+ 'uuid',
+ InputArgument::OPTIONAL,
+ 'Get subscriber by its uuid',
+ )
+ ->addOption(
+ 'order-status',
+ 's',
+ InputOption::VALUE_REQUIRED,
+ 'Filter by order status: ' . OrderStatus::formattedString(),
+ )
+ ;
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+
+ try {
+ $uuid = $input->getArgument('uuid');
+ $orderStatus = $this->getOrderStatus($input->getOption('order-status'));
+
+ if ($uuid) {
+ $subscribers = $this->subscriberRepository->findBy(['uuid' => $uuid]);
+ } else {
+ $subscribers = $this->subscriberRepository->getList($orderStatus);
+ }
+
+ foreach ($subscribers as $subscriber) {
+ $io->title($subscriber->getUuid()->toRfc4122());
+ $io->block([
+ 'Order status: ' . $subscriber->getOrderStatus()->value,
+ 'Channel: ' . $subscriber->getChannelType(),
+ 'Message: ' . $subscriber->getChannelMessage(),
+ 'Added: ' . $subscriber->getCreatedAt()->format('Y-m-d H:i:s'),
+ 'Parameters: ' . json_encode($subscriber->getParams(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
+ ]);
+ }
+
+ if (! $subscribers) {
+ $io->warning('No subscribers found.');
+ }
+ } catch (Throwable $exception) {
+ $io->error($exception->getMessage());
+ return Command::FAILURE;
+ }
+
+
+ return Command::SUCCESS;
+ }
+
+ private function getOrderStatus(?string $value): OrderStatus | null
+ {
+ if (! $value) {
+ return null;
+ }
+
+ $orderStatus = OrderStatus::tryFrom($value);
+ if (!$orderStatus) {
+ throw new Exception("Invalid order status '$value'. Allowed values: "
+ . OrderStatus::formattedString());
+ }
+
+ return $orderStatus;
+ }
+}
diff --git a/src/Command/SubscriberRemoveCommand.php b/src/Command/SubscriberRemoveCommand.php
new file mode 100644
index 0000000..4d75346
--- /dev/null
+++ b/src/Command/SubscriberRemoveCommand.php
@@ -0,0 +1,63 @@
+addArgument('uuid', InputArgument::REQUIRED, 'Subscriber uuid')
+ ;
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ $uuid = $input->getArgument('uuid');
+
+ /** @var SubscriberRepository $subscriberRepository */
+ $subscriberRepository = $this->entityManager->getRepository(Subscriber::class);
+
+ try {
+ $subscriber = $subscriberRepository->findOneBy(['uuid' => $uuid]);
+ if (! $subscriber) {
+ throw new Exception("Subscriber \"$uuid\" not found.");
+ }
+
+ $this->entityManager->remove($subscriber);
+ $this->entityManager->flush();
+
+ $io->success("Subscriber \"$uuid\" has been removed.");
+ } catch (Throwable $exception) {
+ $io->error($exception->getMessage());
+ return Command::FAILURE;
+ }
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Controller/Api/V1/OrderController.php b/src/Controller/Api/V1/OrderController.php
index fa4776a..f1ceab7 100644
--- a/src/Controller/Api/V1/OrderController.php
+++ b/src/Controller/Api/V1/OrderController.php
@@ -8,6 +8,7 @@
use App\Entity\Order;
use App\Entity\OrderProduct;
use App\Entity\OrderStatus;
+use App\Event\OrderWasCreated;
use App\Order\OrderTotalAmountCalculator;
use App\Order\Workflow\OrderWorkflowFactory;
use App\Payment\Common\Exception\PaymentGatewayIsNotRegisteredException;
@@ -23,10 +24,15 @@
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
#[Route('/api/v1/order', name: 'api_v1_order_')]
class OrderController extends AbstractController
{
+ public function __construct(private readonly EventDispatcherInterface $eventDispatcher)
+ {
+ }
+
#[OA\Post(
description: 'Purchase order.',
)]
@@ -75,6 +81,8 @@ public function purchaseOrder(
$entityManager->flush();
+ $this->eventDispatcher->dispatch(new OrderWasCreated($order));
+
$purchaseRequest = $paymentGateway->getPurchaseRequestBuilder()->build($order);
$purchaseRequest->setCallbackUrl($this->generateUrl('api_v1_payment_callback_handler', [
diff --git a/src/DataFixtures/SubscriberFixture.php b/src/DataFixtures/SubscriberFixture.php
new file mode 100644
index 0000000..d1f13e1
--- /dev/null
+++ b/src/DataFixtures/SubscriberFixture.php
@@ -0,0 +1,39 @@
+persist($this->makeHttpSubscriber(OrderStatus::PAYMENT_RECEIVED));
+ $manager->persist($this->makeHttpSubscriber(OrderStatus::PAYMENT_FAILED));
+ $manager->persist($this->makeHttpSubscriber(OrderStatus::PAYMENT_PENDING));
+
+ $manager->flush();
+ }
+
+ private function makeHttpSubscriber(
+ OrderStatus $orderStatus,
+ string $url = 'http://local-test.com',
+ string $httpMethod = 'POST',
+ ): Subscriber {
+ return $this->addHttpSubscriberAction->add(
+ orderStatus: $orderStatus,
+ url: $url,
+ channelMessage: 'simple',
+ httpMethod: $httpMethod,
+ );
+ }
+}
diff --git a/src/Entity/Order.php b/src/Entity/Order.php
index 93ee5ab..7aaf354 100644
--- a/src/Entity/Order.php
+++ b/src/Entity/Order.php
@@ -32,7 +32,7 @@ class Order
private ?string $externalOrderId = null;
#[ORM\Column(type: 'uuid', unique: true)]
- private ?Uuid $uuid = null;
+ private ?Uuid $uuid;
#[ORM\Column(length: 16)]
private ?string $paymentGateway = null;
diff --git a/src/Entity/OrderStatus.php b/src/Entity/OrderStatus.php
index 8ce1ba4..6823171 100644
--- a/src/Entity/OrderStatus.php
+++ b/src/Entity/OrderStatus.php
@@ -4,12 +4,17 @@
namespace App\Entity;
+use BackedEnum;
+
enum OrderStatus: string
{
case CREATED = 'created';
- case ERROR = 'error';
- case FINISHED = 'finished';
case PAYMENT_PENDING = 'payment_pending';
case PAYMENT_RECEIVED = 'payment_received';
case PAYMENT_FAILED = 'payment_failed';
+
+ public static function formattedString(string $delimiter = ', '): string
+ {
+ return implode($delimiter, array_map(fn (BackedEnum $enum): string => $enum->value, self::cases()));
+ }
}
diff --git a/src/Entity/Subscriber.php b/src/Entity/Subscriber.php
new file mode 100644
index 0000000..63972b2
--- /dev/null
+++ b/src/Entity/Subscriber.php
@@ -0,0 +1,124 @@
+uuid = Uuid::v7();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getUuid(): Uuid
+ {
+ return $this->uuid;
+ }
+
+ public function getHash(): ?string
+ {
+ return $this->hash;
+ }
+
+ public function getChannelType(): ?string
+ {
+ return $this->channelType;
+ }
+
+ public function getOrderStatus(): ?OrderStatus
+ {
+ return $this->orderStatus;
+ }
+
+ public function getCreatedAt(): ?DateTimeImmutable
+ {
+ return $this->createdAt;
+ }
+
+ public function getParams(): array
+ {
+ return $this->params;
+ }
+
+ /**
+ * Generates a hash of order status, notification type and parameters.
+ *
+ * @return non-empty-string
+ */
+ public function generateHash(): string
+ {
+ $this->hash = md5($this->channelType . $this->orderStatus->value . serialize($this->params));
+
+ return $this->hash;
+ }
+
+ public function setChannelType(string $channelType): void
+ {
+ $this->channelType = $channelType;
+ }
+
+ public function setOrderStatus(OrderStatus $orderStatus): void
+ {
+ $this->orderStatus = $orderStatus;
+ }
+
+ public function setParams(array $params): void
+ {
+ $this->params = $params;
+ }
+
+ public function setCreatedAtNow(): void
+ {
+ $this->createdAt = new DateTimeImmutable();
+ }
+
+ public function getChannelMessage(): ?string
+ {
+ return $this->channelMessage;
+ }
+
+ public function setChannelMessage(string $channelMessage): void
+ {
+ $this->channelMessage = $channelMessage;
+ }
+}
diff --git a/src/Event/OrderStatusWasChanged.php b/src/Event/OrderStatusWasChanged.php
new file mode 100644
index 0000000..0046af3
--- /dev/null
+++ b/src/Event/OrderStatusWasChanged.php
@@ -0,0 +1,21 @@
+subscriberRepository->getList($event->order->getStatus());
+
+ foreach ($subscribers as $subscriber) {
+ $message = new NotifySubscriber(
+ orderId: $event->order->getId(),
+ subscriberId: $subscriber->getId(),
+ paymentProcessingId: $event->paymentProcessing->getId(),
+ transactionId: $event->response->getTransactionId(),
+ responseMessage: $event->response->getMessage(),
+ );
+ $this->messageBus->dispatch($message);
+ }
+ }
+}
diff --git a/src/Message/NotifySubscriber.php b/src/Message/NotifySubscriber.php
new file mode 100644
index 0000000..011dac8
--- /dev/null
+++ b/src/Message/NotifySubscriber.php
@@ -0,0 +1,17 @@
+subscriberRepository->find($message->subscriberId);
+ if (! $subscriber) {
+ throw new EntityNotFoundException("Subscriber \"{$message->subscriberId}\" not found.");
+ }
+
+ $paymentProcessing = $this->paymentProcessingRepository->find($message->paymentProcessingId);
+ if (! $paymentProcessing) {
+ throw new EntityNotFoundException("Payment processing \"{$message->paymentProcessingId}\" not found.");
+ }
+
+ // TODO: check the order status and subscriber's expected order status.
+
+ $this->sendSubscriberNotificationAction->sendNotification($subscriber, $paymentProcessing);
+ } catch (EntityNotFoundException|ChannelMessageNotRegistered|NotificationChannelNotRegisteredException $e) {
+ $this->logger->error($e->getMessage());
+ return;
+ }
+ }
+}
diff --git a/src/Order/Workflow/OrderWorkflow.php b/src/Order/Workflow/OrderWorkflow.php
index f38f95e..4e3eadf 100644
--- a/src/Order/Workflow/OrderWorkflow.php
+++ b/src/Order/Workflow/OrderWorkflow.php
@@ -7,22 +7,27 @@
use App\Entity\Order;
use App\Entity\OrderStatus;
use App\Entity\PaymentProcessing;
+use App\Event\OrderStatusWasChanged;
use App\Payment\Common\Message\RequestInterface;
use App\Payment\Common\Message\ResponseInterface;
use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
-class OrderWorkflow
+final readonly class OrderWorkflow implements OrderWorkflowInterface
{
public function __construct(
- private readonly EntityManagerInterface $em,
- private readonly Order $order,
- private readonly RequestInterface | null $request,
- private readonly ResponseInterface | null $response,
+ private EntityManagerInterface $em,
+ private EventDispatcherInterface $eventDispatcher,
+ private Order $order,
+ private RequestInterface | null $request,
+ private ResponseInterface | null $response,
) {
}
public function setState(OrderStatus $orderStatus): void
{
+ $previousStatus = $this->order->getStatus();
+
$this->order->setStatus($orderStatus);
$this->em->persist($this->order);
@@ -34,5 +39,19 @@ public function setState(OrderStatus $orderStatus): void
$this->em->persist($paymentProcessing);
$this->em->flush();
+
+ $this->dispatchOrderStatusEvent($previousStatus, $paymentProcessing);
+ }
+
+ private function dispatchOrderStatusEvent(OrderStatus $previousStatus, PaymentProcessing $paymentProcessing): void
+ {
+ $event = new OrderStatusWasChanged(
+ previousStatus: $previousStatus,
+ order: $this->order,
+ paymentProcessing: $paymentProcessing,
+ response: $this->response,
+ );
+
+ $this->eventDispatcher->dispatch($event);
}
}
diff --git a/src/Order/Workflow/OrderWorkflowFactory.php b/src/Order/Workflow/OrderWorkflowFactory.php
index 185c716..1fb0a0f 100644
--- a/src/Order/Workflow/OrderWorkflowFactory.php
+++ b/src/Order/Workflow/OrderWorkflowFactory.php
@@ -8,11 +8,13 @@
use App\Payment\Common\Message\RequestInterface;
use App\Payment\Common\Message\ResponseInterface;
use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
class OrderWorkflowFactory
{
public function __construct(
protected readonly EntityManagerInterface $em,
+ protected readonly EventDispatcherInterface $eventDispatcher,
) {
}
@@ -20,7 +22,7 @@ public function createFromContext(
Order $order,
RequestInterface | null $request = null,
ResponseInterface | null $response = null,
- ): OrderWorkflow {
- return new OrderWorkflow($this->em, $order, $request, $response);
+ ): OrderWorkflowInterface {
+ return new OrderWorkflow($this->em, $this->eventDispatcher, $order, $request, $response);
}
}
diff --git a/src/Order/Workflow/OrderWorkflowInterface.php b/src/Order/Workflow/OrderWorkflowInterface.php
new file mode 100644
index 0000000..01c9fef
--- /dev/null
+++ b/src/Order/Workflow/OrderWorkflowInterface.php
@@ -0,0 +1,12 @@
+
+ */
+class SubscriberRepository extends ServiceEntityRepository
+{
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, Subscriber::class);
+ }
+
+ public function hasSubscriber(string $hash): bool
+ {
+ $result = $this->createQueryBuilder('s')
+ ->where('s.hash = :hash')
+ ->setParameter('hash', $hash)
+ ->select('1')
+ ->getQuery()
+ ->getOneOrNullResult();
+
+ return (bool) $result;
+ }
+
+ /**
+ * @param OrderStatus|null $orderStatus Filter by order status.
+ * @return Subscriber[]
+ */
+ public function getList(?OrderStatus $orderStatus = null): array
+ {
+ $query = $this->createQueryBuilder('s');
+ if ($orderStatus) {
+ $query->where('s.orderStatus = :orderStatus');
+ $query->setParameter('orderStatus', $orderStatus);
+ }
+
+ return $query->getQuery()->getResult();
+ }
+
+ // /**
+ // * @return Subscriber[] Returns an array of Subscriber objects
+ // */
+ // public function findByExampleField($value): array
+ // {
+ // return $this->createQueryBuilder('s')
+ // ->andWhere('s.exampleField = :val')
+ // ->setParameter('val', $value)
+ // ->orderBy('s.id', 'ASC')
+ // ->setMaxResults(10)
+ // ->getQuery()
+ // ->getResult()
+ // ;
+ // }
+
+ // public function findOneBySomeField($value): ?Subscriber
+ // {
+ // return $this->createQueryBuilder('s')
+ // ->andWhere('s.exampleField = :val')
+ // ->setParameter('val', $value)
+ // ->getQuery()
+ // ->getOneOrNullResult()
+ // ;
+ // }
+}
diff --git a/src/Subscriber/Action/AddHttpSubscriberAction.php b/src/Subscriber/Action/AddHttpSubscriberAction.php
new file mode 100644
index 0000000..b32cba6
--- /dev/null
+++ b/src/Subscriber/Action/AddHttpSubscriberAction.php
@@ -0,0 +1,45 @@
+ $url,
+ 'method' => $httpMethod,
+ ];
+
+ return $this->addSubscriber(
+ orderStatus: $orderStatus,
+ channelType: 'http',
+ channelMessage: $channelMessage,
+ params: $params,
+ );
+ }
+}
diff --git a/src/Subscriber/Action/AddSubscriberAction.php b/src/Subscriber/Action/AddSubscriberAction.php
new file mode 100644
index 0000000..fd14bfa
--- /dev/null
+++ b/src/Subscriber/Action/AddSubscriberAction.php
@@ -0,0 +1,79 @@
+validateChannelType($channelType);
+ $this->validateChannelMessage($channelMessage);
+
+ $subscriber = new Subscriber();
+ $subscriber->setOrderStatus($orderStatus);
+ $subscriber->setChannelType($channelType);
+ $subscriber->setParams($params);
+ $subscriber->setChannelMessage($channelMessage);
+ $subscriber->setCreatedAtNow();
+ $subscriber->generateHash();
+
+ // Subscriber must have unique order status, notification type and parameters.
+ $exists = $this->subscriberRepository->hasSubscriber($subscriber->getHash());
+ if ($exists) {
+ throw new SubscriberExistsException();
+ }
+
+ $this->em->persist($subscriber);
+ $this->em->flush();
+
+ return $subscriber;
+ }
+
+ /**
+ * @throws NotificationChannelNotRegisteredException
+ */
+ protected function validateChannelType(string $channelType): void
+ {
+ if (! in_array($channelType, $this->notificationChannelCollection->getNotificationChannelTypes())) {
+ throw new NotificationChannelNotRegisteredException($channelType);
+ }
+ }
+
+ /**
+ * @throws ChannelMessageNotRegistered
+ */
+ protected function validateChannelMessage(string $channelMessage): void
+ {
+ if (! in_array($channelMessage, $this->notificationChannelCollection->getMessageTypes())) {
+ throw new ChannelMessageNotRegistered($channelMessage);
+ }
+ }
+}
diff --git a/src/Subscriber/Action/SendSubscriberNotificationAction.php b/src/Subscriber/Action/SendSubscriberNotificationAction.php
new file mode 100644
index 0000000..35990cd
--- /dev/null
+++ b/src/Subscriber/Action/SendSubscriberNotificationAction.php
@@ -0,0 +1,34 @@
+notificationChannelCollection->getNotificationChannel($subscriber->getChannelType());
+ $message = $this->notificationChannelCollection->getMessage($subscriber->getChannelMessage());
+
+ $message->setPaymentProcessing($paymentProcessing);
+
+ $channel->send($message, $subscriber->getParams());
+ }
+}
diff --git a/src/Subscriber/Channel/AbstractChannelMessage.php b/src/Subscriber/Channel/AbstractChannelMessage.php
new file mode 100644
index 0000000..564fa55
--- /dev/null
+++ b/src/Subscriber/Channel/AbstractChannelMessage.php
@@ -0,0 +1,24 @@
+paymentProcessing = $paymentProcessing;
+ }
+
+ public function getPaymentProcessing(): PaymentProcessing
+ {
+ return $this->paymentProcessing
+ ?? throw new ChannelMessageException('Payment processing response not set.');
+ }
+}
diff --git a/src/Subscriber/Channel/ChannelMessageInterface.php b/src/Subscriber/Channel/ChannelMessageInterface.php
new file mode 100644
index 0000000..e2705fc
--- /dev/null
+++ b/src/Subscriber/Channel/ChannelMessageInterface.php
@@ -0,0 +1,54 @@
+ $this->getPaymentProcessing()->getOrder()->getExternalOrderId(),
+ * ]);
+ * }
+ * }
+ *
+ * In `config/services.yml` the custom channel:
+ *
+ * services:
+ * App\Subscriber\Channel\JsonChannelMessage:
+ * tags:
+ * - { name: 'app.subscriber.message', type: 'json' }
+ *
+ * Then, the `json` custom message will be available in `subscriber:channels` command output.
+ */
+interface ChannelMessageInterface
+{
+ public function setPaymentProcessing(PaymentProcessing $paymentProcessing): void;
+
+ /**
+ * @throws ChannelMessageException When payment processing is not set.
+ */
+ public function getPaymentProcessing(): PaymentProcessing;
+
+ /**
+ * Returns a data that will be transmitted by a channel.
+ *
+ * @throws ChannelMessageException
+ */
+ public function getData(): mixed;
+}
diff --git a/src/Subscriber/Channel/HttpNotificationChannel.php b/src/Subscriber/Channel/HttpNotificationChannel.php
new file mode 100644
index 0000000..b8436d6
--- /dev/null
+++ b/src/Subscriber/Channel/HttpNotificationChannel.php
@@ -0,0 +1,53 @@
+getHttpMethod($params);
+
+ $this->httpClient->request($method, $url, [
+ 'json' => $message->getData(),
+ ]);
+ }
+
+ /**
+ * @param array{method: string} $params
+ * @throws NotificationChannelException
+ */
+ private function getHttpMethod(array $params): string
+ {
+ $method = (string) ($params['method'] ?? self::DEFAULT_HTTP_METHOD);
+
+ if (! in_array($method, self::HTTP_METHODS)) {
+ throw new NotificationChannelException("Http method \"$method\" is not allowed.");
+ }
+
+ return $method;
+ }
+}
diff --git a/src/Subscriber/Channel/NotificationChannelCollection.php b/src/Subscriber/Channel/NotificationChannelCollection.php
new file mode 100644
index 0000000..1de0ba6
--- /dev/null
+++ b/src/Subscriber/Channel/NotificationChannelCollection.php
@@ -0,0 +1,66 @@
+
+ */
+ private readonly array $channels;
+
+ private readonly array $messages;
+
+ public function __construct(
+ #[TaggedIterator('app.subscriber.channel', indexAttribute: 'type')]
+ iterable $channels,
+
+ #[TaggedIterator('app.subscriber.message', indexAttribute: 'type')]
+ iterable $messages,
+ ) {
+ $this->channels = iterator_to_array($channels);
+ $this->messages = iterator_to_array($messages);
+ }
+
+ /**
+ * @param non-empty-string $type A notification channel type.
+ * @throws NotificationChannelNotRegisteredException
+ */
+ public function getNotificationChannel(string $type): NotificationChannelInterface
+ {
+ return $this->channels[$type]
+ ?? throw new NotificationChannelNotRegisteredException($type);
+ }
+
+ /**
+ * @return string[]
+ */
+ public function getNotificationChannelTypes(): array
+ {
+ return array_keys($this->channels);
+ }
+
+ /**
+ * @param non-empty-string $type A channel message type.
+ * @throws ChannelMessageNotRegistered
+ */
+ public function getMessage(string $type): ChannelMessageInterface
+ {
+ return $this->messages[$type]
+ ?? throw new ChannelMessageNotRegistered($type);
+ }
+
+ /**
+ * @return string[]
+ */
+ public function getMessageTypes(): array
+ {
+ return array_keys($this->messages);
+ }
+}
diff --git a/src/Subscriber/Channel/NotificationChannelInterface.php b/src/Subscriber/Channel/NotificationChannelInterface.php
new file mode 100644
index 0000000..185ac10
--- /dev/null
+++ b/src/Subscriber/Channel/NotificationChannelInterface.php
@@ -0,0 +1,48 @@
+sms->send($message->getData(), $params['phone_number']);
+ * }
+ * }
+ *
+ * In `config/services.yml` the custom channel:
+ *
+ * services:
+ * App\Subscriber\Channel\MyCustomSmsChannel:
+ * tags:
+ * - { name: 'app.subscriber.channel', type: 'sms' }
+ *
+ * Then, the `sms` custom channel will be available in `subscriber:channels` command output under `channels`.
+ * The adding subscriber command should be implemented too, in order to add subscribers.
+ */
+interface NotificationChannelInterface
+{
+ /**
+ * @param array $params Channel parameters.
+ *
+ * @throws NotificationChannelException
+ * @throws ChannelMessageException
+ */
+ public function send(ChannelMessageInterface $message, array $params): void;
+}
diff --git a/src/Subscriber/Channel/SimpleArrayChannelMessage.php b/src/Subscriber/Channel/SimpleArrayChannelMessage.php
new file mode 100644
index 0000000..1601c4d
--- /dev/null
+++ b/src/Subscriber/Channel/SimpleArrayChannelMessage.php
@@ -0,0 +1,34 @@
+getPaymentProcessing();
+ $order = $paymentProcessing->getOrder();
+
+ return [
+ 'order_num' => $order->getExternalOrderId(),
+ 'order_status' => $order->getStatus()->value,
+ 'success' => $paymentProcessing->getResponseSuccess(),
+ // TODO: a column in payment_processing should be added.
+ //'transaction_id' => $this->getResponse()->getTransactionId(),
+ 'response' => $paymentProcessing->getResponseData(),
+ ];
+ }
+}
diff --git a/src/Subscriber/Exception/ChannelMessageException.php b/src/Subscriber/Exception/ChannelMessageException.php
new file mode 100644
index 0000000..e43f8b0
--- /dev/null
+++ b/src/Subscriber/Exception/ChannelMessageException.php
@@ -0,0 +1,11 @@
+channelMessageType}\" not registered.");
+ }
+}
diff --git a/src/Subscriber/Exception/NotificationChannelException.php b/src/Subscriber/Exception/NotificationChannelException.php
new file mode 100644
index 0000000..4f58466
--- /dev/null
+++ b/src/Subscriber/Exception/NotificationChannelException.php
@@ -0,0 +1,11 @@
+notificationChannelType\" is not registered.");
+ }
+}
diff --git a/src/Subscriber/Exception/SubscriberExistsException.php b/src/Subscriber/Exception/SubscriberExistsException.php
new file mode 100644
index 0000000..364d96c
--- /dev/null
+++ b/src/Subscriber/Exception/SubscriberExistsException.php
@@ -0,0 +1,12 @@
+application = new Application(self::$kernel);
+ }
+}
diff --git a/tests/Application/Command/PaymentGatewaysCommandTest.php b/tests/Application/Command/PaymentGatewaysCommandTest.php
index f555c62..5699841 100644
--- a/tests/Application/Command/PaymentGatewaysCommandTest.php
+++ b/tests/Application/Command/PaymentGatewaysCommandTest.php
@@ -10,20 +10,10 @@
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
-final class PaymentGatewaysCommandTest extends KernelTestCase
+final class PaymentGatewaysCommandTest extends AbstractCommandTest
{
use WithPaymentGateway;
- private Application $application;
-
- protected function setUp(): void
- {
- parent::setUp();
-
- self::bootKernel();
- $this->application = new Application(self::$kernel);
- }
-
public function testCommandExecuteIsSuccessful(): void
{
$registry = new PaymentGatewayRegistry([]);
diff --git a/tests/Application/Command/Subscribers/AddHttpCommandTest.php b/tests/Application/Command/Subscribers/AddHttpCommandTest.php
new file mode 100644
index 0000000..06aa56f
--- /dev/null
+++ b/tests/Application/Command/Subscribers/AddHttpCommandTest.php
@@ -0,0 +1,69 @@
+faker()->url();
+ $httpMethod = 'PUT';
+ $orderStatus = OrderStatus::PAYMENT_PENDING;
+
+ $channelCollection = $this->createMock(NotificationChannelCollection::class);
+ $channelCollection
+ ->expects($this->once())
+ ->method('getNotificationChannelTypes')
+ ->willReturn(['http']);
+ $channelCollection
+ ->expects($this->once())
+ ->method('getMessageTypes')
+ ->willReturn(['test']);
+
+ $this->getContainer()->set(NotificationChannelCollection::class, $channelCollection);
+
+ $command = $this->application->find('subscriber:add-http');
+ $commandTester = new CommandTester($command);
+ $commandTester->execute([
+ 'url' => $url,
+ '--channel-message' => 'test',
+ '--order-status' => $orderStatus->value,
+ '--http-method' => $httpMethod,
+ ]);
+
+ $commandTester->assertCommandIsSuccessful($commandTester->getDisplay());
+
+ $display = $commandTester->getDisplay();
+
+ $this->assertEquals(
+ 1,
+ preg_match('/Subscriber "(.+)" has been added/', $display, $matches),
+ message: 'Cannot get subscriber uuid from command output.'
+ );
+
+ $uuid = $matches[1];
+
+ $subscriberRepository = $this->getContainer()->get(SubscriberRepository::class);
+ $subscriber = $subscriberRepository->findOneBy(['uuid' => $uuid]);
+
+ $this->assertNotEmpty($subscriber, 'Subscriber not found');
+
+ $this->assertEquals($orderStatus, $subscriber->getOrderStatus());
+
+ $this->assertEquals([
+ 'url' => $url,
+ 'method' => $httpMethod,
+ ], $subscriber->getParams());
+ }
+}
diff --git a/tests/Application/Command/Subscribers/ChannelsCommandTest.php b/tests/Application/Command/Subscribers/ChannelsCommandTest.php
new file mode 100644
index 0000000..6d51616
--- /dev/null
+++ b/tests/Application/Command/Subscribers/ChannelsCommandTest.php
@@ -0,0 +1,36 @@
+createMock(NotificationChannelCollection::class);
+ $channelCollection
+ ->expects($this->once())
+ ->method('getNotificationChannelTypes')
+ ->willReturn(['test-channel']);
+ $channelCollection
+ ->expects($this->once())
+ ->method('getMessageTypes')
+ ->willReturn(['test-message']);
+
+ $this->getContainer()->set(NotificationChannelCollection::class, $channelCollection);
+
+ $command = $this->application->find('subscriber:channels');
+ $commandTester = new CommandTester($command);
+ $commandTester->execute([]);
+
+ $display = $commandTester->getDisplay();
+
+ $this->assertStringContainsString('test-channel', $display, 'Channel type is missing in command output.');
+ $this->assertStringContainsString('test-message', $display, 'Message type is missing in command output.');
+ }
+}
diff --git a/tests/Application/Command/Subscribers/RemoveCommandTest.php b/tests/Application/Command/Subscribers/RemoveCommandTest.php
new file mode 100644
index 0000000..a356c48
--- /dev/null
+++ b/tests/Application/Command/Subscribers/RemoveCommandTest.php
@@ -0,0 +1,38 @@
+getContainer()->get(SubscriberRepository::class);
+
+ $subscriber = $subscriberRepository->findOneBy([
+ 'orderStatus' => OrderStatus::PAYMENT_PENDING,
+ ]);
+
+ $command = $this->application->find('subscriber:remove');
+ $commandTester = new CommandTester($command);
+ $commandTester->execute([
+ 'uuid' => $subscriber->getUuid(),
+ ]);
+
+ $commandTester->assertCommandIsSuccessful();
+
+ $subscriber = $subscriberRepository->findOneBy(['uuid' => $subscriber->getUuid()]);
+
+ $this->assertEmpty($subscriber);
+ }
+}
diff --git a/tests/Application/Event/NotifySubscribersTest.php b/tests/Application/Event/NotifySubscribersTest.php
new file mode 100644
index 0000000..df2449c
--- /dev/null
+++ b/tests/Application/Event/NotifySubscribersTest.php
@@ -0,0 +1,81 @@
+eventDispatcher = new EventDispatcher();
+ $this->entityManager = $kernel->getContainer()->get('doctrine')->getManager();
+ }
+
+ protected function tearDown(): void
+ {
+ parent::tearDown();
+
+ $this->entityManager->close();
+ $this->entityManager = null;
+ }
+
+ public function testEventOrderStatusWasChangedSendsMessage(): void
+ {
+ $orderRepository = self::getContainer()->get(OrderRepository::class);
+ $listener = self::getContainer()->get(NotifySubscribersListener::class);
+
+ $this->eventDispatcher->addListener('onOrderStatusWasChanged', [$listener, 'onOrderStatusWasChanged']);
+
+ // check fixtures for existing order.
+ $order = $orderRepository->findOneBy([
+ 'externalOrderId' => '111-test',
+ ]);
+ // Because orders in fixtures are with "created" status,
+ // set manually one of the status which subscribers expecting (see subscriber fixtures).
+ $order->setStatus(OrderStatus::PAYMENT_RECEIVED);
+
+ $response = $this->createStub(ResponseInterface::class);
+ $response->method('isSuccessful')->willReturn(true);
+ $response->method('jsonSerialize')->willReturn([
+ 'message' => 'ok',
+ ]);
+
+ $paymentProcessing = PaymentProcessing::create($order, null, $response);
+ $this->entityManager->persist($paymentProcessing);
+ $this->entityManager->flush();
+
+ $event = new OrderStatusWasChanged(
+ previousStatus: OrderStatus::CREATED,
+ order: $order,
+ paymentProcessing: $paymentProcessing,
+ response: $response,
+ );
+
+ $this->eventDispatcher->dispatch($event, 'onOrderStatusWasChanged');
+
+ /** @var InMemoryTransport $transport */
+ $transport = $this->getContainer()->get('messenger.transport.async');
+
+ $this->assertCount(1, $transport->getSent());
+ }
+}
diff --git a/tests/Unit/Subscriber/AddHttpSubscriberActionTest.php b/tests/Unit/Subscriber/AddHttpSubscriberActionTest.php
new file mode 100644
index 0000000..2a4c95f
--- /dev/null
+++ b/tests/Unit/Subscriber/AddHttpSubscriberActionTest.php
@@ -0,0 +1,122 @@
+expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Invalid url');
+
+ $action = new AddHttpSubscriberAction(
+ $this->createStub(EntityManagerInterface::class),
+ $this->createStub(SubscriberRepository::class),
+ $this->createStub(NotificationChannelCollection::class),
+ );
+
+ $action->add(
+ orderStatus: OrderStatus::PAYMENT_FAILED,
+ url: '11111',
+ channelMessage: 'test',
+ );
+ }
+
+ public function testAddSubscriber(): void
+ {
+ $entityManager = $this->createMock(EntityManagerInterface::class);
+ $entityManager
+ ->expects($this->once())
+ ->method('persist');
+ $entityManager
+ ->expects($this->once())
+ ->method('flush');
+
+ $repository = $this->createMock(SubscriberRepository::class);
+ $repository
+ ->expects($this->once())
+ ->method('hasSubscriber')
+ ->willReturn(false);
+
+ $channelCollection = $this->createMock(NotificationChannelCollection::class);
+ $channelCollection
+ ->expects($this->once())
+ ->method('getNotificationChannelTypes')
+ ->willReturn(['http']);
+ $channelCollection
+ ->expects($this->once())
+ ->method('getMessageTypes')
+ ->willReturn(['test']);
+
+ $action = new AddHttpSubscriberAction($entityManager, $repository, $channelCollection);
+
+ $action->add(OrderStatus::PAYMENT_RECEIVED, $this->faker()->url(), 'test');
+ }
+
+ public function testCannotAddSubscriberWithSameParameters(): void
+ {
+ $entityManager = $this->createMock(EntityManagerInterface::class);
+ $entityManager
+ ->expects($this->never())
+ ->method('persist');
+ $entityManager
+ ->expects($this->never())
+ ->method('flush');
+
+ $repository = $this->createMock(SubscriberRepository::class);
+ $repository
+ ->expects($this->once())
+ ->method('hasSubscriber')
+ ->willReturn(true);
+
+ $channelCollection = $this->createMock(NotificationChannelCollection::class);
+ $channelCollection
+ ->expects($this->once())
+ ->method('getNotificationChannelTypes')
+ ->willReturn(['http']);
+ $channelCollection
+ ->expects($this->once())
+ ->method('getMessageTypes')
+ ->willReturn(['test1']);
+
+ $action = new AddHttpSubscriberAction($entityManager, $repository, $channelCollection);
+
+ $this->expectException(SubscriberExistsException::class);
+ $this->expectExceptionMessage('Subscriber with such parameters already exists');
+
+ $action->add(OrderStatus::PAYMENT_RECEIVED, $this->faker()->url(), 'test1');
+ }
+
+ public function testHttpMethodMustBeAllowed(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Invalid http method "TEST", must be one of [POST,PUT,PATCH]');
+
+ $action = new AddHttpSubscriberAction(
+ $this->createStub(EntityManagerInterface::class),
+ $this->createStub(SubscriberRepository::class),
+ $this->createStub(NotificationChannelCollection::class),
+ );
+
+ $action->add(
+ orderStatus: OrderStatus::PAYMENT_FAILED,
+ url: $this->faker()->url(),
+ channelMessage: 'foobar',
+ httpMethod: 'TEST',
+ );
+ }
+}
diff --git a/tests/Unit/Subscriber/NotificationChannelCollectionTest.php b/tests/Unit/Subscriber/NotificationChannelCollectionTest.php
new file mode 100644
index 0000000..a7d7026
--- /dev/null
+++ b/tests/Unit/Subscriber/NotificationChannelCollectionTest.php
@@ -0,0 +1,73 @@
+createStub(NotificationChannelInterface::class);
+ $type2 = $this->createStub(NotificationChannelInterface::class);
+
+ $collection = new NotificationChannelCollection(
+ channels: [
+ 'a' => $type1,
+ 'b' => $type2,
+ ],
+ messages: [],
+ );
+
+ $this->assertEquals(['a', 'b'], $collection->getNotificationChannelTypes());
+ }
+
+ public function testAvailableMessageTypes(): void
+ {
+ $type1 = $this->createStub(ChannelMessageInterface::class);
+ $type2 = $this->createStub(ChannelMessageInterface::class);
+
+ $collection = new NotificationChannelCollection(
+ channels: [],
+ messages: [
+ 'x' => $type1,
+ 'y' => $type2,
+ ],
+ );
+
+ $this->assertEquals(['x', 'y'], $collection->getMessageTypes());
+ }
+
+ public function testThrowsChannelNotRegisteredException(): void
+ {
+ $collection = new NotificationChannelCollection(
+ channels: [],
+ messages: [],
+ );
+
+ $this->expectException(NotificationChannelNotRegisteredException::class);
+ $this->expectExceptionMessage('Subscriber notification channel "aaa" is not registered');
+
+ $collection->getNotificationChannel('aaa');
+ }
+
+ public function testThrowsMessageNotRegisteredException(): void
+ {
+ $collection = new NotificationChannelCollection(
+ channels: [],
+ messages: [],
+ );
+
+ $this->expectException(ChannelMessageNotRegistered::class);
+ $this->expectExceptionMessage('Channel message type "msg" not registered');
+
+ $collection->getMessage('msg');
+ }
+}
diff --git a/tests/Unit/Subscriber/SendSubscriberNotificationActionTest.php b/tests/Unit/Subscriber/SendSubscriberNotificationActionTest.php
new file mode 100644
index 0000000..3d6913e
--- /dev/null
+++ b/tests/Unit/Subscriber/SendSubscriberNotificationActionTest.php
@@ -0,0 +1,61 @@
+ 1,
+ 'b' => 2,
+ ];
+
+ $subscriber = new Subscriber();
+ $subscriber->setChannelType('in-test');
+ $subscriber->setChannelMessage('test-message');
+ $subscriber->setParams($params);
+
+ $message = $this->createMock(ChannelMessageInterface::class);
+ $message
+ ->expects($this->once())
+ ->method('setPaymentProcessing');
+
+ $channel = $this->createMock(NotificationChannelInterface::class);
+ $channel
+ ->expects($this->once())
+ ->method('send')
+ ->with($message, $params);
+
+ $channelCollection = $this->createMock(NotificationChannelCollection::class);
+ $channelCollection
+ ->expects($this->once())
+ ->method('getNotificationChannel')
+ ->with('in-test')
+ ->willReturn($channel);
+ $channelCollection
+ ->expects($this->once())
+ ->method('getMessage')
+ ->with('test-message')
+ ->willReturn($message);
+
+ $order = new Order();
+ $paymentProcessing = new PaymentProcessing();
+ $response = $this->createStub(ResponseInterface::class);
+
+ $action = new SendSubscriberNotificationAction($channelCollection);
+ $action->sendNotification($subscriber, $paymentProcessing);
+ }
+}
diff --git a/tests/Unit/Subscriber/SimpleArrayChannelMessageTest.php b/tests/Unit/Subscriber/SimpleArrayChannelMessageTest.php
new file mode 100644
index 0000000..27851eb
--- /dev/null
+++ b/tests/Unit/Subscriber/SimpleArrayChannelMessageTest.php
@@ -0,0 +1,50 @@
+setExternalOrderId($this->faker()->md5());
+ $order->setStatus(OrderStatus::PAYMENT_FAILED);
+
+ $response = $this->createStub(ResponseInterface::class);
+ $response->method('isSuccessful')->willReturn(false);
+ $response->method('jsonSerialize')->willReturn([
+ 'a' => 1,
+ 'b' => 2,
+ ]);
+
+ $paymentProcessing = PaymentProcessing::create($order, null, $response);
+
+ $message->setPaymentProcessing($paymentProcessing);
+
+ $data = $message->getData();
+
+ $this->assertEquals([
+ 'order_num' => $order->getExternalOrderId(),
+ 'order_status' => 'payment_failed',
+ 'success' => false,
+ 'response' => [
+ 'a' => 1,
+ 'b' => 2,
+ ],
+ ], $data);
+ }
+}