From 8163f7458d872b9f0181b98dd055d1538ab9fa8d Mon Sep 17 00:00:00 2001 From: Aleksander Kowalski Date: Fri, 3 Nov 2023 12:35:28 +0100 Subject: [PATCH] #6 - add rabbitmq and examples (#16) --- .env.test | 8 +- .github/workflows/php.yml | 2 +- .gitignore | 4 +- Makefile | 4 +- README.md | 98 +- bin/console.php | 1 - composer.json | 7 +- composer.lock | 993 +++++++++++------- config/cli-config.php | 3 +- config/compiler-passes.php | 4 + config/container.php | 16 + config/routes.php | 5 + config/settings.php | 7 + deptrac.yaml | 4 + docker-compose.yml | 6 +- phpstan.neon | 1 - resources/docs/openapi.yaml | 42 + .../Actions/User/AddUserAction.php | 67 ++ .../Actions/User/GetAllUsersAction.php | 2 +- .../Actions/User/GetUserByIdAction.php | 2 +- .../Console/AmqpConsumeConsoleCommand.php | 56 + src/Application/Factory/UserFactory.php | 19 + .../Factory/ValidationErrorFactory.php | 24 + src/Application/Handlers/HttpErrorHandler.php | 4 + src/Application/Validator/UserValidator.php | 24 + src/Application/Validator/ValidationError.php | 22 + .../Validator/ValidationMiddleware.php | 55 + .../DomainException/ValidationException.php | 27 + src/Domain/Entity/User/User.php | 11 + .../Entity/User/UserRepositoryInterface.php | 2 + .../User/DomainEvents/UserWasCreated.php | 20 + .../UserWasCreatedEventHandler.php | 25 + src/Domain/Service/User/UserEventsService.php | 21 + src/Domain/Service/{ => User}/UserService.php | 9 +- .../AMQP/AMQPChannelFactory.php | 41 + .../AMQP/AMQPChannelOptions.php | 62 ++ .../AMQP/AMQPStreamConnectionFactory.php | 40 + src/Infrastructure/AMQP/Consumer.php | 87 ++ src/Infrastructure/AMQP/Envelope.php | 9 + src/Infrastructure/AMQP/Queue/AmqpQueue.php | 76 ++ .../AMQP/Queue/DelayedQueue/DelayedQueue.php | 55 + .../DelayedQueue/DelayedQueueFactory.php | 24 + .../AMQP/Queue/FailedQueue/FailedQueue.php | 35 + .../Queue/FailedQueue/FailedQueueFactory.php | 23 + src/Infrastructure/AMQP/Queue/Queue.php | 24 + .../AMQP/Queue/QueueCompilerPass.php | 25 + .../AMQP/Queue/QueueContainer.php | 35 + src/Infrastructure/AMQP/Worker/BaseWorker.php | 48 + src/Infrastructure/AMQP/Worker/Worker.php | 30 + .../WorkerMaxLifeTimeOrIterationsExceeded.php | 11 + src/Infrastructure/Attribute/AsAmqpQueue.php | 26 + .../Attribute/AsEventHandler.php | 13 + .../Attribute/ClassAttributeCache.php | 12 +- .../Attribute/ClassAttributeResolver.php | 3 +- .../Console/ConsoleCommandContainer.php | 5 +- .../DependencyInjection/ContainerBuilder.php | 3 +- src/Infrastructure/Events/DomainEvent.php | 47 + src/Infrastructure/Events/EventBus.php | 66 ++ .../CanNotRegisterEventHandler.php | 11 + .../Events/EventHandler/EventHandler.php | 12 + .../EventHandler/EventHandlerCompilerPass.php | 26 + src/Infrastructure/Events/EventQueue.php | 48 + .../Events/EventQueueWorker.php | 48 + .../Persistence/Queues/UserEventQueue.php | 13 + src/Infrastructure/Serialization/Json.php | 6 + tests/Application/Actions/ActionTest.php | 49 + .../Actions/Docs/SwaggerUiActionTest.php | 2 +- .../Console/AmqpConsumeConsoleCommandTest.php | 84 ++ .../Console/CacheClearConsoleCommandTest.php | 75 ++ tests/Application/Factory/UserFactoryTest.php | 32 + .../Validator/UserValidatorTest.php | 69 ++ tests/ConsoleCommandTestCase.php | 63 ++ tests/ContainerTestCase.php | 34 + tests/Domain/Service/UserServiceTest.php | 27 +- tests/EventHandlerTestCase.php | 23 + .../AMQP/AMQPChannelFactoryTest.php | 107 ++ tests/Infrastructure/AMQP/ConsumerTest.php | 254 +++++ .../DelayedQueue/DelayedQueueFactoryTest.php | 43 + .../Queue/DelayedQueue/DelayedQueueTest.php | 88 ++ .../FailedQueue/FailedQueueFactoryTest.php | 40 + .../Queue/FailedQueue/FailedQueueTest.php | 44 + .../AMQP/Queue/QueueContainerTest.php | 54 + tests/Infrastructure/AMQP/Queue/QueueTest.php | 136 +++ tests/Infrastructure/AMQP/Queue/TestQueue.php | 21 + .../AMQP/Queue/TestQueueWithoutAttribute.php | 19 + ...inerTest__testItRegistersAllQueues__1.json | 7 + .../AMQP/RunUnitTester/RunUnitTester.php | 11 + .../RunUnitTesterEventHandler.php | 16 + .../Infrastructure/AMQP/Worker/TestWorker.php | 35 + .../Infrastructure/AMQP/Worker/WorkerTest.php | 42 + .../Attribute/ClassAttributeCacheTest.php | 61 ++ .../Attribute/ClassAttributeResolverTest.php | 63 ++ ...assAttributeCacheTest__testCompile__1.json | 4 + ...ResolverTest__testResolveWithCache__1.json | 5 + ...AttributeResolverTest__testResolve__1.json | 5 + .../ConsoleCommandCompilerPassTest.php | 47 + .../Console/ConsoleCommandContainerTest.php | 49 + .../Console/TestConsoleCommand.php | 13 + .../Console/TestConsoleCommandNoName.php | 13 + ...nerTest__testItRegistersAllCommand__1.json | 5 + .../ContainerBuilderTest.php | 130 +++ .../Environment/SettingsTest.php | 29 + .../Infrastructure/Events/DomainEventTest.php | 24 + tests/Infrastructure/Events/EventBusTest.php | 73 ++ .../CommandHandlerCompilerPassTest.php | 47 + .../Infrastructure/Events/EventQueueTest.php | 115 ++ .../Events/EventQueueWorkerTest.php | 82 ++ .../InvalidTestEvent/InvalidTestCommand.php | 11 + .../InvalidTestEventEventHandler.php | 16 + tests/Infrastructure/Events/TestEvent.php | 11 + .../Infrastructure/Events/TestEventQueue.php | 13 + .../Events/TestInvalidEventHandlerName.php | 16 + .../TestNoCorrespondingEventEventHandler.php | 16 + ...usTest__testItRegistersAllCommands__1.json | 1 + .../DomainCommandTest__testMetaData__1.json | 8 + .../DomainCommandTest__testMetaData__2.json | 9 + .../DomainEventTest__testMetaData__1.json | 8 + .../DomainEventTest__testMetaData__2.json | 9 + ...ntBusTest__testItRegistersAllEvent__1.json | 3 + .../Infrastructure/Serialization/JsonTest.php | 35 + .../JsonTest__testEncodeDecode__1.json | 8 + tests/PausedClock.php | 25 + 122 files changed, 4476 insertions(+), 412 deletions(-) create mode 100644 src/Application/Actions/User/AddUserAction.php create mode 100644 src/Application/Console/AmqpConsumeConsoleCommand.php create mode 100644 src/Application/Factory/UserFactory.php create mode 100644 src/Application/Factory/ValidationErrorFactory.php create mode 100644 src/Application/Validator/UserValidator.php create mode 100644 src/Application/Validator/ValidationError.php create mode 100644 src/Application/Validator/ValidationMiddleware.php create mode 100644 src/Domain/DomainException/ValidationException.php create mode 100644 src/Domain/Service/User/DomainEvents/UserWasCreated.php create mode 100644 src/Domain/Service/User/DomainEvents/UserWasCreatedEventHandler.php create mode 100644 src/Domain/Service/User/UserEventsService.php rename src/Domain/Service/{ => User}/UserService.php (73%) create mode 100644 src/Infrastructure/AMQP/AMQPChannelFactory.php create mode 100644 src/Infrastructure/AMQP/AMQPChannelOptions.php create mode 100644 src/Infrastructure/AMQP/AMQPStreamConnectionFactory.php create mode 100644 src/Infrastructure/AMQP/Consumer.php create mode 100644 src/Infrastructure/AMQP/Envelope.php create mode 100644 src/Infrastructure/AMQP/Queue/AmqpQueue.php create mode 100644 src/Infrastructure/AMQP/Queue/DelayedQueue/DelayedQueue.php create mode 100644 src/Infrastructure/AMQP/Queue/DelayedQueue/DelayedQueueFactory.php create mode 100644 src/Infrastructure/AMQP/Queue/FailedQueue/FailedQueue.php create mode 100644 src/Infrastructure/AMQP/Queue/FailedQueue/FailedQueueFactory.php create mode 100644 src/Infrastructure/AMQP/Queue/Queue.php create mode 100644 src/Infrastructure/AMQP/Queue/QueueCompilerPass.php create mode 100644 src/Infrastructure/AMQP/Queue/QueueContainer.php create mode 100644 src/Infrastructure/AMQP/Worker/BaseWorker.php create mode 100644 src/Infrastructure/AMQP/Worker/Worker.php create mode 100644 src/Infrastructure/AMQP/Worker/WorkerMaxLifeTimeOrIterationsExceeded.php create mode 100644 src/Infrastructure/Attribute/AsAmqpQueue.php create mode 100644 src/Infrastructure/Attribute/AsEventHandler.php create mode 100644 src/Infrastructure/Events/DomainEvent.php create mode 100644 src/Infrastructure/Events/EventBus.php create mode 100644 src/Infrastructure/Events/EventHandler/CanNotRegisterEventHandler.php create mode 100644 src/Infrastructure/Events/EventHandler/EventHandler.php create mode 100644 src/Infrastructure/Events/EventHandler/EventHandlerCompilerPass.php create mode 100644 src/Infrastructure/Events/EventQueue.php create mode 100644 src/Infrastructure/Events/EventQueueWorker.php create mode 100644 src/Infrastructure/Persistence/Queues/UserEventQueue.php create mode 100644 tests/Application/Actions/ActionTest.php create mode 100644 tests/Application/Console/AmqpConsumeConsoleCommandTest.php create mode 100644 tests/Application/Console/CacheClearConsoleCommandTest.php create mode 100644 tests/Application/Factory/UserFactoryTest.php create mode 100644 tests/Application/Validator/UserValidatorTest.php create mode 100644 tests/ConsoleCommandTestCase.php create mode 100644 tests/ContainerTestCase.php create mode 100644 tests/EventHandlerTestCase.php create mode 100644 tests/Infrastructure/AMQP/AMQPChannelFactoryTest.php create mode 100644 tests/Infrastructure/AMQP/ConsumerTest.php create mode 100644 tests/Infrastructure/AMQP/Queue/DelayedQueue/DelayedQueueFactoryTest.php create mode 100644 tests/Infrastructure/AMQP/Queue/DelayedQueue/DelayedQueueTest.php create mode 100644 tests/Infrastructure/AMQP/Queue/FailedQueue/FailedQueueFactoryTest.php create mode 100644 tests/Infrastructure/AMQP/Queue/FailedQueue/FailedQueueTest.php create mode 100644 tests/Infrastructure/AMQP/Queue/QueueContainerTest.php create mode 100644 tests/Infrastructure/AMQP/Queue/QueueTest.php create mode 100644 tests/Infrastructure/AMQP/Queue/TestQueue.php create mode 100644 tests/Infrastructure/AMQP/Queue/TestQueueWithoutAttribute.php create mode 100644 tests/Infrastructure/AMQP/Queue/__snapshots__/QueueContainerTest__testItRegistersAllQueues__1.json create mode 100644 tests/Infrastructure/AMQP/RunUnitTester/RunUnitTester.php create mode 100644 tests/Infrastructure/AMQP/RunUnitTester/RunUnitTesterEventHandler.php create mode 100644 tests/Infrastructure/AMQP/Worker/TestWorker.php create mode 100644 tests/Infrastructure/AMQP/Worker/WorkerTest.php create mode 100644 tests/Infrastructure/Attribute/ClassAttributeCacheTest.php create mode 100644 tests/Infrastructure/Attribute/ClassAttributeResolverTest.php create mode 100644 tests/Infrastructure/Attribute/__snapshots__/ClassAttributeCacheTest__testCompile__1.json create mode 100644 tests/Infrastructure/Attribute/__snapshots__/ClassAttributeResolverTest__testResolveWithCache__1.json create mode 100644 tests/Infrastructure/Attribute/__snapshots__/ClassAttributeResolverTest__testResolve__1.json create mode 100644 tests/Infrastructure/Console/ConsoleCommandCompilerPassTest.php create mode 100644 tests/Infrastructure/Console/ConsoleCommandContainerTest.php create mode 100644 tests/Infrastructure/Console/TestConsoleCommand.php create mode 100644 tests/Infrastructure/Console/TestConsoleCommandNoName.php create mode 100644 tests/Infrastructure/Console/__snapshots__/ConsoleCommandContainerTest__testItRegistersAllCommand__1.json create mode 100644 tests/Infrastructure/DependencyInjection/ContainerBuilderTest.php create mode 100644 tests/Infrastructure/Environment/SettingsTest.php create mode 100644 tests/Infrastructure/Events/DomainEventTest.php create mode 100644 tests/Infrastructure/Events/EventBusTest.php create mode 100644 tests/Infrastructure/Events/EventHandler/CommandHandlerCompilerPassTest.php create mode 100644 tests/Infrastructure/Events/EventQueueTest.php create mode 100644 tests/Infrastructure/Events/EventQueueWorkerTest.php create mode 100644 tests/Infrastructure/Events/InvalidTestEvent/InvalidTestCommand.php create mode 100644 tests/Infrastructure/Events/InvalidTestEvent/InvalidTestEventEventHandler.php create mode 100644 tests/Infrastructure/Events/TestEvent.php create mode 100644 tests/Infrastructure/Events/TestEventQueue.php create mode 100644 tests/Infrastructure/Events/TestInvalidEventHandlerName.php create mode 100644 tests/Infrastructure/Events/TestNoCorrespondingEventEventHandler.php create mode 100644 tests/Infrastructure/Events/__snapshots__/CommandBusTest__testItRegistersAllCommands__1.json create mode 100644 tests/Infrastructure/Events/__snapshots__/DomainCommandTest__testMetaData__1.json create mode 100644 tests/Infrastructure/Events/__snapshots__/DomainCommandTest__testMetaData__2.json create mode 100644 tests/Infrastructure/Events/__snapshots__/DomainEventTest__testMetaData__1.json create mode 100644 tests/Infrastructure/Events/__snapshots__/DomainEventTest__testMetaData__2.json create mode 100644 tests/Infrastructure/Events/__snapshots__/EventBusTest__testItRegistersAllEvent__1.json create mode 100644 tests/Infrastructure/Serialization/JsonTest.php create mode 100644 tests/Infrastructure/Serialization/__snapshots__/JsonTest__testEncodeDecode__1.json create mode 100644 tests/PausedClock.php diff --git a/.env.test b/.env.test index 1f53cb5..b59e748 100644 --- a/.env.test +++ b/.env.test @@ -8,4 +8,10 @@ DB_HOST=localhost DB_PORT=5432 DB_NAME=slim DB_USER=slim -DB_PASSWORD=password \ No newline at end of file +DB_PASSWORD=password + +RABBITMQ_HOST=rabbitmq +RABBITMQ_PORT=5672 +RABBITMQ_USER=rabbitmq +RABBITMQ_PASS=rabbitmq +RABBITMQ_VHOST=app \ No newline at end of file diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 49cd893..955b65b 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -57,7 +57,7 @@ jobs: run: | cp .env.test .env composer migrate - vendor/bin/phpunit --testsuite unit --fail-on-incomplete --log-junit junit.xml --coverage-clover clover.xml + vendor/bin/phpunit --testsuite unit --log-junit junit.xml --coverage-clover clover.xml - name: Send test coverage to codecov.io uses: codecov/codecov-action@v3 diff --git a/.gitignore b/.gitignore index 4623c6a..06c12f9 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,6 @@ /logs/ .deptrac.cache junit.xml -clover.xml \ No newline at end of file +clover.xml +tests/*.txt +/test/*/*Success__1.json diff --git a/Makefile b/Makefile index d8e3e6f..964e029 100644 --- a/Makefile +++ b/Makefile @@ -41,8 +41,8 @@ kill-all: ## Kill all running containers openapi: ## Generate documentation for api docker-compose exec php vendor/bin/openapi /var/www/src --output resources/docs/openapi.yaml -db-create: ## Create db from doctrine schema - docker-compose exec php php vendor/bin/doctrine orm:schema-tool:create +db-create: ## Create db from migrations + $(MAKE) migrate migrate: ## Run migrations docker-compose exec php php vendor/bin/doctrine-migrations migrate diff --git a/README.md b/README.md index 67502c7..9bf60ab 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Codecov.io](https://codecov.io/gh/MrHDOLEK/slim4-boirlerplate/graph/badge.svg?token=KKBMW5HJVM)](https://codecov.io/gh/MrHDOLEK/slim4-boirlerplate) [![License](https://img.shields.io/github/license/robiningelbrecht/slim-skeleton-ddd-amqp?color=428f7e&logo=open%20source%20initiative&logoColor=white)](https://github.com/MrHDOLEK/slim4-boirlerplate/blob/master/LICENSE) [![PHPStan Enabled](https://img.shields.io/badge/PHPStan-level%205-succes.svg?logo=php&logoColor=white&color=31C652)](https://phpstan.org/) -[![PHP](https://img.shields.io/packagist/php-v/robiningelbrecht/php-slim-skeleton/dev-master?color=%23777bb3&logo=php&logoColor=white)](https://php.net/) +[![PHP](https://img.shields.io/packagist/php-v/mrhdolek/slim4-boirlerplate/dev-main?color=%23777bb3&logo=php&logoColor=white)](https://php.net/) --- @@ -35,6 +35,8 @@ Please install packages makefile for [Windows](http://gnuwin32.sourceforge.net/p - `http://localhost` ## Documentation for a Rest Api - `http://localhost/docs/v1` +## RabbitMq dashboard +- `http://localhost:15672` ## All commands - `make help` @@ -59,12 +61,15 @@ Please install packages makefile for [Windows](http://gnuwin32.sourceforge.net/p - Console <- Related to loading console commands into the application's dependency container. - DependencyInjection <- Manages the dependencies and their relationships in the application. It ensures that objects get the right services they depend upon. - Environment <- Might handle environment-specific configurations or utilities. + - Events <- Here are the things that make it possible to throw and handle events + - AMQP <- Here you will find the implementation of the protocol together with its full support - Persistence <- Deals with data storage and retrieval. - Doctrine - Fixtures <- Contains sample data that can be loaded into the database for testing or initial setup. - Mapping <- Manages how objects are mapped to database tables. - Migrations <- Helps in versioning and migrating database schemas. - Repository <- Repositories are used to retrieve and store data in the database. + - Queues <- Here are all registered queues with configuration - Serialization @@ -106,6 +111,7 @@ return function (App $app) { The console application uses the Symfony console component to leverage CLI functionality. + ```php #[AsCommand(name: 'app:user:create')] class CreateUserConsoleCommand extends Command @@ -117,6 +123,96 @@ class CreateUserConsoleCommand extends Command } } ``` + +### Domain event and event handlers + +The framework implements the amqp protocol with handlers that allow events to be easily pushed onto the queue. +Each event must have a handler implemented that consumes the event. + +#### Creating a new event + +```php +class UserWasCreated extends DomainEvent +{ + +} +``` + +#### Creating the corresponding event handler + +```php +namespace App\Domain\Entity\User\DomainEvents; + +#[AsEventHandler] +class UserWasCreatedEventHandler implements EventHandler +{ + public function __construct( + ) { + } + + public function handle(DomainEvent $event): void + { + assert($event instanceof UserWasCreated); + + // Do stuff. + } +} +``` + +### Eventing + +#### Create a new event + +```php +class UserWasCreated extends DomainEvent +{ + public function __construct( + private UserId $userId, + ) { + } + + public function getUserId(): UserId + { + return $this->userId; + } +} +``` + +### Async processing of commands with RabbitMQ + +The chosen AMQP implementation for this project is RabbitMQ, but it can be easily switched to for example Amazon's AMQP solution. + +#### Registering new queues + +```php +#[AsAmqpQueue(name: "user-command-queue", numberOfWorkers: 1)] +class UserEventQueue extends EventQueue +{ +} +``` + +#### Queueing events + +```php +final readonly class UserEventsService +{ + public function __construct( + private UserEventQueue $userEventQueue, + ) {} + + public function userWasCreated(User $user): void + { + $this->userEventQueue->queue(new UserWasCreated($user)); + } +} +``` + +#### Consuming your queue + +```bash +> docker-compose run --rm php bin/console.php app:amqp:consume user-command-queue +``` + ### Create new entity If you have created a new entity and want to map it to a database you must create a xml in src/Infrastructure/Persistence/Doctrine/Mapping . diff --git a/bin/console.php b/bin/console.php index 59a6200..5bfc13d 100644 --- a/bin/console.php +++ b/bin/console.php @@ -14,7 +14,6 @@ $_ENV['APP_ENV'] = $env; } -/** @var ContainerInterface $container */ $container = ContainerFactory::create(); try { diff --git a/composer.json b/composer.json index ac45652..cbb2fc0 100644 --- a/composer.json +++ b/composer.json @@ -24,14 +24,14 @@ "ext-json": "*", "ext-pcntl": "*", "ext-sockets": "*", - "awurth/slim-validation": "^5.0", + "awurth/slim-validation": "^3.3", "doctrine/data-fixtures": "^1.6", "doctrine/migrations": "^3.6", "doctrine/orm": "^2.15", "fakerphp/faker": "^1.23", "lcobucci/clock": "^3.1", "monolog/monolog": "^2.8", - "php-amqplib/php-amqplib": "^2.8", + "php-amqplib/php-amqplib": "^3.2", "php-di/php-di": "^7.0", "php-di/slim-bridge": "^3.4", "ramsey/uuid": "^4.7", @@ -53,7 +53,8 @@ "phpstan/extension-installer": "^1.2.0", "phpstan/phpstan": "^1.8", "phpunit/phpunit": "^9.5.26", - "qossmic/deptrac-shim": "^1.0" + "qossmic/deptrac-shim": "^1.0", + "spatie/phpunit-snapshot-assertions": "^4.2" }, "config": { "allow-plugins": { diff --git a/composer.lock b/composer.lock index 7866a29..232d1bf 100644 --- a/composer.lock +++ b/composer.lock @@ -4,39 +4,40 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "023de57c226d67eeb4d486350bf07994", + "content-hash": "fc5a8436b1b54db2b781db65f3bb50c3", "packages": [ { "name": "awurth/slim-validation", - "version": "v5.0.0", + "version": "v3.4.0", "source": { "type": "git", "url": "https://github.com/awurth/SlimValidation.git", - "reference": "d1d2de4e8dbf3cd967f8644ce60206dbb1ef277e" + "reference": "c6fe3414d6c43513c06ecce0f6353087cf7a962f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/awurth/SlimValidation/zipball/d1d2de4e8dbf3cd967f8644ce60206dbb1ef277e", - "reference": "d1d2de4e8dbf3cd967f8644ce60206dbb1ef277e", + "url": "https://api.github.com/repos/awurth/SlimValidation/zipball/c6fe3414d6c43513c06ecce0f6353087cf7a962f", + "reference": "c6fe3414d6c43513c06ecce0f6353087cf7a962f", "shasum": "" }, "require": { - "php": ">=8.1", - "respect/validation": "^2.0", - "symfony/options-resolver": "^6.0", - "symfony/property-access": "^6.0" + "php": ">=7.1", + "psr/http-message": "^1.0", + "respect/validation": "^1.1" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.11", - "phpunit/phpunit": "^9.5", - "psr/http-message": "^1.0", - "slim/psr7": "^1.5", - "twig/twig": "^3.5" + "phpunit/phpunit": ">=7.0", + "slim/slim": "^3.8", + "slim/twig-view": "^2.2" + }, + "suggest": { + "slim/twig-view": "Needed to be able to use the Twig Extension with Slim", + "twig/twig": "Needed to be able to use the Twig Extension" }, "type": "library", "autoload": { "psr-4": { - "Awurth\\Validator\\": "src" + "Awurth\\SlimValidation\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -50,20 +51,19 @@ "homepage": "https://github.com/awurth" } ], - "description": "A wrapper around the respect/validation PHP validation library for easier error handling and display", + "description": "A validator for the Slim PHP Micro-Framework", "homepage": "https://github.com/awurth/SlimValidation", "keywords": [ "respect", "slim", - "validate", "validation", "validator" ], "support": { "issues": "https://github.com/awurth/SlimValidation/issues", - "source": "https://github.com/awurth/SlimValidation/tree/v5.0.0" + "source": "https://github.com/awurth/SlimValidation/tree/v3.4.0" }, - "time": "2023-04-10T14:27:34+00:00" + "time": "2021-09-17T14:31:12+00:00" }, { "name": "brick/math", @@ -1801,25 +1801,142 @@ }, "time": "2018-02-13T20:26:39+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v2.6.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "58c3f47f650c94ec05a151692652a868995d2938" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/58c3f47f650c94ec05a151692652a868995d2938", + "reference": "58c3f47f650c94ec05a151692652a868995d2938", + "shasum": "" + }, + "require": { + "php": "^7|^8" + }, + "require-dev": { + "phpunit/phpunit": "^6|^7|^8|^9", + "vimeo/psalm": "^1|^2|^3|^4" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2022-06-14T06:56:20+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, { "name": "php-amqplib/php-amqplib", - "version": "v2.12.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/php-amqplib/php-amqplib.git", - "reference": "0eaaa9d5d45335f4342f69603288883388c2fe21" + "reference": "fb84e99589de0904a25861451b0552f806284ee5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-amqplib/php-amqplib/zipball/0eaaa9d5d45335f4342f69603288883388c2fe21", - "reference": "0eaaa9d5d45335f4342f69603288883388c2fe21", + "url": "https://api.github.com/repos/php-amqplib/php-amqplib/zipball/fb84e99589de0904a25861451b0552f806284ee5", + "reference": "fb84e99589de0904a25861451b0552f806284ee5", "shasum": "" }, "require": { "ext-mbstring": "*", "ext-sockets": "*", - "php": ">=5.6.3", - "phpseclib/phpseclib": "^2.0.0" + "php": "^7.2||^8.0", + "phpseclib/phpseclib": "^2.0|^3.0" }, "conflict": { "php": "7.4.0 - 7.4.1" @@ -1830,13 +1947,13 @@ "require-dev": { "ext-curl": "*", "nategood/httpful": "^0.2.20", - "phpunit/phpunit": "^5.7|^6.5|^7.0", - "squizlabs/php_codesniffer": "^2.5" + "phpunit/phpunit": "^7.5|^9.5", + "squizlabs/php_codesniffer": "^3.6" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.12-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -1878,9 +1995,9 @@ ], "support": { "issues": "https://github.com/php-amqplib/php-amqplib/issues", - "source": "https://github.com/php-amqplib/php-amqplib/tree/v2.12.1" + "source": "https://github.com/php-amqplib/php-amqplib/tree/v3.6.0" }, - "time": "2020-09-25T18:34:58+00:00" + "time": "2023-10-22T15:02:02+00:00" }, { "name": "php-di/invoker", @@ -2128,32 +2245,32 @@ }, { "name": "phpseclib/phpseclib", - "version": "2.0.45", + "version": "3.0.33", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "28d8f438a0064c9de80857e3270d071495544640" + "reference": "33fa69b2514a61138dd48e7a49f99445711e0ad0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/28d8f438a0064c9de80857e3270d071495544640", - "reference": "28d8f438a0064c9de80857e3270d071495544640", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/33fa69b2514a61138dd48e7a49f99445711e0ad0", + "reference": "33fa69b2514a61138dd48e7a49f99445711e0ad0", "shasum": "" }, "require": { - "php": ">=5.3.3" + "paragonie/constant_time_encoding": "^1|^2", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" }, "require-dev": { - "phing/phing": "~2.7", - "phpunit/phpunit": "^4.8.35|^5.7|^6.0|^9.4", - "squizlabs/php_codesniffer": "~2.0" + "phpunit/phpunit": "*" }, "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", - "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations.", - "ext-xml": "Install the XML extension to load XML formatted public keys." + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." }, "type": "library", "autoload": { @@ -2161,7 +2278,7 @@ "phpseclib/bootstrap.php" ], "psr-4": { - "phpseclib\\": "phpseclib/" + "phpseclib3\\": "phpseclib/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2218,7 +2335,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/2.0.45" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.33" }, "funding": [ { @@ -2234,7 +2351,7 @@ "type": "tidelift" } ], - "time": "2023-09-15T20:55:47+00:00" + "time": "2023-10-21T14:00:39+00:00" }, { "name": "psr/cache", @@ -2882,99 +2999,45 @@ ], "time": "2023-04-15T23:01:58+00:00" }, - { - "name": "respect/stringifier", - "version": "0.2.0", - "source": { - "type": "git", - "url": "https://github.com/Respect/Stringifier.git", - "reference": "e55af3c8aeaeaa2abb5fa47a58a8e9688cc23b59" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Respect/Stringifier/zipball/e55af3c8aeaeaa2abb5fa47a58a8e9688cc23b59", - "reference": "e55af3c8aeaeaa2abb5fa47a58a8e9688cc23b59", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^2.8", - "malukenho/docheader": "^0.1.7", - "phpunit/phpunit": "^6.4" - }, - "type": "library", - "autoload": { - "files": [ - "src/stringify.php" - ], - "psr-4": { - "Respect\\Stringifier\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Respect/Stringifier Contributors", - "homepage": "https://github.com/Respect/Stringifier/graphs/contributors" - } - ], - "description": "Converts any value to a string", - "homepage": "http://respect.github.io/Stringifier/", - "keywords": [ - "respect", - "stringifier", - "stringify" - ], - "support": { - "issues": "https://github.com/Respect/Stringifier/issues", - "source": "https://github.com/Respect/Stringifier/tree/0.2.0" - }, - "time": "2017-12-29T19:39:25+00:00" - }, { "name": "respect/validation", - "version": "2.2.4", + "version": "1.1.31", "source": { "type": "git", "url": "https://github.com/Respect/Validation.git", - "reference": "d304ace5325efd7180daffb1f8627bb0affd4e3a" + "reference": "45d109fc830644fecc1145200d6351ce4f2769d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Respect/Validation/zipball/d304ace5325efd7180daffb1f8627bb0affd4e3a", - "reference": "d304ace5325efd7180daffb1f8627bb0affd4e3a", + "url": "https://api.github.com/repos/Respect/Validation/zipball/45d109fc830644fecc1145200d6351ce4f2769d0", + "reference": "45d109fc830644fecc1145200d6351ce4f2769d0", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0 || ^8.1 || ^8.2", - "respect/stringifier": "^0.2.0", + "php": ">=5.4", "symfony/polyfill-mbstring": "^1.2" }, "require-dev": { - "egulias/email-validator": "^3.0", - "malukenho/docheader": "^0.1", - "mikey179/vfsstream": "^1.6", - "phpstan/phpstan": "^1.9", - "phpstan/phpstan-deprecation-rules": "^1.1", - "phpstan/phpstan-phpunit": "^1.3", - "phpunit/phpunit": "^9.6", - "psr/http-message": "^1.0", - "respect/coding-standard": "^3.0", - "squizlabs/php_codesniffer": "^3.7", - "symfony/validator": "^3.0||^4.0" + "egulias/email-validator": "~1.2 || ~2.1", + "mikey179/vfsstream": "^1.5", + "phpunit/phpunit": "~4.0 || ~5.0", + "symfony/validator": "~2.6.9", + "zendframework/zend-validator": "~2.3" }, "suggest": { "egulias/email-validator": "Strict (RFC compliant) email validation", "ext-bcmath": "Arbitrary Precision Mathematics", - "ext-fileinfo": "File Information", - "ext-mbstring": "Multibyte String Functions" + "ext-mbstring": "Multibyte String Functions", + "friendsofphp/php-cs-fixer": "Fix PSR2 and other coding style issues", + "symfony/validator": "Use Symfony validator through Respect\\Validation", + "zendframework/zend-validator": "Use Zend Framework validator through Respect\\Validation" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, "autoload": { "psr-4": { "Respect\\Validation\\": "library/" @@ -2982,7 +3045,7 @@ }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { @@ -2999,9 +3062,9 @@ ], "support": { "issues": "https://github.com/Respect/Validation/issues", - "source": "https://github.com/Respect/Validation/tree/2.2.4" + "source": "https://github.com/Respect/Validation/tree/1.1.31" }, - "time": "2023-02-15T01:05:24+00:00" + "time": "2019-05-28T06:10:06+00:00" }, { "name": "slim/psr7", @@ -3658,73 +3721,6 @@ ], "time": "2023-09-26T12:56:25+00:00" }, - { - "name": "symfony/options-resolver", - "version": "v6.3.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/options-resolver.git", - "reference": "a10f19f5198d589d5c33333cffe98dc9820332dd" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/a10f19f5198d589d5c33333cffe98dc9820332dd", - "reference": "a10f19f5198d589d5c33333cffe98dc9820332dd", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\OptionsResolver\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides an improved replacement for the array_replace PHP function", - "homepage": "https://symfony.com", - "keywords": [ - "config", - "configuration", - "options" - ], - "support": { - "source": "https://github.com/symfony/options-resolver/tree/v6.3.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2023-05-12T14:21:09+00:00" - }, { "name": "symfony/polyfill-ctype", "version": "v1.28.0", @@ -4294,34 +4290,42 @@ "time": "2023-01-26T09:26:14+00:00" }, { - "name": "symfony/property-access", - "version": "v6.3.2", + "name": "symfony/service-contracts", + "version": "v3.3.0", "source": { "type": "git", - "url": "https://github.com/symfony/property-access.git", - "reference": "2dc4f9da444b8f8ff592e95d570caad67924f1d0" + "url": "https://github.com/symfony/service-contracts.git", + "reference": "40da9cc13ec349d9e4966ce18b5fbcd724ab10a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-access/zipball/2dc4f9da444b8f8ff592e95d570caad67924f1d0", - "reference": "2dc4f9da444b8f8ff592e95d570caad67924f1d0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/40da9cc13ec349d9e4966ce18b5fbcd724ab10a4", + "reference": "40da9cc13ec349d9e4966ce18b5fbcd724ab10a4", "shasum": "" }, "require": { "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/property-info": "^5.4|^6.0" + "psr/container": "^2.0" }, - "require-dev": { - "symfony/cache": "^5.4|^6.0" + "conflict": { + "ext-psr": "<1.1|>=2" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.4-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, "autoload": { "psr-4": { - "Symfony\\Component\\PropertyAccess\\": "" + "Symfony\\Contracts\\Service\\": "" }, "exclude-from-classmap": [ - "/Tests/" + "/Test/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -4330,29 +4334,26 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "description": "Generic abstractions related to writing services", "homepage": "https://symfony.com", "keywords": [ - "access", - "array", - "extraction", - "index", - "injection", - "object", - "property", - "property-path", - "reflection" + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" ], "support": { - "source": "https://github.com/symfony/property-access/tree/v6.3.2" + "source": "https://github.com/symfony/service-contracts/tree/v3.3.0" }, "funding": [ { @@ -4368,190 +4369,25 @@ "type": "tidelift" } ], - "time": "2023-07-13T15:26:11+00:00" + "time": "2023-05-23T14:45:45+00:00" }, { - "name": "symfony/property-info", + "name": "symfony/stopwatch", "version": "v6.3.0", "source": { "type": "git", - "url": "https://github.com/symfony/property-info.git", - "reference": "7f3a03716112269741fe2a809f8f791a371d1fcd" + "url": "https://github.com/symfony/stopwatch.git", + "reference": "fc47f1015ec80927ff64ba9094dfe8b9d48fe9f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/7f3a03716112269741fe2a809f8f791a371d1fcd", - "reference": "7f3a03716112269741fe2a809f8f791a371d1fcd", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/fc47f1015ec80927ff64ba9094dfe8b9d48fe9f2", + "reference": "fc47f1015ec80927ff64ba9094dfe8b9d48fe9f2", "shasum": "" }, "require": { "php": ">=8.1", - "symfony/string": "^5.4|^6.0" - }, - "conflict": { - "phpdocumentor/reflection-docblock": "<5.2", - "phpdocumentor/type-resolver": "<1.5.1", - "symfony/dependency-injection": "<5.4" - }, - "require-dev": { - "doctrine/annotations": "^1.10.4|^2", - "phpdocumentor/reflection-docblock": "^5.2", - "phpstan/phpdoc-parser": "^1.0", - "symfony/cache": "^5.4|^6.0", - "symfony/dependency-injection": "^5.4|^6.0", - "symfony/serializer": "^5.4|^6.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\PropertyInfo\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Kévin Dunglas", - "email": "dunglas@gmail.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Extracts information about PHP class' properties using metadata of popular sources", - "homepage": "https://symfony.com", - "keywords": [ - "doctrine", - "phpdoc", - "property", - "symfony", - "type", - "validator" - ], - "support": { - "source": "https://github.com/symfony/property-info/tree/v6.3.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2023-05-19T08:06:44+00:00" - }, - { - "name": "symfony/service-contracts", - "version": "v3.3.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "40da9cc13ec349d9e4966ce18b5fbcd724ab10a4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/40da9cc13ec349d9e4966ce18b5fbcd724ab10a4", - "reference": "40da9cc13ec349d9e4966ce18b5fbcd724ab10a4", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "psr/container": "^2.0" - }, - "conflict": { - "ext-psr": "<1.1|>=2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.4-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Contracts\\Service\\": "" - }, - "exclude-from-classmap": [ - "/Test/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to writing services", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.3.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2023-05-23T14:45:45+00:00" - }, - { - "name": "symfony/stopwatch", - "version": "v6.3.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/stopwatch.git", - "reference": "fc47f1015ec80927ff64ba9094dfe8b9d48fe9f2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/fc47f1015ec80927ff64ba9094dfe8b9d48fe9f2", - "reference": "fc47f1015ec80927ff64ba9094dfe8b9d48fe9f2", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "symfony/service-contracts": "^2.5|^3" + "symfony/service-contracts": "^2.5|^3" }, "type": "library", "autoload": { @@ -7833,6 +7669,74 @@ ], "time": "2020-09-28T06:39:44+00:00" }, + { + "name": "spatie/phpunit-snapshot-assertions", + "version": "4.2.16", + "source": { + "type": "git", + "url": "https://github.com/spatie/phpunit-snapshot-assertions.git", + "reference": "4c325139313c06b656ba10d5b60306c0de728c1f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/phpunit-snapshot-assertions/zipball/4c325139313c06b656ba10d5b60306c0de728c1f", + "reference": "4c325139313c06b656ba10d5b60306c0de728c1f", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "php": "^7.3|^7.4|^8.0", + "phpunit/phpunit": "^8.3|^9.0", + "symfony/property-access": "^4.0|^5.0|^6.0", + "symfony/serializer": "^4.0|^5.0|^6.0", + "symfony/yaml": "^4.0|^5.0|^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Snapshots\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian De Deyne", + "email": "sebastian@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Snapshot testing with PHPUnit", + "homepage": "https://github.com/spatie/phpunit-snapshot-assertions", + "keywords": [ + "assert", + "phpunit", + "phpunit-snapshot-assertions", + "snapshot", + "spatie", + "testing" + ], + "support": { + "issues": "https://github.com/spatie/phpunit-snapshot-assertions/issues", + "source": "https://github.com/spatie/phpunit-snapshot-assertions/tree/4.2.16" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + } + ], + "time": "2022-10-10T15:58:50+00:00" + }, { "name": "symfony/event-dispatcher", "version": "v6.3.2", @@ -8052,6 +7956,73 @@ ], "time": "2023-06-01T08:30:39+00:00" }, + { + "name": "symfony/options-resolver", + "version": "v6.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "a10f19f5198d589d5c33333cffe98dc9820332dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/a10f19f5198d589d5c33333cffe98dc9820332dd", + "reference": "a10f19f5198d589d5c33333cffe98dc9820332dd", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v6.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-05-12T14:21:09+00:00" + }, { "name": "symfony/process", "version": "v6.3.4", @@ -8113,6 +8084,260 @@ ], "time": "2023-08-07T10:39:22+00:00" }, + { + "name": "symfony/property-access", + "version": "v6.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-access.git", + "reference": "2dc4f9da444b8f8ff592e95d570caad67924f1d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-access/zipball/2dc4f9da444b8f8ff592e95d570caad67924f1d0", + "reference": "2dc4f9da444b8f8ff592e95d570caad67924f1d0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/property-info": "^5.4|^6.0" + }, + "require-dev": { + "symfony/cache": "^5.4|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v6.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-07-13T15:26:11+00:00" + }, + { + "name": "symfony/property-info", + "version": "v6.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "7f3a03716112269741fe2a809f8f791a371d1fcd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/7f3a03716112269741fe2a809f8f791a371d1fcd", + "reference": "7f3a03716112269741fe2a809f8f791a371d1fcd", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/string": "^5.4|^6.0" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/dependency-injection": "<5.4" + }, + "require-dev": { + "doctrine/annotations": "^1.10.4|^2", + "phpdocumentor/reflection-docblock": "^5.2", + "phpstan/phpdoc-parser": "^1.0", + "symfony/cache": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/serializer": "^5.4|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v6.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-05-19T08:06:44+00:00" + }, + { + "name": "symfony/serializer", + "version": "v6.3.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "641472dd3d6dc3c4d0fdd1496ebd1b55c72e43d9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/641472dd3d6dc3c4d0fdd1496ebd1b55c72e43d9", + "reference": "641472dd3d6dc3c4d0fdd1496ebd1b55c72e43d9", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "doctrine/annotations": "<1.12", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/dependency-injection": "<5.4", + "symfony/property-access": "<5.4", + "symfony/property-info": "<5.4.24|>=6,<6.2.11", + "symfony/uid": "<5.4", + "symfony/yaml": "<5.4" + }, + "require-dev": { + "doctrine/annotations": "^1.12|^2", + "phpdocumentor/reflection-docblock": "^3.2|^4.0|^5.0", + "symfony/cache": "^5.4|^6.0", + "symfony/config": "^5.4|^6.0", + "symfony/console": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/error-handler": "^5.4|^6.0", + "symfony/filesystem": "^5.4|^6.0", + "symfony/form": "^5.4|^6.0", + "symfony/http-foundation": "^5.4|^6.0", + "symfony/http-kernel": "^5.4|^6.0", + "symfony/mime": "^5.4|^6.0", + "symfony/property-access": "^5.4|^6.0", + "symfony/property-info": "^5.4.24|^6.2.11", + "symfony/uid": "^5.4|^6.0", + "symfony/validator": "^5.4|^6.0", + "symfony/var-dumper": "^5.4|^6.0", + "symfony/var-exporter": "^5.4|^6.0", + "symfony/yaml": "^5.4|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v6.3.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-10-26T18:15:14+00:00" + }, { "name": "theseer/tokenizer", "version": "1.2.1", @@ -8234,5 +8459,5 @@ "ext-sockets": "*" }, "platform-dev": [], - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } diff --git a/config/cli-config.php b/config/cli-config.php index bbb2e60..d86581b 100644 --- a/config/cli-config.php +++ b/config/cli-config.php @@ -6,12 +6,13 @@ use App\Infrastructure\DependencyInjection\ContainerFactory; use App\Infrastructure\Environment\Settings; +use DI\Container; use Doctrine\Migrations\Configuration\EntityManager\ExistingEntityManager; use Doctrine\Migrations\Configuration\Migration\ConfigurationArray; use Doctrine\Migrations\DependencyFactory; use Doctrine\ORM\EntityManager; -/** @var \DI\Container $container */ +/** @var Container $container */ $container = ContainerFactory::create(); return DependencyFactory::fromEntityManager( diff --git a/config/compiler-passes.php b/config/compiler-passes.php index 3626241..ac68f90 100644 --- a/config/compiler-passes.php +++ b/config/compiler-passes.php @@ -2,8 +2,12 @@ declare(strict_types=1); +use App\Infrastructure\AMQP\Queue\QueueCompilerPass; use App\Infrastructure\Console\ConsoleCommandCompilerPass; +use App\Infrastructure\Events\EventHandler\EventHandlerCompilerPass; return [ new ConsoleCommandCompilerPass(), + new QueueCompilerPass(), + new EventHandlerCompilerPass(), ]; diff --git a/config/container.php b/config/container.php index c47ee5b..c8dd284 100644 --- a/config/container.php +++ b/config/container.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Domain\Entity\User\UserRepositoryInterface; +use App\Infrastructure\AMQP\AMQPStreamConnectionFactory; use App\Infrastructure\Console\ConsoleCommandContainer; use App\Infrastructure\Environment\Environment; use App\Infrastructure\Environment\Settings; @@ -19,7 +20,9 @@ use Monolog\Logger; use Monolog\Processor\UidProcessor; use Psr\Container\ContainerInterface; +use Psr\Http\Message\ServerRequestFactoryInterface; use Psr\Log\LoggerInterface; +use Slim\Psr7\Factory\ServerRequestFactory; use Slim\Views\Twig; use Symfony\Component\Console\Application; use Twig\Loader\FilesystemLoader; @@ -75,6 +78,19 @@ Environment::class => fn() => Environment::from($_ENV["ENVIRONMENT"]), // Settings. Settings::class => DI\factory([Settings::class, "load"]), + // AMQP. + AMQPStreamConnectionFactory::class => function (Settings $settings) { + $rabbitMqConfig = $settings->get("rabbitmq"); + + return new AMQPStreamConnectionFactory( + $rabbitMqConfig["host"], + (int)$rabbitMqConfig["port"], + $rabbitMqConfig["username"], + $rabbitMqConfig["password"], + $rabbitMqConfig["vhost"], + ); + }, + ServerRequestFactoryInterface::class => \DI\get(ServerRequestFactory::class), // Repositories UserRepositoryInterface::class => DI\get(UserRepository::class), ]; diff --git a/config/routes.php b/config/routes.php index 62d853d..7749bc3 100644 --- a/config/routes.php +++ b/config/routes.php @@ -5,8 +5,10 @@ use App\Application\Actions\Docs\OpenApiDocsAction; use App\Application\Actions\Docs\SwaggerUiAction; use App\Application\Actions\HealthCheck\HealthCheckAction; +use App\Application\Actions\User\AddUserAction; use App\Application\Actions\User\GetAllUsersAction; use App\Application\Actions\User\GetUserByIdAction; +use App\Application\Validator\UserValidator; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Slim\App; @@ -33,6 +35,9 @@ $group->group("/user", function (Group $group): void { $group->get("/{id}", GetUserByIdAction::class) ->setName("getUserById"); + $group->post("", AddUserAction::class) + ->add(UserValidator::class) + ->setName("addUser"); }); $group->get("/users", GetAllUsersAction::class) diff --git a/config/settings.php b/config/settings.php index bd8f8e6..bc8ef02 100644 --- a/config/settings.php +++ b/config/settings.php @@ -26,6 +26,13 @@ // Path where Slim will cache the container, compiler passes, ... "cache_dir" => Settings::getAppRoot() . "/var/cache/slim", ], + "rabbitmq" => [ + "host" => $_ENV["RABBITMQ_HOST"], + "port" => $_ENV["RABBITMQ_PORT"], + "username" => $_ENV["RABBITMQ_USER"], + "password" => $_ENV["RABBITMQ_PASS"], + "vhost" => $_ENV["RABBITMQ_VHOST"], + ], "doctrine" => [ // Enables or disables Doctrine metadata caching // for either performance or convenience during development. diff --git a/deptrac.yaml b/deptrac.yaml index 4478260..8326369 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -27,3 +27,7 @@ deptrac: skip_violations: App\Application\Console\CacheClearConsoleCommand: - App\Infrastructure\Environment\Settings + App\Application\Console\AmqpConsumeConsoleCommand: + - App\Infrastructure\AMQP\Queue\QueueContainer + - App\Infrastructure\AMQP\Consumer + - App\Infrastructure\AMQP\Queue\QueueContainer diff --git a/docker-compose.yml b/docker-compose.yml index d50f400..99e4cbd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,9 +51,9 @@ services: - 5672:5672 - 15672:15672 environment: - RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER} - RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS} - RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST} + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-rabbitmq} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS:-rabbitmq} + RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST:-app} networks: - slim # Networks diff --git a/phpstan.neon b/phpstan.neon index bc57d7a..6302ad5 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,5 +3,4 @@ parameters: checkMissingIterableValueType: false paths: - src - - tests - config diff --git a/resources/docs/openapi.yaml b/resources/docs/openapi.yaml index 1439206..4d9cb4c 100644 --- a/resources/docs/openapi.yaml +++ b/resources/docs/openapi.yaml @@ -36,6 +36,31 @@ paths: description: success '503': description: 'some services are not responding' + /api/v1/user: + post: + tags: + - user + summary: 'Create a new user' + requestBody: + request: User + description: 'User data in JSON format' + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + '201': + description: 'User created' + content: + application/json: + schema: + type: object + example: [] + '400': + description: 'User data validation error' + '401': + description: Unauthorized /api/v1/users: get: tags: @@ -92,3 +117,20 @@ components: type: string example: lastName type: object + User: + title: User + required: + - username + - firstName + - lastName + properties: + username: + type: string + example: Janusz123 + firstName: + type: string + example: Janusz + lastName: + type: string + example: Borowy + type: object diff --git a/src/Application/Actions/User/AddUserAction.php b/src/Application/Actions/User/AddUserAction.php new file mode 100644 index 0000000..30e4f32 --- /dev/null +++ b/src/Application/Actions/User/AddUserAction.php @@ -0,0 +1,67 @@ +getFormData(true); + + try { + $user = $this->userFactory->createFromRequest($userData); + $this->userService->createUser($user); + + return $this->respondWithJson(null, StatusCodeInterface::STATUS_CREATED); + } catch (Throwable $exception) { + throw new HttpBadRequestException( + $this->request, + $exception->getMessage(), + $exception->getPrevious(), + ); + } + } +} diff --git a/src/Application/Actions/User/GetAllUsersAction.php b/src/Application/Actions/User/GetAllUsersAction.php index b9ab82a..98f626e 100644 --- a/src/Application/Actions/User/GetAllUsersAction.php +++ b/src/Application/Actions/User/GetAllUsersAction.php @@ -5,7 +5,7 @@ namespace App\Application\Actions\User; use App\Application\DTO\Response\UsersResponseDto; -use App\Domain\Service\UserService; +use App\Domain\Service\User\UserService; use Psr\Http\Message\ResponseInterface as Response; use Psr\Log\LoggerInterface; use Slim\Exception\HttpBadRequestException; diff --git a/src/Application/Actions/User/GetUserByIdAction.php b/src/Application/Actions/User/GetUserByIdAction.php index 107638e..e79e79a 100644 --- a/src/Application/Actions/User/GetUserByIdAction.php +++ b/src/Application/Actions/User/GetUserByIdAction.php @@ -5,7 +5,7 @@ namespace App\Application\Actions\User; use App\Application\DTO\Response\UserResponseDto; -use App\Domain\Service\UserService; +use App\Domain\Service\User\UserService; use Psr\Http\Message\ResponseInterface as Response; use Psr\Log\LoggerInterface; use Slim\Exception\HttpBadRequestException; diff --git a/src/Application/Console/AmqpConsumeConsoleCommand.php b/src/Application/Console/AmqpConsumeConsoleCommand.php new file mode 100644 index 0000000..2710453 --- /dev/null +++ b/src/Application/Console/AmqpConsumeConsoleCommand.php @@ -0,0 +1,56 @@ + + */ + public function getSubscribedSignals(): array + { + return [SIGTERM, SIGINT]; + } + + /** + * @phpstan-ignore-next-line + */ + public function handleSignal(int $signal): void + { + $this->consumer->shutdown(); + } + + protected function configure(): void + { + $this->setDefinition([ + new InputArgument("queue", InputArgument::REQUIRED, "The queue to consume."), + ]); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $queue = $this->queueContainer->getQueue($input->getArgument("queue")); + $this->consumer->consume($queue); + + return Command::SUCCESS; + } +} diff --git a/src/Application/Factory/UserFactory.php b/src/Application/Factory/UserFactory.php new file mode 100644 index 0000000..304f9db --- /dev/null +++ b/src/Application/Factory/UserFactory.php @@ -0,0 +1,19 @@ + + */ + public static function create(array $errors): array + { + $return = []; + + foreach ($errors as $field => $error) { + $return[] = new ValidationError($field, $error); + } + + return $return; + } +} diff --git a/src/Application/Handlers/HttpErrorHandler.php b/src/Application/Handlers/HttpErrorHandler.php index 3f8263a..7086da0 100644 --- a/src/Application/Handlers/HttpErrorHandler.php +++ b/src/Application/Handlers/HttpErrorHandler.php @@ -8,6 +8,7 @@ use App\Application\Exception\HttpUnprocessableEntityException; use App\Domain\DomainException\DomainException; use App\Domain\DomainException\DomainRecordNotFoundException; +use App\Domain\DomainException\ValidationException; use Psr\Http\Message\ResponseInterface as Response; use Slim\Exception\HttpBadRequestException; use Slim\Exception\HttpException; @@ -45,6 +46,9 @@ protected function respond(): Response $error->setDescription(ActionError::NOT_IMPLEMENTED); } elseif ($exception instanceof HttpUnprocessableEntityException) { $error->setDescription($exception->getMessage()); + } elseif ($exception instanceof ValidationException) { + $error->setDescription($exception->getMessage()); + $error->setErrors($exception->errors()); } } diff --git a/src/Application/Validator/UserValidator.php b/src/Application/Validator/UserValidator.php new file mode 100644 index 0000000..c67ec0d --- /dev/null +++ b/src/Application/Validator/UserValidator.php @@ -0,0 +1,24 @@ + V::allOf(V::stringType(), V::notEmpty()), + "firstName" => V::allOf(V::stringType(), V::notEmpty()), + "lastName" => V::allOf(V::stringType(), V::notEmpty()), + ]; + } + + protected function message(): string + { + return "User data validation error"; + } +} diff --git a/src/Application/Validator/ValidationError.php b/src/Application/Validator/ValidationError.php new file mode 100644 index 0000000..da3e632 --- /dev/null +++ b/src/Application/Validator/ValidationError.php @@ -0,0 +1,22 @@ +field => implode(", ", $this->errors), + ]; + } +} diff --git a/src/Application/Validator/ValidationMiddleware.php b/src/Application/Validator/ValidationMiddleware.php new file mode 100644 index 0000000..81e711f --- /dev/null +++ b/src/Application/Validator/ValidationMiddleware.php @@ -0,0 +1,55 @@ +validateRequest($request); + + return $handler->handle($request); + } + + /** + * @throws ValidationException + * @throws HttpBadRequestException + */ + protected function validateRequest(ServerRequestInterface $request): void + { + $data = json_decode((string)$request->getBody(), true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new HttpBadRequestException($request, "Malformed JSON input."); + } + + $validator = $this->validate($data, $this->rules($data)); + + if (!$this->isValid()) { + throw new ValidationException( + $this->message(), + ValidationErrorFactory::create($validator->getErrors()), + ); + } + } + + abstract protected function rules(array $data = []): array; + + abstract protected function message(): string; +} diff --git a/src/Domain/DomainException/ValidationException.php b/src/Domain/DomainException/ValidationException.php new file mode 100644 index 0000000..c368ac0 --- /dev/null +++ b/src/Domain/DomainException/ValidationException.php @@ -0,0 +1,27 @@ +errors = $errors; + } + + public function errors(): array + { + return $this->errors; + } +} diff --git a/src/Domain/Entity/User/User.php b/src/Domain/Entity/User/User.php index f065b2b..b84ff8c 100644 --- a/src/Domain/Entity/User/User.php +++ b/src/Domain/Entity/User/User.php @@ -7,14 +7,25 @@ use Doctrine\ORM\Mapping\Entity; use JsonSerializable; +/** + * @OA\Schema( + * title="User", + * required={"username","firstName","lastName"} + * ) + */ #[Entity] class User implements JsonSerializable { /** @phpstan-ignore-next-line */ private int $id; + /** @OA\Property(type="string", example="Janusz123") */ private string $username; + + /** @OA\Property(type="string", example="Janusz") */ private string $firstName; + + /** @OA\Property(type="string", example="Borowy") */ private string $lastName; public function __construct( diff --git a/src/Domain/Entity/User/UserRepositoryInterface.php b/src/Domain/Entity/User/UserRepositoryInterface.php index 2513573..ef7732c 100644 --- a/src/Domain/Entity/User/UserRepositoryInterface.php +++ b/src/Domain/Entity/User/UserRepositoryInterface.php @@ -17,4 +17,6 @@ public function findAll(): UsersCollection; * @throws UserNotFoundException */ public function findUserOfId(int $id): User; + + public function save(User $user): void; } diff --git a/src/Domain/Service/User/DomainEvents/UserWasCreated.php b/src/Domain/Service/User/DomainEvents/UserWasCreated.php new file mode 100644 index 0000000..6cb0cc7 --- /dev/null +++ b/src/Domain/Service/User/DomainEvents/UserWasCreated.php @@ -0,0 +1,20 @@ +user; + } +} diff --git a/src/Domain/Service/User/DomainEvents/UserWasCreatedEventHandler.php b/src/Domain/Service/User/DomainEvents/UserWasCreatedEventHandler.php new file mode 100644 index 0000000..089230d --- /dev/null +++ b/src/Domain/Service/User/DomainEvents/UserWasCreatedEventHandler.php @@ -0,0 +1,25 @@ +logger->info(sprintf("User named %s has been created", $event->user()->firstName())); + } +} diff --git a/src/Domain/Service/User/UserEventsService.php b/src/Domain/Service/User/UserEventsService.php new file mode 100644 index 0000000..765d2ad --- /dev/null +++ b/src/Domain/Service/User/UserEventsService.php @@ -0,0 +1,21 @@ +userEventQueue->queue(new UserWasCreated($user)); + } +} diff --git a/src/Domain/Service/UserService.php b/src/Domain/Service/User/UserService.php similarity index 73% rename from src/Domain/Service/UserService.php rename to src/Domain/Service/User/UserService.php index bd4a5de..8738bf8 100644 --- a/src/Domain/Service/UserService.php +++ b/src/Domain/Service/User/UserService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Domain\Service; +namespace App\Domain\Service\User; use App\Domain\Entity\User\Exception\UserNotFoundException; use App\Domain\Entity\User\User; @@ -13,6 +13,7 @@ { public function __construct( private UserRepositoryInterface $userRepository, + private UserEventsService $userEventsService, ) {} /** @@ -30,4 +31,10 @@ public function getAllUsers(): UsersCollection { return $this->userRepository->findAll(); } + + public function createUser(User $user): void + { + $this->userRepository->save($user); + $this->userEventsService->userWasCreated($user); + } } diff --git a/src/Infrastructure/AMQP/AMQPChannelFactory.php b/src/Infrastructure/AMQP/AMQPChannelFactory.php new file mode 100644 index 0000000..02032f7 --- /dev/null +++ b/src/Infrastructure/AMQP/AMQPChannelFactory.php @@ -0,0 +1,41 @@ + */ + private array $channels = []; + + public function __construct( + private readonly AMQPStreamConnectionFactory $AMQPStreamConnectionFactory, + ) {} + + public function getForQueue(Queue $queue, ?AMQPChannelOptions $options = null): AMQPChannel + { + if (!array_key_exists($queue->getName(), $this->channels)) { + $this->channels[$queue->getName()] = $this->AMQPStreamConnectionFactory->get()->channel(); + + $options ??= AMQPChannelOptions::default(); + + $this->channels[$queue->getName()]->queue_declare( + $queue->getName(), + $options->isPassive(), + $options->isDurable(), + $options->isExclusive(), + $options->isAutoDelete(), + $options->isNowait(), + $options->getArguments(), + $options->getTicket(), + ); + $this->channels[$queue->getName()]->basic_qos(0, 1, false); + } + + return $this->channels[$queue->getName()]; + } +} diff --git a/src/Infrastructure/AMQP/AMQPChannelOptions.php b/src/Infrastructure/AMQP/AMQPChannelOptions.php new file mode 100644 index 0000000..06d784f --- /dev/null +++ b/src/Infrastructure/AMQP/AMQPChannelOptions.php @@ -0,0 +1,62 @@ +passive; + } + + public function isDurable(): bool + { + return $this->durable; + } + + public function isExclusive(): bool + { + return $this->exclusive; + } + + public function isAutoDelete(): bool + { + return $this->autoDelete; + } + + public function isNowait(): bool + { + return $this->nowait; + } + + /** + * @return array + */ + public function getArguments(): array + { + return $this->arguments; + } + + public function getTicket(): ?int + { + return $this->ticket; + } + + public static function default(): self + { + return new self(false, true, false, false); + } +} diff --git a/src/Infrastructure/AMQP/AMQPStreamConnectionFactory.php b/src/Infrastructure/AMQP/AMQPStreamConnectionFactory.php new file mode 100644 index 0000000..fcb26b2 --- /dev/null +++ b/src/Infrastructure/AMQP/AMQPStreamConnectionFactory.php @@ -0,0 +1,40 @@ +AMQPStreamConnection === null) { + $this->AMQPStreamConnection = new AMQPStreamConnection( + $this->host, + $this->port, + $this->username, + $this->password, + $this->vhost, + ); + } + + return $this->AMQPStreamConnection; + } +} diff --git a/src/Infrastructure/AMQP/Consumer.php b/src/Infrastructure/AMQP/Consumer.php new file mode 100644 index 0000000..e9995e2 --- /dev/null +++ b/src/Infrastructure/AMQP/Consumer.php @@ -0,0 +1,87 @@ +channel?->close(); + } + + public function shutdown(): void + { + $this->forceShutDown = true; + } + + public function consume(Queue $queue): void + { + $channel = $this->AMQPChannelFactory->getForQueue($queue); + + $callback = static function (AMQPMessage $message) use ($queue): void { + // Block any incoming exit signals to make sure the current message can be processed. + pcntl_sigprocmask(SIG_BLOCK, [SIGTERM, SIGINT]); + self::consumeCallback($message, $queue); + // Unblock any incoming exit signals, message has been processed, consumer can DIE. + pcntl_sigprocmask(SIG_UNBLOCK, [SIGTERM, SIGINT]); + // Dispatch the exit signals that might've come in. + pcntl_signal_dispatch(); + }; + + try { + $channel->basic_consume($queue->getName(), "", false, false, false, false, $callback); + + while ($channel->is_open() && !$this->forceShutDown) { + $channel->wait(); + // Dispatch incoming exit signals. + pcntl_signal_dispatch(); + } + } catch (WorkerMaxLifeTimeOrIterationsExceeded|ConnectionLost) { + $channel->close(); + $this->AMQPStreamConnectionFactory->get()->close(); + } + } + + public static function consumeCallback( + AMQPMessage $message, + Queue $queue, + ): void { + $worker = $queue->getWorker(); + $envelope = unserialize($message->getBody()); + + try { + if ($worker->maxLifeTimeReached() || $worker->maxIterationsReached()) { + throw new WorkerMaxLifeTimeOrIterationsExceeded(); + } + + $worker->processMessage($envelope, $message); + $message->getChannel()?->basic_ack($message->getDeliveryTag()); + } catch (WorkerMaxLifeTimeOrIterationsExceeded $exception) { + // Requeue message to make sure next consumer can process it. + $message->getChannel()?->basic_nack($message->getDeliveryTag(), false, true); + + throw $exception; + } catch (Throwable $exception) { + $worker->processFailure($envelope, $message, $exception, $queue); + // Ack the message to unblock queue. Worker should handle failed messages. + $message->getChannel()?->basic_ack($message->getDeliveryTag()); + } + } +} diff --git a/src/Infrastructure/AMQP/Envelope.php b/src/Infrastructure/AMQP/Envelope.php new file mode 100644 index 0000000..112b19a --- /dev/null +++ b/src/Infrastructure/AMQP/Envelope.php @@ -0,0 +1,9 @@ +getAttributes(AsAmqpQueue::class)) { + $this->amqpQueueAttribute = $attribute[0]->newInstance(); + } + } + + public function getName(): string + { + if (!$this->amqpQueueAttribute) { + throw new \RuntimeException("AsAmqpQueue attribute not set"); + } + + return $this->amqpQueueAttribute->getName(); + } + + public function getNumberOfConsumers(): int + { + if (!$this->amqpQueueAttribute) { + throw new \RuntimeException("AsAmqpQueue attribute not set"); + } + + return $this->amqpQueueAttribute->getNumberOfWorkers(); + } + + public function queue(Envelope $envelope): void + { + $properties = ["content_type" => "text/plain", "delivery_mode" => AMQPMessage::DELIVERY_MODE_PERSISTENT]; + $message = new AMQPMessage(serialize($envelope), $properties); + $this->getChannel()->basic_publish($message, "", $this->getName()); + } + + public function queueBatch(array $envelopes): void + { + if (empty($envelopes)) { + return; + } + + /** @phpstan-ignore-next-line */ + if (!empty(array_filter($envelopes, fn($envelope) => !$envelope instanceof Envelope))) { + throw new RuntimeException(sprintf("All envelopes need to implement %s", Envelope::class)); + } + + $channel = $this->getChannel(); + + foreach ($envelopes as $envelope) { + $properties = ["content_type" => "text/plain", "delivery_mode" => AMQPMessage::DELIVERY_MODE_PERSISTENT]; + $message = new AMQPMessage(serialize($envelope), $properties); + $channel->batch_basic_publish($message, "", $this->getName()); + } + $channel->publish_batch(); + } + + protected function getChannel(): AMQPChannel + { + return $this->AMQPChannelFactory->getForQueue($this); + } +} diff --git a/src/Infrastructure/AMQP/Queue/DelayedQueue/DelayedQueue.php b/src/Infrastructure/AMQP/Queue/DelayedQueue/DelayedQueue.php new file mode 100644 index 0000000..bad401d --- /dev/null +++ b/src/Infrastructure/AMQP/Queue/DelayedQueue/DelayedQueue.php @@ -0,0 +1,55 @@ +delayInSeconds < 1) { + throw new \InvalidArgumentException("Delay cannot be less than 1 second"); + } + parent::__construct($AMQPChannelFactory); + } + + public function getName(): string + { + return "delayed-" . $this->delayInSeconds . "s-" . $this->queue->getName(); + } + + public function getWorker(): Worker + { + throw new \RuntimeException("Delayed queues do not have workers"); + } + + public function getNumberOfConsumers(): int + { + return 0; + } + + protected function getChannel(): AMQPChannel + { + $options = new AMQPChannelOptions(false, true, false, false, false, [ + "x-dead-letter-exchange" => ["S", self::X_DEAD_LETTER_EXCHANGE], + "x-dead-letter-routing-key" => ["S", $this->queue->getName()], + "x-message-ttl" => ["I", $this->delayInSeconds * 1000], + "x-expires" => ["I", $this->delayInSeconds * 1000 + 100000], // Keep the Q for 100s after the last message, + ]); + + return $this->AMQPChannelFactory->getForQueue($this, $options); + } +} diff --git a/src/Infrastructure/AMQP/Queue/DelayedQueue/DelayedQueueFactory.php b/src/Infrastructure/AMQP/Queue/DelayedQueue/DelayedQueueFactory.php new file mode 100644 index 0000000..9324de0 --- /dev/null +++ b/src/Infrastructure/AMQP/Queue/DelayedQueue/DelayedQueueFactory.php @@ -0,0 +1,24 @@ +AMQPChannelFactory, + ); + } +} diff --git a/src/Infrastructure/AMQP/Queue/FailedQueue/FailedQueue.php b/src/Infrastructure/AMQP/Queue/FailedQueue/FailedQueue.php new file mode 100644 index 0000000..179eecd --- /dev/null +++ b/src/Infrastructure/AMQP/Queue/FailedQueue/FailedQueue.php @@ -0,0 +1,35 @@ +queue->getName() . "-failed"; + } + + public function getWorker(): Worker + { + throw new \RuntimeException("Failed queues do not have workers"); + } + + public function getNumberOfConsumers(): int + { + return 0; + } +} diff --git a/src/Infrastructure/AMQP/Queue/FailedQueue/FailedQueueFactory.php b/src/Infrastructure/AMQP/Queue/FailedQueue/FailedQueueFactory.php new file mode 100644 index 0000000..a13de7d --- /dev/null +++ b/src/Infrastructure/AMQP/Queue/FailedQueue/FailedQueueFactory.php @@ -0,0 +1,23 @@ +AMQPChannelFactory, + ); + } +} diff --git a/src/Infrastructure/AMQP/Queue/Queue.php b/src/Infrastructure/AMQP/Queue/Queue.php new file mode 100644 index 0000000..feb8567 --- /dev/null +++ b/src/Infrastructure/AMQP/Queue/Queue.php @@ -0,0 +1,24 @@ + $envelopes + */ + public function queueBatch(array $envelopes): void; +} diff --git a/src/Infrastructure/AMQP/Queue/QueueCompilerPass.php b/src/Infrastructure/AMQP/Queue/QueueCompilerPass.php new file mode 100644 index 0000000..67871cb --- /dev/null +++ b/src/Infrastructure/AMQP/Queue/QueueCompilerPass.php @@ -0,0 +1,25 @@ +findDefinition(QueueContainer::class); + + foreach ($container->findTaggedWithClassAttribute(AsAmqpQueue::class) as $class) { + $definition->method("registerQueue", \DI\autowire($class)); + } + + $container->addDefinitions( + [QueueContainer::class => $definition], + ); + } +} diff --git a/src/Infrastructure/AMQP/Queue/QueueContainer.php b/src/Infrastructure/AMQP/Queue/QueueContainer.php new file mode 100644 index 0000000..38f6d1d --- /dev/null +++ b/src/Infrastructure/AMQP/Queue/QueueContainer.php @@ -0,0 +1,35 @@ + */ + private array $queues = []; + + public function registerQueue(Queue $queue): void + { + $this->queues[$queue->getName()] = $queue; + } + + public function getQueue(string $name): Queue + { + if (!array_key_exists($name, $this->queues)) { + throw new RuntimeException(sprintf('Queue "%s" not registered in container', $name)); + } + + return $this->queues[$name]; + } + + /** + * @return array + */ + public function getQueues(): array + { + return $this->queues; + } +} diff --git a/src/Infrastructure/AMQP/Worker/BaseWorker.php b/src/Infrastructure/AMQP/Worker/BaseWorker.php new file mode 100644 index 0000000..5025798 --- /dev/null +++ b/src/Infrastructure/AMQP/Worker/BaseWorker.php @@ -0,0 +1,48 @@ +maxLifeTimeDateTime = $this->clock->now()->add($this->getMaxLifeTimeInterval()); + } + + public function getMaxIterations(): int + { + return 1000; + } + + public function maxIterationsReached(): bool + { + return $this->counter++ >= $this->getMaxIterations(); + } + + public function getMaxLifeTime(): DateTimeImmutable + { + return $this->maxLifeTimeDateTime; + } + + public function getMaxLifeTimeInterval(): DateInterval + { + return new DateInterval(self::MAX_LIFE_TIME_INTERVAL); + } + + public function maxLifeTimeReached(): bool + { + return $this->clock->now() >= $this->maxLifeTimeDateTime; + } +} diff --git a/src/Infrastructure/AMQP/Worker/Worker.php b/src/Infrastructure/AMQP/Worker/Worker.php new file mode 100644 index 0000000..36a0862 --- /dev/null +++ b/src/Infrastructure/AMQP/Worker/Worker.php @@ -0,0 +1,30 @@ +name; + } + + public function getNumberOfWorkers(): int + { + return $this->numberOfWorkers; + } +} diff --git a/src/Infrastructure/Attribute/AsEventHandler.php b/src/Infrastructure/Attribute/AsEventHandler.php new file mode 100644 index 0000000..9434e89 --- /dev/null +++ b/src/Infrastructure/Attribute/AsEventHandler.php @@ -0,0 +1,13 @@ +cacheFileName = rtrim($this->cacheDir, "/") . "/" . (new \ReflectionClass($attributeClassName))->getShortName() . ".php"; + $this->cacheFileName = rtrim($this->cacheDir, "/") . "/" . (new ReflectionClass($attributeClassName))->getShortName() . ".php"; } public function get(): string { if (!$this->exists()) { - throw new \RuntimeException(sprintf("Cache not set for %s", (new \ReflectionClass($this->attributeClassName))->getShortName())); + throw new RuntimeException(sprintf("Cache not set for %s", (new ReflectionClass($this->attributeClassName))->getShortName())); } return $this->cacheFileName; @@ -62,11 +66,11 @@ public function exists(): bool private function createCacheDirectory(string $directory): void { if (!is_dir($directory) && !@mkdir($directory, 0777, true) && !is_dir($directory)) { - throw new \InvalidArgumentException(sprintf("Cache directory does not exist and cannot be created: %s.", $directory)); + throw new InvalidArgumentException(sprintf("Cache directory does not exist and cannot be created: %s.", $directory)); } if (!is_writable($directory)) { - throw new \InvalidArgumentException(sprintf("Cache directory is not writable: %s.", $directory)); + throw new InvalidArgumentException(sprintf("Cache directory is not writable: %s.", $directory)); } } } diff --git a/src/Infrastructure/Attribute/ClassAttributeResolver.php b/src/Infrastructure/Attribute/ClassAttributeResolver.php index 65add85..6d7b951 100644 --- a/src/Infrastructure/Attribute/ClassAttributeResolver.php +++ b/src/Infrastructure/Attribute/ClassAttributeResolver.php @@ -5,6 +5,7 @@ namespace App\Infrastructure\Attribute; use App\Infrastructure\Environment\Settings; +use ReflectionClass; use Symfony\Component\Finder\Finder; class ClassAttributeResolver @@ -64,7 +65,7 @@ private function searchForClasses( $class, ); - if (!(new \ReflectionClass($class))->getAttributes($attributeClassName)) { + if (!(new ReflectionClass($class))->getAttributes($attributeClassName)) { // Class is not tagged with attribute. continue; } diff --git a/src/Infrastructure/Console/ConsoleCommandContainer.php b/src/Infrastructure/Console/ConsoleCommandContainer.php index 536e63b..febe1d9 100644 --- a/src/Infrastructure/Console/ConsoleCommandContainer.php +++ b/src/Infrastructure/Console/ConsoleCommandContainer.php @@ -4,6 +4,7 @@ namespace App\Infrastructure\Console; +use RuntimeException; use Symfony\Component\Console\Command\Command; class ConsoleCommandContainer @@ -14,11 +15,11 @@ class ConsoleCommandContainer public function registerCommand(Command $command): void { if (empty($command->getName())) { - throw new \RuntimeException("Command name cannot be empty"); + throw new RuntimeException("Command name cannot be empty"); } if (array_key_exists($command->getName(), $this->getCommands())) { - throw new \RuntimeException(sprintf('Command "%s" already registered in container', $command->getName())); + throw new RuntimeException(sprintf('Command "%s" already registered in container', $command->getName())); } $this->consoleCommands[$command->getName()] = $command; } diff --git a/src/Infrastructure/DependencyInjection/ContainerBuilder.php b/src/Infrastructure/DependencyInjection/ContainerBuilder.php index 2f3bbe0..643d8f4 100644 --- a/src/Infrastructure/DependencyInjection/ContainerBuilder.php +++ b/src/Infrastructure/DependencyInjection/ContainerBuilder.php @@ -10,6 +10,7 @@ use DI\Container; use DI\Definition\Helper\AutowireDefinitionHelper; use DI\Definition\Source\DefinitionSource; +use RuntimeException; class ContainerBuilder { @@ -66,7 +67,7 @@ public function addCompilerPasses(CompilerPass ...$compilerPasses): self public function addCompilerPass(CompilerPass $pass): self { if (array_key_exists($pass::class, $this->passes)) { - throw new \RuntimeException(sprintf("CompilerPass %s already added. Cannot add the same pass twice", $pass::class)); + throw new RuntimeException(sprintf("CompilerPass %s already added. Cannot add the same pass twice", $pass::class)); } $this->passes[$pass::class] = $pass; diff --git a/src/Infrastructure/Events/DomainEvent.php b/src/Infrastructure/Events/DomainEvent.php new file mode 100644 index 0000000..dbe793e --- /dev/null +++ b/src/Infrastructure/Events/DomainEvent.php @@ -0,0 +1,47 @@ + */ + protected array $metadata = []; + + /** + * @param array $metadata + */ + public function setMetaData(array $metadata): void + { + $this->metadata = array_merge($this->metadata, $metadata); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + "eventName" => str_replace("\\", ".", static::class), + "payload" => $this->getSerializablePayload(), + ]; + } + + /** + * @return array + */ + protected function getSerializablePayload(): array + { + $serializedPayload = []; + + foreach ((new ReflectionClass($this))->getProperties() as $property) { + $serializedPayload[$property->getName()] = $property->getValue($this); + } + + return $serializedPayload; + } +} diff --git a/src/Infrastructure/Events/EventBus.php b/src/Infrastructure/Events/EventBus.php new file mode 100644 index 0000000..f43e39f --- /dev/null +++ b/src/Infrastructure/Events/EventBus.php @@ -0,0 +1,66 @@ + */ + private array $eventHandlers = []; + + public function dispatch(DomainEvent $event): void + { + $this->getHandlerForEvent($event)->handle($event); + } + + public function subscribeEventHandler(EventHandler $eventHandler): void + { + $this->guardThatFqcnEndsInEventHandler($eventHandler::class); + $this->guardThatThereIsACorrespondingEvent($eventHandler); + + $eventFqcn = str_replace(self::EVENT_HANDLER_SUFFIX, "", $eventHandler::class); + $this->eventHandlers[$eventFqcn] = $eventHandler; + } + + /** + * @return array + */ + public function getEventHandlers(): array + { + return $this->eventHandlers; + } + + private function getHandlerForEvent(DomainEvent $event): EventHandler + { + return $this->eventHandlers[$event::class] ?? + throw new \RuntimeException(sprintf('EventHandler for event "%s" not subscribed to this bus', $event::class)); + } + + private function guardThatFqcnEndsInEventHandler(string $fqcn): void + { + if (str_ends_with($fqcn, self::EVENT_HANDLER_SUFFIX)) { + return; + } + + throw new CanNotRegisterEventHandler(sprintf('Fqcn "%s" does not end with "EventHandler"', $fqcn)); + } + + private function guardThatThereIsACorrespondingEvent(EventHandler $eventHandler): void + { + $eventFqcn = str_replace(self::EVENT_HANDLER_SUFFIX, "", $eventHandler::class); + + if (!class_exists($eventFqcn)) { + throw new CanNotRegisterEventHandler(sprintf('No corresponding event for eventHandler "%s" found', $eventHandler::class)); + } + + if (str_ends_with($eventFqcn, "Event")) { + throw new CanNotRegisterEventHandler('Event name cannot end with "event"'); + } + } +} diff --git a/src/Infrastructure/Events/EventHandler/CanNotRegisterEventHandler.php b/src/Infrastructure/Events/EventHandler/CanNotRegisterEventHandler.php new file mode 100644 index 0000000..2205939 --- /dev/null +++ b/src/Infrastructure/Events/EventHandler/CanNotRegisterEventHandler.php @@ -0,0 +1,11 @@ +findDefinition(EventBus::class); + + foreach ($container->findTaggedWithClassAttribute(AsEventHandler::class) as $class) { + $definition->method("subscribeEventHandler", \DI\autowire($class)); + } + + $container->addDefinitions( + [EventBus::class => $definition], + ); + } +} diff --git a/src/Infrastructure/Events/EventQueue.php b/src/Infrastructure/Events/EventQueue.php new file mode 100644 index 0000000..da4fd8b --- /dev/null +++ b/src/Infrastructure/Events/EventQueue.php @@ -0,0 +1,48 @@ +eventQueueWorker; + } + + public function queue(Envelope $envelope): void + { + if (!$envelope instanceof DomainEvent) { + throw new \RuntimeException(sprintf('Queue "%s" requires a event to be queued, %s given', $this->getName(), $envelope::class)); + } + + parent::queue($envelope); + } + + public function queueBatch(array $envelopes): void + { + foreach ($envelopes as $envelope) { + if ($envelope instanceof DomainEvent) { + continue; + } + + throw new RuntimeException(sprintf('Queue "%s" requires a event to be queued, %s given', $this->getName(), $envelope::class)); + } + + parent::queueBatch($envelopes); + } +} diff --git a/src/Infrastructure/Events/EventQueueWorker.php b/src/Infrastructure/Events/EventQueueWorker.php new file mode 100644 index 0000000..ad5476f --- /dev/null +++ b/src/Infrastructure/Events/EventQueueWorker.php @@ -0,0 +1,48 @@ +eventBus->dispatch($event); + } + + public function processFailure(Envelope $envelope, AMQPMessage $message, Throwable $exception, Queue $queue): void + { + /** @var DomainEvent $event */ + $event = $envelope; + $event->setMetaData([ + "exceptionMessage" => $exception->getMessage(), + "traceAsString" => $exception->getTraceAsString(), + ]); + + $this->failedQueueFactory->buildFor($queue)->queue($event); + } +} diff --git a/src/Infrastructure/Persistence/Queues/UserEventQueue.php b/src/Infrastructure/Persistence/Queues/UserEventQueue.php new file mode 100644 index 0000000..a84d7ae --- /dev/null +++ b/src/Infrastructure/Persistence/Queues/UserEventQueue.php @@ -0,0 +1,13 @@ +getApp(); + $container = $app->getContainer(); + $logger = $container->get(LoggerInterface::class); + + $testAction = new class($logger) extends Action { + public function __construct( + LoggerInterface $loggerInterface, + ) { + parent::__construct($loggerInterface); + } + + public function action(): Response + { + return $this->respondWithJson( + [ + "willBeDoneAt" => (new DateTimeImmutable())->format(DateTimeImmutable::ATOM), + ], + 202, + ); + } + }; + + $app->get("/test-action-response-code", $testAction); + $request = $this->createRequest("GET", "/test-action-response-code"); + $response = $app->handle($request); + + $this->assertEquals(202, $response->getStatusCode()); + $this->assertArrayHasKey( + "willBeDoneAt", + json_decode((string)$response->getBody(), true), + ); + } +} diff --git a/tests/Application/Actions/Docs/SwaggerUiActionTest.php b/tests/Application/Actions/Docs/SwaggerUiActionTest.php index b56846a..c86774b 100644 --- a/tests/Application/Actions/Docs/SwaggerUiActionTest.php +++ b/tests/Application/Actions/Docs/SwaggerUiActionTest.php @@ -8,7 +8,7 @@ class SwaggerUiActionTest extends TestCase { - public function testSwaggerUiAction(): void + public function testSwaggerUiActionSuccess(): void { $app = $this->getApp(); diff --git a/tests/Application/Console/AmqpConsumeConsoleCommandTest.php b/tests/Application/Console/AmqpConsumeConsoleCommandTest.php new file mode 100644 index 0000000..f748d02 --- /dev/null +++ b/tests/Application/Console/AmqpConsumeConsoleCommandTest.php @@ -0,0 +1,84 @@ +queueContainer = $this->createMock(QueueContainer::class); + $this->consumer = $this->createMock(Consumer::class); + + $this->amqpConsumeConsoleCommand = new AmqpConsumeConsoleCommand( + $this->queueContainer, + $this->consumer, + ); + } + + public function testExecute(): void + { + $queue = new TestQueue($this->createMock(AMQPChannelFactory::class)); + + $this->queueContainer + ->expects($this->once()) + ->method("getQueue") + ->with("test-queue") + ->willReturn($queue); + + $this->consumer + ->expects($this->once()) + ->method("consume") + ->with($queue); + + $command = $this->getCommandInApplication("app:amqp:consume"); + + $commandTester = new CommandTester($command); + $commandTester->execute([ + "command" => $command->getName(), + "queue" => "test-queue", + ]); + } + + public function testGetSubscribedSignals(): void + { + /** @var SignalableCommandInterface $command */ + $command = $this->getCommandInApplication("app:amqp:consume"); + $this->assertEquals([SIGTERM, SIGINT], $command->getSubscribedSignals()); + } + + public function testHandleSignal(): void + { + /** @var SignalableCommandInterface $command */ + $command = $this->getCommandInApplication("app:amqp:consume"); + + $this->consumer + ->expects($this->once()) + ->method("shutdown"); + + $command->handleSignal(1); + } + + protected function getConsoleCommand(): Command + { + return $this->amqpConsumeConsoleCommand; + } +} diff --git a/tests/Application/Console/CacheClearConsoleCommandTest.php b/tests/Application/Console/CacheClearConsoleCommandTest.php new file mode 100644 index 0000000..15f0da7 --- /dev/null +++ b/tests/Application/Console/CacheClearConsoleCommandTest.php @@ -0,0 +1,75 @@ +cacheDir = Settings::getAppRoot() . "/tests/Application/Console/cache"; + $this->settings = $this->createMock(Settings::class); + + $this->cacheClearCommand = new CacheClearConsoleCommand( + $this->settings, + ); + } + + protected function tearDown(): void + { + parent::tearDown(); + + @rmdir($this->cacheDir); + } + + public function testExecute(): void + { + @mkdir($this->cacheDir); + @mkdir($this->cacheDir . "/slim"); + file_put_contents($this->cacheDir . "/slim/cache.file", "contents"); + @mkdir($this->cacheDir . "/slim/sub-dir"); + + $matcher = $this->exactly(2); + $this->settings + ->expects($matcher) + ->method("get") + ->willReturnCallback(function (string $key) use ($matcher): void { + match ($matcher->numberOfInvocations()) { + 1 => $this->assertEquals($key, "doctrine.cache_dir"), + 2 => $this->assertEquals($key, "slim.cache_dir"), + }; + }) + ->willReturnOnConsecutiveCalls( + $this->cacheDir . "/doctrine", + $this->cacheDir . "/slim", + ); + + $command = $this->getCommandInApplication("app:cache:clear"); + + $commandTester = new CommandTester($command); + $commandTester->execute([ + "command" => $command->getName(), + ]); + + $this->assertFalse(file_exists(Settings::getAppRoot() . "/tests/Console/cache/slim")); + } + + protected function getConsoleCommand(): Command + { + return $this->cacheClearCommand; + } +} diff --git a/tests/Application/Factory/UserFactoryTest.php b/tests/Application/Factory/UserFactoryTest.php new file mode 100644 index 0000000..a467688 --- /dev/null +++ b/tests/Application/Factory/UserFactoryTest.php @@ -0,0 +1,32 @@ + "Janusz123", + "firstName" => "Janusz", + "lastName" => "Borowy", + ]]]; + } + + /** + * @dataProvider userData + */ + public function testCreateSuccess(array $userData): void + { + $user = UserFactory::createFromRequest($userData); + + $this->assertEquals("Janusz", $user->firstName()); + $this->assertEquals("Borowy", $user->lastName()); + $this->assertEquals("janusz123", $user->username()); + } +} diff --git a/tests/Application/Validator/UserValidatorTest.php b/tests/Application/Validator/UserValidatorTest.php new file mode 100644 index 0000000..d12064a --- /dev/null +++ b/tests/Application/Validator/UserValidatorTest.php @@ -0,0 +1,69 @@ +createRequest( + "POST", + "", + [], + [], + [], + '{ + "username": "Janusz123", + "firstName": "Janusz", + "lastName": "Borowy" + }', + ); + $handler = $this->prophesize(RequestHandlerInterface::class); + $handler->handle($request)->shouldBeCalledOnce(); + + $validator->process($request, $handler->reveal()); + + $this->assertTrue($validator->isValid()); + } + + public function testProcessThrowsValidationException(): void + { + $validator = new UserValidator(); + + try { + $request = $this->createRequest( + "POST", + "", + [], + [], + [], + '{ + "firstName": "Janusz", + "lastName": "Borowy" + }', + ); + $handler = $this->prophesize(RequestHandlerInterface::class); + $handler->handle($request)->shouldNotBeCalled(); + + $validator->process($request, $handler->reveal()); + } catch (Throwable $exception) { + $this->assertInstanceOf(ValidationException::class, $exception); + + $this->assertEquals( + ["username" => "null must be a string, null must not be empty"], + $exception->errors()[0]->jsonSerialize(), + ); + $this->assertEquals(1, count($validator->getErrors())); + } + } +} diff --git a/tests/ConsoleCommandTestCase.php b/tests/ConsoleCommandTestCase.php new file mode 100644 index 0000000..3fd3983 --- /dev/null +++ b/tests/ConsoleCommandTestCase.php @@ -0,0 +1,63 @@ +input = $this->createMock(InputInterface::class); + $this->output = $this->createMock(OutputInterface::class); + $this->application = new Application(); + } + + public function testItShouldBeInConsoleCommandContainer(): void + { + /** @var ConsoleCommandContainer $consoleCommandContainer */ + $consoleCommandContainer = $this->getContainer()->get(ConsoleCommandContainer::class); + + $this->assertNotEmpty(array_filter( + $consoleCommandContainer->getCommands(), + fn(Command $command) => $command::class === $this->getConsoleCommand()::class, + ), sprintf('ConsoleCommand "%s" not found in ConsoleCommandContainer. Did you tag it with an attribute?', $this->getConsoleCommand()::class)); + } + + public function getCommandInApplication(string $name, array $helpers = []): Command + { + $this->application->add($this->getConsoleCommand()); + $command = $this->application->find($name); + + foreach ($helpers as $alias => $helper) { + $command->getHelperSet()->set($helper, $alias); + } + + return $command; + } + + public function getInput() + { + return $this->input; + } + + public function getOutput() + { + return $this->output; + } + + abstract protected function getConsoleCommand(): Command; +} diff --git a/tests/ContainerTestCase.php b/tests/ContainerTestCase.php new file mode 100644 index 0000000..f8da675 --- /dev/null +++ b/tests/ContainerTestCase.php @@ -0,0 +1,34 @@ +bootContainer(); + } + + public function bootContainer(): ContainerInterface + { + if (!self::$container) { + self::$container = ContainerFactory::createForTestSuite(); + } + + return self::$container; + } + + public function getContainer(): ContainerInterface + { + return self::$container; + } +} diff --git a/tests/Domain/Service/UserServiceTest.php b/tests/Domain/Service/UserServiceTest.php index 41eb496..4b31428 100644 --- a/tests/Domain/Service/UserServiceTest.php +++ b/tests/Domain/Service/UserServiceTest.php @@ -8,7 +8,8 @@ use App\Domain\Entity\User\User; use App\Domain\Entity\User\UserRepositoryInterface; use App\Domain\Entity\User\UsersCollection; -use App\Domain\Service\UserService; +use App\Domain\Service\User\UserEventsService; +use App\Domain\Service\User\UserService; use Tests\TestCase; class UserServiceTest extends TestCase @@ -39,12 +40,18 @@ public function testGetAllUsersSuccess(User $user): void UserRepositoryInterface::class, ); + $userEventServiceMock = $this->createMock(UserEventsService::class); + $userEventServiceMock + ->expects($this->never()) + ->method("userWasCreated"); + $userRepositoryProphecy->findAll() ->willReturn($usersCollection) ->shouldBeCalledOnce(); $userService = new UserService( $userRepositoryProphecy->reveal(), + $userEventServiceMock, ); $users = $userService->getAllUsers(); @@ -61,12 +68,18 @@ public function testGetUserByIdSuccess(User $user): void UserRepositoryInterface::class, ); + $userEventServiceMock = $this->createMock(UserEventsService::class); + $userEventServiceMock + ->expects($this->never()) + ->method("userWasCreated"); + $userRepositoryProphecy->findUserOfId(1) ->willReturn($user) ->shouldBeCalledOnce(); $userService = new UserService( $userRepositoryProphecy->reveal(), + $userEventServiceMock, ); $user = $userService->getUserById(1); @@ -82,12 +95,18 @@ public function testGetAllUsersThrowsUserNotFoundException(): void UserRepositoryInterface::class, ); + $userEventServiceMock = $this->createMock(UserEventsService::class); + $userEventServiceMock + ->expects($this->never()) + ->method("userWasCreated"); + $userRepositoryProphecy->findAll() ->willThrow(UserNotFoundException::class) ->shouldBeCalledOnce(); $userService = new UserService( $userRepositoryProphecy->reveal(), + $userEventServiceMock, ); $this->expectException(UserNotFoundException::class); @@ -100,12 +119,18 @@ public function testGetUserByIdThrowsUserNotFoundException(): void UserRepositoryInterface::class, ); + $userEventServiceMock = $this->createMock(UserEventsService::class); + $userEventServiceMock + ->expects($this->never()) + ->method("userWasCreated"); + $userRepositoryProphecy->findUserOfId(1) ->willThrow(UserNotFoundException::class) ->shouldBeCalledOnce(); $userService = new UserService( $userRepositoryProphecy->reveal(), + $userEventServiceMock, ); $this->expectException(UserNotFoundException::class); diff --git a/tests/EventHandlerTestCase.php b/tests/EventHandlerTestCase.php new file mode 100644 index 0000000..3d34c0c --- /dev/null +++ b/tests/EventHandlerTestCase.php @@ -0,0 +1,23 @@ +getContainer()->get(EventBus::class); + + $this->assertNotEmpty(array_filter( + $commandBus->getEventHandlers(), + fn(EventBus $eventHandler) => $eventHandler::class === $this->getEventHandlers()::class, + ), sprintf('CommandHandler "%s" not found in CommandBus. Did you tag it with an attribute?', $this->getEventHandlers()::class)); + } + + abstract protected function getEventHandlers(): EventBus; +} diff --git a/tests/Infrastructure/AMQP/AMQPChannelFactoryTest.php b/tests/Infrastructure/AMQP/AMQPChannelFactoryTest.php new file mode 100644 index 0000000..f959271 --- /dev/null +++ b/tests/Infrastructure/AMQP/AMQPChannelFactoryTest.php @@ -0,0 +1,107 @@ +AMQPStreamConnectionFactory = $this->createMock(AMQPStreamConnectionFactory::class); + + $this->AMQPChannelFactory = new AMQPChannelFactory( + $this->AMQPStreamConnectionFactory, + ); + } + + public function testGetForQueueAndDefaultOptionsSuccess(): void + { + $queue = $this->createMock(Queue::class); + $queue + ->expects($this->any()) + ->method("getName") + ->willReturn("test-queue"); + + $connection = $this->createMock(AMQPStreamConnection::class); + $this->AMQPStreamConnectionFactory + ->expects($this->once()) + ->method("get") + ->willReturn($connection); + + $channel = $this->createMock(AMQPChannel::class); + $connection + ->expects($this->once()) + ->method("channel") + ->willReturn($channel); + + $channel + ->expects($this->once()) + ->method("queue_declare") + ->with($queue->getName(), false, true, false, false, false, [], null); + + $channel + ->expects($this->once()) + ->method("basic_qos") + ->with(null, 1, null); + + $this->assertEquals($channel, $this->AMQPChannelFactory->getForQueue($queue)); + // Call again to verify static cache. + $this->AMQPChannelFactory->getForQueue($queue); + } + + public function testGetForQueueWithNonDefaultOptionsSuccess(): void + { + $queue = $this->createMock(Queue::class); + $queue + ->expects($this->any()) + ->method("getName") + ->willReturn("test-queue"); + + $connection = $this->createMock(AMQPStreamConnection::class); + $this->AMQPStreamConnectionFactory + ->expects($this->once()) + ->method("get") + ->willReturn($connection); + + $channel = $this->createMock(AMQPChannel::class); + $connection + ->expects($this->once()) + ->method("channel") + ->willReturn($channel); + + $channel + ->expects($this->once()) + ->method("queue_declare") + ->with($queue->getName(), true, true, true, false, false, [1, 2, 3], 3); + + $channel + ->expects($this->once()) + ->method("basic_qos") + ->with(null, 1, null); + + $this->assertEquals($channel, $this->AMQPChannelFactory->getForQueue($queue, new AMQPChannelOptions( + true, + true, + true, + false, + false, + [1, 2, 3], + 3, + ))); + } +} diff --git a/tests/Infrastructure/AMQP/ConsumerTest.php b/tests/Infrastructure/AMQP/ConsumerTest.php new file mode 100644 index 0000000..f87f287 --- /dev/null +++ b/tests/Infrastructure/AMQP/ConsumerTest.php @@ -0,0 +1,254 @@ +AMQPStreamConnectionFactory = $this->createMock(AMQPStreamConnectionFactory::class); + $this->AMQPChannelFactory = $this->createMock(AMQPChannelFactory::class); + + $this->consumer = new Consumer( + $this->AMQPStreamConnectionFactory, + $this->AMQPChannelFactory, + ); + } + + public function testConsumeSuccess(): void + { + $queue = $this->createMock(Queue::class); + + $channel = $this->createMock(AMQPChannel::class); + $this->AMQPChannelFactory + ->expects($this->once()) + ->method("getForQueue") + ->with($queue) + ->willReturn($channel); + + $message = new AMQPMessage( + "message", + ["content_type" => "text/plain", "delivery_mode" => AMQPMessage::DELIVERY_MODE_PERSISTENT], + ); + $message->setChannel($channel); + $message->setDeliveryInfo("tag", false, null, null); + + $channel + ->expects($this->once()) + ->method("basic_consume") + ->with($queue->getName(), "", false, false, false, false) + ->willReturnCallback(function () use ($message, &$callbackCalled): void { + self::assertEquals("message", $message->getBody()); + $callbackCalled = true; + }); + + $matcher = $this->exactly(2); + $channel + ->expects($matcher) + ->method("is_open") + ->willReturnCallback(function () use ($matcher) { + if ($matcher->getInvocationCount() === 1) { + return true; + } + + $this->consumer->shutdown(); + + return true; + }); + + $channel + ->expects($this->never()) + ->method("close"); + + $this->AMQPStreamConnectionFactory + ->expects($this->never()) + ->method("get"); + + $this->consumer->consume($queue); + $this->assertTrue($callbackCalled); + } + + public function testConsumeOnWorkerMaxLifeTimeOrIterationsExceededSuccess(): void + { + $queue = $this->createMock(Queue::class); + + $channel = $this->createMock(AMQPChannel::class); + $this->AMQPChannelFactory + ->expects($this->once()) + ->method("getForQueue") + ->with($queue) + ->willReturn($channel); + + $message = new AMQPMessage( + "message", + ["content_type" => "text/plain", "delivery_mode" => AMQPMessage::DELIVERY_MODE_PERSISTENT], + ); + $message->setChannel($channel); + $message->setDeliveryInfo("tag", false, null, null); + + $channel + ->expects($this->once()) + ->method("basic_consume") + ->with($queue->getName(), "", false, false, false, false) + ->willReturnCallback(function () use ($message, &$callbackCalled): void { + self::assertEquals("message", $message->getBody()); + $callbackCalled = true; + + throw new WorkerMaxLifeTimeOrIterationsExceeded(); + }); + + $channel + ->expects($this->never()) + ->method("is_open"); + + $channel + ->expects($this->once()) + ->method("close"); + + $this->AMQPStreamConnectionFactory + ->expects($this->once()) + ->method("get"); + + $this->consumer->consume($queue); + $this->assertTrue($callbackCalled); + } + + public function testConsumeCallbackSuccess(): void + { + $envelope = new RunUnitTester(); + $channel = $this->createMock(AMQPChannel::class); + $queue = $this->createMock(Queue::class); + $worker = $this->createMock(Worker::class); + + $message = new AMQPMessage( + serialize($envelope), + ["content_type" => "text/plain", "delivery_mode" => AMQPMessage::DELIVERY_MODE_PERSISTENT], + ); + $message->setChannel($channel); + $message->setDeliveryInfo("tag", false, null, null); + + $queue + ->expects($this->once()) + ->method("getWorker") + ->willReturn($worker); + + $worker + ->expects($this->once()) + ->method("processMessage") + ->with($envelope, $message); + + $channel + ->expects($this->once()) + ->method("basic_ack") + ->with("tag"); + + Consumer::consumeCallback( + $message, + $queue, + ); + } + + public function testConsumeCallbackWorkerMaxLifeTimeOrIterationsExceeded(): void + { + $channel = $this->createMock(AMQPChannel::class); + $queue = $this->createMock(Queue::class); + $worker = $this->createMock(Worker::class); + + $message = new AMQPMessage( + serialize(new RunUnitTester()), + ["content_type" => "text/plain", "delivery_mode" => AMQPMessage::DELIVERY_MODE_PERSISTENT], + ); + $message->setChannel($channel); + $message->setDeliveryInfo("tag", false, null, null); + + $queue + ->expects($this->once()) + ->method("getWorker") + ->willReturn($worker); + + $worker + ->expects($this->once()) + ->method("processMessage") + ->willThrowException(new WorkerMaxLifeTimeOrIterationsExceeded()); + + $channel + ->expects($this->once()) + ->method("basic_nack") + ->with("tag", false, true); + + $channel + ->expects($this->never()) + ->method("basic_ack"); + + $this->expectException(WorkerMaxLifeTimeOrIterationsExceeded::class); + + Consumer::consumeCallback( + $message, + $queue, + ); + } + + public function testConsumeCallbackOnException(): void + { + $envelope = new RunUnitTester(); + $channel = $this->createMock(AMQPChannel::class); + $queue = $this->createMock(Queue::class); + $worker = $this->createMock(Worker::class); + + $message = new AMQPMessage( + serialize($envelope), + ["content_type" => "text/plain", "delivery_mode" => AMQPMessage::DELIVERY_MODE_PERSISTENT], + ); + $message->setChannel($channel); + $message->setDeliveryInfo("tag", false, null, null); + + $queue + ->expects($this->once()) + ->method("getWorker") + ->willReturn($worker); + + $exception = new \RuntimeException(); + $worker + ->expects($this->once()) + ->method("processMessage") + ->willThrowException($exception); + + $worker + ->expects($this->once()) + ->method("processFailure") + ->with($envelope, $message, $exception, $queue); + + $channel + ->expects($this->once()) + ->method("basic_ack") + ->with("tag"); + + Consumer::consumeCallback( + $message, + $queue, + ); + } +} diff --git a/tests/Infrastructure/AMQP/Queue/DelayedQueue/DelayedQueueFactoryTest.php b/tests/Infrastructure/AMQP/Queue/DelayedQueue/DelayedQueueFactoryTest.php new file mode 100644 index 0000000..2f6fe15 --- /dev/null +++ b/tests/Infrastructure/AMQP/Queue/DelayedQueue/DelayedQueueFactoryTest.php @@ -0,0 +1,43 @@ +AMQPChannelFactory = $this->createMock(AMQPChannelFactory::class); + + $this->delayedQueueFactory = new DelayedQueueFactory( + $this->AMQPChannelFactory, + ); + } + + public function testBuildWithDelayForQueueSuccess(): void + { + $this->assertInstanceOf( + DelayedQueue::class, + $this->delayedQueueFactory->buildWithDelayForQueue(10, new TestQueue($this->AMQPChannelFactory)), + ); + + $this->assertEquals(new DelayedQueue( + new TestQueue($this->AMQPChannelFactory), + 60, + $this->AMQPChannelFactory, + ), $this->delayedQueueFactory->buildWithDelayForQueue(60, new TestQueue($this->AMQPChannelFactory))); + } +} diff --git a/tests/Infrastructure/AMQP/Queue/DelayedQueue/DelayedQueueTest.php b/tests/Infrastructure/AMQP/Queue/DelayedQueue/DelayedQueueTest.php new file mode 100644 index 0000000..133bebf --- /dev/null +++ b/tests/Infrastructure/AMQP/Queue/DelayedQueue/DelayedQueueTest.php @@ -0,0 +1,88 @@ +AMQPChannelFactory = $this->createMock(AMQPChannelFactory::class); + } + + public function testGetNameSuccess(): void + { + $delayedQueue = new DelayedQueue( + new TestQueue($this->AMQPChannelFactory), + 10, + $this->AMQPChannelFactory, + ); + + $this->assertEquals("delayed-10s-test-queue", $delayedQueue->getName()); + $this->assertEquals(0, $delayedQueue->getNumberOfConsumers()); + } + + public function testQueueSuccess(): void + { + $delayedQueue = new DelayedQueue( + new TestQueue($this->AMQPChannelFactory), + 10, + $this->AMQPChannelFactory, + ); + + $options = new AMQPChannelOptions(false, true, false, false, false, [ + "x-dead-letter-exchange" => ["S", "dlx"], + "x-dead-letter-routing-key" => ["S", "test-queue"], + "x-message-ttl" => ["I", 10000], + "x-expires" => ["I", 10000 + 100000], // Keep the Q for 100s after the last message, + ]); + + $this->AMQPChannelFactory + ->expects($this->once()) + ->method("getForQueue") + ->with($delayedQueue, $options); + + $delayedQueue->queue(new RunUnitTester()); + } + + public function testGetWorkerSuccess(): void + { + $delayedQueue = new DelayedQueue( + new TestQueue($this->AMQPChannelFactory), + 10, + $this->AMQPChannelFactory, + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Delayed queues do not have workers"); + + $delayedQueue->getWorker(); + } + + public function testItShouldThrowWhenInvalidSeconds(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Delay cannot be less than 1 second"); + + new DelayedQueue( + new TestQueue($this->AMQPChannelFactory), + 0, + $this->AMQPChannelFactory, + ); + } +} diff --git a/tests/Infrastructure/AMQP/Queue/FailedQueue/FailedQueueFactoryTest.php b/tests/Infrastructure/AMQP/Queue/FailedQueue/FailedQueueFactoryTest.php new file mode 100644 index 0000000..77cd58a --- /dev/null +++ b/tests/Infrastructure/AMQP/Queue/FailedQueue/FailedQueueFactoryTest.php @@ -0,0 +1,40 @@ +AMQPChannelFactory = $this->createMock(AMQPChannelFactory::class); + + $this->failedQueueFactory = new FailedQueueFactory( + $this->AMQPChannelFactory, + ); + } + + public function testBuildFor(): void + { + $queue = new TestQueue($this->AMQPChannelFactory); + $expectedFailedQueue = new FailedQueue($queue, $this->AMQPChannelFactory); + + $this->assertEquals( + $expectedFailedQueue, + $this->failedQueueFactory->buildFor($queue), + ); + } +} diff --git a/tests/Infrastructure/AMQP/Queue/FailedQueue/FailedQueueTest.php b/tests/Infrastructure/AMQP/Queue/FailedQueue/FailedQueueTest.php new file mode 100644 index 0000000..dc8f1ed --- /dev/null +++ b/tests/Infrastructure/AMQP/Queue/FailedQueue/FailedQueueTest.php @@ -0,0 +1,44 @@ +createMock(AMQPChannelFactory::class); + $this->testQueue = new TestQueue($AMQPChannelFactory); + + $this->failedQueue = new FailedQueue( + $this->testQueue, + $AMQPChannelFactory, + ); + } + + public function testGetNameSuccess(): void + { + $this->assertEquals("test-queue-failed", $this->failedQueue->getName()); + $this->assertEquals(0, $this->failedQueue->getNumberOfConsumers()); + } + + public function testGetWorkerSuccess(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Failed queues do not have workers"); + + $this->failedQueue->getWorker(); + } +} diff --git a/tests/Infrastructure/AMQP/Queue/QueueContainerTest.php b/tests/Infrastructure/AMQP/Queue/QueueContainerTest.php new file mode 100644 index 0000000..e411f17 --- /dev/null +++ b/tests/Infrastructure/AMQP/Queue/QueueContainerTest.php @@ -0,0 +1,54 @@ +queueContainer = $this->getContainer()->get(QueueContainer::class); + } + + public function testItRegistersAllQueuesSuccess(): void + { + $this->assertMatchesJsonSnapshot(array_map(function (Queue $queue) { + return [ + "queueName" => $queue->getName(), + "workerName" => $queue->getWorker()->getName(), + "numberOfConsumers" => $queue->getNumberOfConsumers(), + ]; + }, $this->queueContainer->getQueues())); + } + + public function testGetQueueSuccess(): void + { + $queue = new TestQueue($this->createMock(AMQPChannelFactory::class)); + $this->queueContainer->registerQueue($queue); + + $this->assertEquals( + $queue, + $this->queueContainer->getQueue("test-queue"), + ); + } + + public function testItShouldThrowWhenGettingInvalidQueueName(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Queue "random-queue" not registered in container'); + $this->queueContainer->getQueue("random-queue"); + } +} diff --git a/tests/Infrastructure/AMQP/Queue/QueueTest.php b/tests/Infrastructure/AMQP/Queue/QueueTest.php new file mode 100644 index 0000000..5237934 --- /dev/null +++ b/tests/Infrastructure/AMQP/Queue/QueueTest.php @@ -0,0 +1,136 @@ +AMQPChannelFactory = $this->createMock(AMQPChannelFactory::class); + + $this->testQueue = new TestQueue( + $this->AMQPChannelFactory, + ); + } + + public function testQueueSuccess(): void + { + $envelope = new RunUnitTester(); + + $channel = $this->createMock(AMQPChannel::class); + $this->AMQPChannelFactory + ->expects($this->once()) + ->method("getForQueue") + ->with($this->testQueue, null) + ->willReturn($channel); + + $properties = ["content_type" => "text/plain", "delivery_mode" => AMQPMessage::DELIVERY_MODE_PERSISTENT]; + $message = new AMQPMessage(serialize($envelope), $properties); + + $channel + ->expects($this->once()) + ->method("basic_publish") + ->with($message, null, $this->testQueue->getName()); + + $this->testQueue->queue($envelope); + } + + public function testQueueBatchSuccess(): void + { + $envelope = new RunUnitTester(); + + $channel = $this->createMock(AMQPChannel::class); + $this->AMQPChannelFactory + ->expects($this->once()) + ->method("getForQueue") + ->with($this->testQueue, null) + ->willReturn($channel); + + $properties = ["content_type" => "text/plain", "delivery_mode" => AMQPMessage::DELIVERY_MODE_PERSISTENT]; + $message = new AMQPMessage(serialize($envelope), $properties); + + $channel + ->expects($this->exactly(2)) + ->method("batch_basic_publish") + ->with($message, null, $this->testQueue->getName()); + + $channel + ->expects($this->once()) + ->method("publish_batch"); + + $this->testQueue->queueBatch([$envelope, $envelope]); + } + + public function testQueueBatchWhenEmpty(): void + { + $channel = $this->createMock(AMQPChannel::class); + $this->AMQPChannelFactory + ->expects($this->never()) + ->method("getForQueue"); + + $channel + ->expects($this->never()) + ->method("batch_basic_publish"); + + $channel + ->expects($this->never()) + ->method("publish_batch"); + + $this->testQueue->queueBatch([]); + } + + public function testQueueBatchItShouldThrowWhenInvalidEnvelope(): void + { + $channel = $this->createMock(AMQPChannel::class); + $this->AMQPChannelFactory + ->expects($this->never()) + ->method("getForQueue"); + + $channel + ->expects($this->never()) + ->method("batch_basic_publish"); + + $channel + ->expects($this->never()) + ->method("publish_batch"); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('All envelopes need to implement App\Infrastructure\AMQP\Envelope'); + + /** @phpstan-ignore-next-line */ + $this->testQueue->queueBatch(["test"]); + } + + public function testGetNameItShouldThrowWhenNoAttribute(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("AsAmqpQueue attribute not set"); + + $queue = new TestQueueWithoutAttribute($this->createMock(AMQPChannelFactory::class)); + $queue->getName(); + } + + public function testGetNumberOfWorkersItShouldThrowWhenNoAttribute(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("AsAmqpQueue attribute not set"); + + $queue = new TestQueueWithoutAttribute($this->createMock(AMQPChannelFactory::class)); + $queue->getNumberOfConsumers(); + } +} diff --git a/tests/Infrastructure/AMQP/Queue/TestQueue.php b/tests/Infrastructure/AMQP/Queue/TestQueue.php new file mode 100644 index 0000000..94459b1 --- /dev/null +++ b/tests/Infrastructure/AMQP/Queue/TestQueue.php @@ -0,0 +1,21 @@ +testWorker = new TestWorker(PausedClock::on(new DateTimeImmutable("2022-07-01"))); + } + + public function testGetters(): void + { + $this->assertEquals(1000, $this->testWorker->getMaxIterations()); + $this->assertEquals( + (new DateTimeImmutable("2022-07-01"))->add(new DateInterval("PT1H")), + $this->testWorker->getMaxLifeTime(), + ); + } + + public function testMaxIterationsReached(): void + { + $this->assertFalse($this->testWorker->maxIterationsReached()); + + for ($i = 0; $i < 998; ++$i) { + $this->testWorker->maxIterationsReached(); + } + $this->assertFalse($this->testWorker->maxIterationsReached()); + $this->assertTrue($this->testWorker->maxIterationsReached()); + } +} diff --git a/tests/Infrastructure/Attribute/ClassAttributeCacheTest.php b/tests/Infrastructure/Attribute/ClassAttributeCacheTest.php new file mode 100644 index 0000000..525a118 --- /dev/null +++ b/tests/Infrastructure/Attribute/ClassAttributeCacheTest.php @@ -0,0 +1,61 @@ +dir = Settings::getAppRoot() . "/tests/Infrastructure/Attribute/cache"; + @unlink($this->dir . "/AsAmqpQueue.php"); + @rmdir($this->dir); + + $this->classAttributeCache = new ClassAttributeCache( + AsAmqpQueue::class, + $this->dir, + ); + } + + protected function tearDown(): void + { + parent::tearDown(); + + @unlink($this->dir . "/AsAmqpQueue.php"); + @rmdir($this->dir); + } + + public function testGetItShouldThrowIfNotExists(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage("Cache not set for AsAmqpQueue"); + + $this->classAttributeCache->get(); + } + + public function testCompileSuccess(): void + { + $this->assertFalse($this->classAttributeCache->exists()); + $this->classAttributeCache->compile(["classOne", "classTwo"]); + $this->classAttributeCache->compile(["classOne", "classTwo"]); + $this->assertTrue($this->classAttributeCache->exists()); + + $this->assertStringContainsString("tests/Infrastructure/Attribute/cache/AsAmqpQueue.php", $this->classAttributeCache->get()); + $this->assertMatchesJsonSnapshot(Json::encode(require $this->classAttributeCache->get())); + } +} diff --git a/tests/Infrastructure/Attribute/ClassAttributeResolverTest.php b/tests/Infrastructure/Attribute/ClassAttributeResolverTest.php new file mode 100644 index 0000000..8e6baba --- /dev/null +++ b/tests/Infrastructure/Attribute/ClassAttributeResolverTest.php @@ -0,0 +1,63 @@ +dir = Settings::getAppRoot() . "/tests/Infrastructure/Attribute/cache"; + @unlink($this->dir . "/AsCommand.php"); + @rmdir($this->dir); + } + + protected function tearDown(): void + { + parent::tearDown(); + + @unlink($this->dir . "/AsCommand.php"); + @rmdir($this->dir); + } + + public function testResolveSuccess(): void + { + $resolver = new ClassAttributeResolver(); + $classes = $resolver->resolve(AsCommand::class, ["src/Application/Console"]); + sort($classes); + + $this->assertMatchesJsonSnapshot($classes); + } + + public function testResolveWithCacheSuccess(): void + { + $resolver = new ClassAttributeResolver(); + $classes = $resolver->resolve( + AsCommand::class, + ["src/Application/Console"], + $this->dir, + ); + $this->assertEquals($classes, $resolver->resolve( + AsCommand::class, + ["src/Application/console"], + $this->dir, + )); + sort($classes); + + $this->assertFileExists($this->dir . "/AsCommand.php"); + $this->assertMatchesJsonSnapshot($classes); + } +} diff --git a/tests/Infrastructure/Attribute/__snapshots__/ClassAttributeCacheTest__testCompile__1.json b/tests/Infrastructure/Attribute/__snapshots__/ClassAttributeCacheTest__testCompile__1.json new file mode 100644 index 0000000..4138bfa --- /dev/null +++ b/tests/Infrastructure/Attribute/__snapshots__/ClassAttributeCacheTest__testCompile__1.json @@ -0,0 +1,4 @@ +[ + "classOne", + "classTwo" +] diff --git a/tests/Infrastructure/Attribute/__snapshots__/ClassAttributeResolverTest__testResolveWithCache__1.json b/tests/Infrastructure/Attribute/__snapshots__/ClassAttributeResolverTest__testResolveWithCache__1.json new file mode 100644 index 0000000..77a3a48 --- /dev/null +++ b/tests/Infrastructure/Attribute/__snapshots__/ClassAttributeResolverTest__testResolveWithCache__1.json @@ -0,0 +1,5 @@ +[ + "App\\Application\\Console\\AmqpConsumeConsoleCommand", + "App\\Application\\Console\\CacheClearConsoleCommand", + "App\\Application\\Console\\DataFixturesCommand" +] diff --git a/tests/Infrastructure/Attribute/__snapshots__/ClassAttributeResolverTest__testResolve__1.json b/tests/Infrastructure/Attribute/__snapshots__/ClassAttributeResolverTest__testResolve__1.json new file mode 100644 index 0000000..1b13c7d --- /dev/null +++ b/tests/Infrastructure/Attribute/__snapshots__/ClassAttributeResolverTest__testResolve__1.json @@ -0,0 +1,5 @@ +[ + "App\\Application\\Console\\AmqpConsumeConsoleCommand", + "App\\Application\\Console\\CacheClearConsoleCommand", + "App\\Application\\Console\\DataFixturesCommand" +] diff --git a/tests/Infrastructure/Console/ConsoleCommandCompilerPassTest.php b/tests/Infrastructure/Console/ConsoleCommandCompilerPassTest.php new file mode 100644 index 0000000..845a35f --- /dev/null +++ b/tests/Infrastructure/Console/ConsoleCommandCompilerPassTest.php @@ -0,0 +1,47 @@ +createMock(ContainerBuilder::class); + $definition = $this->createMock(AutowireDefinitionHelper::class); + + $containerBuilder + ->expects($this->once()) + ->method("findDefinition") + ->with(ConsoleCommandContainer::class) + ->willReturn($definition); + + $containerBuilder + ->expects($this->once()) + ->method("findTaggedWithClassAttribute") + ->with(AsCommand::class) + ->willReturn([Command::class]); + + $definition + ->expects($this->once()) + ->method("method") + ->with("registerCommand", \DI\autowire(Command::class)); + + $containerBuilder + ->expects($this->once()) + ->method("addDefinitions") + ->with([ConsoleCommandContainer::class => $definition]); + + $compilerPass = new ConsoleCommandCompilerPass(); + $compilerPass->process($containerBuilder); + } +} diff --git a/tests/Infrastructure/Console/ConsoleCommandContainerTest.php b/tests/Infrastructure/Console/ConsoleCommandContainerTest.php new file mode 100644 index 0000000..fc8951b --- /dev/null +++ b/tests/Infrastructure/Console/ConsoleCommandContainerTest.php @@ -0,0 +1,49 @@ +consoleCommandContainer = $this->getContainer()->get(ConsoleCommandContainer::class); + } + + public function testItRegistersAllCommandSuccess(): void + { + $commands = array_keys($this->consoleCommandContainer->getCommands()); + sort($commands); + $this->assertMatchesJsonSnapshot(Json::encode($commands)); + } + + public function testItShouldThrowWhenEmptyName(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Command name cannot be empty"); + + $this->consoleCommandContainer->registerCommand(new TestConsoleCommandNoName()); + } + + public function testItShouldThrowWhenDuplicateCommand(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Command "test:command" already registered in container'); + + $this->consoleCommandContainer->registerCommand(new TestConsoleCommand()); + $this->consoleCommandContainer->registerCommand(new TestConsoleCommand()); + } +} diff --git a/tests/Infrastructure/Console/TestConsoleCommand.php b/tests/Infrastructure/Console/TestConsoleCommand.php new file mode 100644 index 0000000..35dab32 --- /dev/null +++ b/tests/Infrastructure/Console/TestConsoleCommand.php @@ -0,0 +1,13 @@ +DIContainerBuilder = $this->createMock(\DI\ContainerBuilder::class); + $this->classAttributeResolver = $this->createMock(ClassAttributeResolver::class); + + $this->containerBuilder = new ContainerBuilder( + $this->DIContainerBuilder, + $this->classAttributeResolver, + ); + } + + public function testBuildSuccess(): void + { + $compilerPass = $this->createMock(CompilerPass::class); + + $matcher = $this->exactly(2); + $this->DIContainerBuilder + ->expects($matcher) + ->method("addDefinitions") + ->willReturnCallback(function (string $key) use ($matcher): void { + match ($matcher->getInvocationCount()) { + 1 => $this->assertEquals($key, "definition"), + 2 => $this->assertEquals($key, []), + }; + }) + ->willReturn($this->createMock(\DI\ContainerBuilder::class)); + + $this->DIContainerBuilder + ->expects($this->once()) + ->method("enableCompilation") + ->with( + "dir", + "CompiledContainer", + CompiledContainer::class, + ); + + $compilerPass + ->expects($this->once()) + ->method("process") + ->with($this->containerBuilder); + + $this->DIContainerBuilder + ->expects($this->once()) + ->method("isCompilationEnabled") + ->willReturn(true); + + $this->DIContainerBuilder + ->expects($this->once()) + ->method("build") + ->willReturn($this->createMock(Container::class)); + + $this->containerBuilder + ->enableCompilation("dir") + ->enableClassAttributeCache("dir") + ->addDefinitions("definition") + ->addCompilerPasses($compilerPass) + ->build(); + } + + public function testBuildWithoutCache(): void + { + $compilerPass = $this->createMock(CompilerPass::class); + + $this->DIContainerBuilder + ->expects($this->once()) + ->method("addDefinitions") + ->with("definition"); + + $compilerPass + ->expects($this->once()) + ->method("process") + ->with($this->containerBuilder); + + $this->DIContainerBuilder + ->expects($this->once()) + ->method("isCompilationEnabled") + ->willReturn(false); + + $this->DIContainerBuilder + ->expects($this->once()) + ->method("build") + ->willReturn($this->createMock(Container::class)); + + $this->containerBuilder + ->addDefinitions("definition") + ->addCompilerPasses($compilerPass) + ->build(); + } + + public function testItShouldThrowOnDuplicateCompilerPasses(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage("CompilerPass CompilerPassOne already added. Cannot add the same pass twice"); + + $compilerPass = $this->getMockBuilder(CompilerPass::class) + ->disableOriginalConstructor() + ->disableOriginalClone() + ->disableArgumentCloning() + ->disallowMockingUnknownTypes() + ->setMockClassName("CompilerPassOne") + ->getMock(); + + $this->containerBuilder->addCompilerPasses( + $compilerPass, + $compilerPass, + ); + } +} diff --git a/tests/Infrastructure/Environment/SettingsTest.php b/tests/Infrastructure/Environment/SettingsTest.php new file mode 100644 index 0000000..e63f05a --- /dev/null +++ b/tests/Infrastructure/Environment/SettingsTest.php @@ -0,0 +1,29 @@ + ["key2" => ["key3" => "value"]]]); + + $this->assertEquals(["key2" => ["key3" => "value"]], $settings->get("key")); + $this->assertEquals(["key3" => "value"], $settings->get("key.key2")); + $this->assertEquals("value", $settings->get("key.key2.key3")); + } + + public function testGetItShouldThrowWhenInvalid(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Trying to fetch invalid setting "key.key2.key3"'); + + $settings = new Settings([]); + $this->assertEquals("value", $settings->get("key.key2.key3")); + } +} diff --git a/tests/Infrastructure/Events/DomainEventTest.php b/tests/Infrastructure/Events/DomainEventTest.php new file mode 100644 index 0000000..f6eb2f1 --- /dev/null +++ b/tests/Infrastructure/Events/DomainEventTest.php @@ -0,0 +1,24 @@ +setMetaData(["key" => "value"]); + $this->assertMatchesJsonSnapshot(Json::encode($command)); + $command->setMetaData(["key" => "value override", "key2" => "value"]); + $this->assertMatchesJsonSnapshot(Json::encode($command)); + } +} diff --git a/tests/Infrastructure/Events/EventBusTest.php b/tests/Infrastructure/Events/EventBusTest.php new file mode 100644 index 0000000..0480a29 --- /dev/null +++ b/tests/Infrastructure/Events/EventBusTest.php @@ -0,0 +1,73 @@ +eventBus = $this->getContainer()->get(EventBus::class); + } + + public function testItRegistersAllEventSuccess(): void + { + $events = array_keys($this->eventBus->getEventHandlers()); + sort($events); + $this->assertMatchesJsonSnapshot(Json::encode($events)); + } + + public function testItRegistersEventSuccess(): void + { + $eventHandler = new RunUnitTesterEventHandler(); + $this->eventBus->subscribeEventHandler($eventHandler); + $this->assertContains($eventHandler, $this->eventBus->getEventHandlers()); + } + + public function testGetItShouldThrowOnInvalidEventHandler(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('EventHandler for event "Tests\Infrastructure\Events\TestEvent" not subscribed to this bus'); + + $this->eventBus->dispatch(new TestEvent()); + } + + public function testSubscribeEventHandlerItShouldThrowWhenNoCorrespondingEvent(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No corresponding event for eventHandler "Tests\Infrastructure\Events\TestNoCorrespondingEventEventHandler" found'); + + $this->eventBus->subscribeEventHandler(new TestNoCorrespondingEventEventHandler()); + } + + public function testSubscribeEventHandlerItShouldThrowWhenInvalidEventHandlerName(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Fqcn "Tests\Infrastructure\Events\TestInvalidEventHandlerName" does not end with "EventHandler"'); + + $this->eventBus->subscribeEventHandler(new TestInvalidEventHandlerName()); + } + + public function testSubscribeEventHandlerItShouldThrowWhenInvalidEventName(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No corresponding event for eventHandler "Tests\Infrastructure\Events\InvalidTestEvent\InvalidTestEventEventHandler'); + + $this->eventBus->subscribeEventHandler(new InvalidTestEventEventHandler()); + } +} diff --git a/tests/Infrastructure/Events/EventHandler/CommandHandlerCompilerPassTest.php b/tests/Infrastructure/Events/EventHandler/CommandHandlerCompilerPassTest.php new file mode 100644 index 0000000..d575066 --- /dev/null +++ b/tests/Infrastructure/Events/EventHandler/CommandHandlerCompilerPassTest.php @@ -0,0 +1,47 @@ +createMock(ContainerBuilder::class); + $definition = $this->createMock(AutowireDefinitionHelper::class); + + $containerBuilder + ->expects($this->once()) + ->method("findDefinition") + ->with(EventBus::class) + ->willReturn($definition); + + $containerBuilder + ->expects($this->once()) + ->method("findTaggedWithClassAttribute") + ->with(AsEventHandler::class) + ->willReturn([EventHandler::class]); + + $definition + ->expects($this->once()) + ->method("method") + ->with("subscribeEventHandler", \DI\autowire(EventHandler::class)); + + $containerBuilder + ->expects($this->once()) + ->method("addDefinitions") + ->with([EventBus::class => $definition]); + + $compilerPass = new EventHandlerCompilerPass(); + $compilerPass->process($containerBuilder); + } +} diff --git a/tests/Infrastructure/Events/EventQueueTest.php b/tests/Infrastructure/Events/EventQueueTest.php new file mode 100644 index 0000000..efd5c89 --- /dev/null +++ b/tests/Infrastructure/Events/EventQueueTest.php @@ -0,0 +1,115 @@ +AMQPChannelFactory = $this->createMock(AMQPChannelFactory::class); + $this->eventQueueWorker = $this->createMock(EventQueueWorker::class); + + $this->eventQueue = new TestEventQueue( + $this->AMQPChannelFactory, + $this->eventQueueWorker, + ); + } + + public function testGetWorkerSuccess(): void + { + $this->assertInstanceOf(EventQueueWorker::class, $this->eventQueue->getWorker()); + } + + public function testQueueSuccess(): void + { + $event = new TestEvent(); + $amqpChannel = $this->createMock(AMQPChannel::class); + + $this->AMQPChannelFactory + ->expects($this->once()) + ->method("getForQueue") + ->with($this->eventQueue) + ->willReturn($amqpChannel); + + $properties = ["content_type" => "text/plain", "delivery_mode" => AMQPMessage::DELIVERY_MODE_PERSISTENT]; + $message = new AMQPMessage(serialize($event), $properties); + + $amqpChannel + ->expects($this->once()) + ->method("basic_publish") + ->with($message, "", "test-command-queue"); + + $this->eventQueue->queue($event); + } + + public function testQueueItShouldThrowWhenInvalidEnvelope(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Queue "test-command-queue" requires a event to be queued, Envelope given'); + + $this->eventQueue->queue($this->getMockBuilder(Envelope::class) + ->disableOriginalConstructor() + ->disableOriginalClone() + ->disableArgumentCloning() + ->disallowMockingUnknownTypes() + ->setMockClassName("Envelope") + ->getMock()); + } + + public function testQueueBatchSuccess(): void + { + $event = new TestEvent(); + $amqpChannel = $this->createMock(AMQPChannel::class); + + $this->AMQPChannelFactory + ->expects($this->once()) + ->method("getForQueue") + ->with($this->eventQueue) + ->willReturn($amqpChannel); + + $properties = ["content_type" => "text/plain", "delivery_mode" => AMQPMessage::DELIVERY_MODE_PERSISTENT]; + $message = new AMQPMessage(serialize($event), $properties); + + $amqpChannel + ->expects($this->once()) + ->method("batch_basic_publish") + ->with($message, "", "test-command-queue"); + + $amqpChannel + ->expects($this->once()) + ->method("publish_batch"); + + $this->eventQueue->queueBatch([$event]); + } + + public function testQueueBatchItShouldThrowWhenInvalidEnvelope(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Queue "test-command-queue" requires a event to be queued, Envelope given'); + + $this->eventQueue->queueBatch([$this->getMockBuilder(Envelope::class) + ->disableOriginalConstructor() + ->disableOriginalClone() + ->disableArgumentCloning() + ->disallowMockingUnknownTypes() + ->setMockClassName("Envelope") + ->getMock(), ]); + } +} diff --git a/tests/Infrastructure/Events/EventQueueWorkerTest.php b/tests/Infrastructure/Events/EventQueueWorkerTest.php new file mode 100644 index 0000000..d3f71b5 --- /dev/null +++ b/tests/Infrastructure/Events/EventQueueWorkerTest.php @@ -0,0 +1,82 @@ +commandBus = $this->createMock(EventBus::class); + $this->failedQueueFactory = $this->createMock(FailedQueueFactory::class); + $this->clock = PausedClock::on(new \DateTimeImmutable("2022-07-01")); + + $this->commandQueueWorker = new EventQueueWorker( + $this->commandBus, + $this->failedQueueFactory, + $this->clock, + ); + } + + public function testProcessMessageSuccess(): void + { + $message = $this->createMock(AMQPMessage::class); + $command = new TestEvent(); + + $this->commandBus + ->expects($this->once()) + ->method("dispatch") + ->with($command); + + $this->commandQueueWorker->processMessage( + $command, + $message, + ); + $this->assertEquals("event-queue-worker", $this->commandQueueWorker->getName()); + } + + public function testProcessFailureSuccess(): void + { + $message = $this->createMock(AMQPMessage::class); + $command = new TestEvent(); + $queue = $this->createMock(Queue::class); + $failedQueue = $this->createMock(FailedQueue::class); + + $this->failedQueueFactory + ->expects($this->once()) + ->method("buildFor") + ->with($queue) + ->willReturn($failedQueue); + + $failedQueue + ->expects($this->once()) + ->method("queue") + ->with($command); + + $this->commandQueueWorker->processFailure( + $command, + $message, + new \RuntimeException("A grave error"), + $queue, + ); + } +} diff --git a/tests/Infrastructure/Events/InvalidTestEvent/InvalidTestCommand.php b/tests/Infrastructure/Events/InvalidTestEvent/InvalidTestCommand.php new file mode 100644 index 0000000..70fdecf --- /dev/null +++ b/tests/Infrastructure/Events/InvalidTestEvent/InvalidTestCommand.php @@ -0,0 +1,11 @@ + ["array" => ["with", "children"]]]; + + $encoded = Json::encode($array); + $this->assertMatchesJsonSnapshot($encoded); + + $this->assertEquals($array, Json::decode($encoded)); + } + + public function testEncodeItShouldThrowWhenInvalidJson(): void + { + $this->expectException(JsonException::class); + $this->expectExceptionMessage("Type is not supported: NULL"); + + $fp = fopen(Settings::getAppRoot() . "/tests/test.txt", "w"); + Json::encode($fp); + } +} diff --git a/tests/Infrastructure/Serialization/__snapshots__/JsonTest__testEncodeDecode__1.json b/tests/Infrastructure/Serialization/__snapshots__/JsonTest__testEncodeDecode__1.json new file mode 100644 index 0000000..afe4749 --- /dev/null +++ b/tests/Infrastructure/Serialization/__snapshots__/JsonTest__testEncodeDecode__1.json @@ -0,0 +1,8 @@ +{ + "random": { + "array": [ + "with", + "children" + ] + } +} diff --git a/tests/PausedClock.php b/tests/PausedClock.php new file mode 100644 index 0000000..adc0dd9 --- /dev/null +++ b/tests/PausedClock.php @@ -0,0 +1,25 @@ +pausedOn; + } +}