From 9076d6da3f8d9b91e1cba1706fbc3855ae612fe7 Mon Sep 17 00:00:00 2001 From: boherm Date: Mon, 6 Nov 2023 11:45:07 +0100 Subject: [PATCH 1/4] Add Symfony Http Client and put Guzzle in suggest in composer.json --- composer.json | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index ddcd825..27af0b3 100644 --- a/composer.json +++ b/composer.json @@ -14,9 +14,11 @@ } ], "require": { - "guzzlehttp/guzzle": "^7.3" + "php": ">=7.2.5", + "symfony/http-client": "^5.4" }, "require-dev": { + "guzzlehttp/guzzle": "^7.3", "phpunit/phpunit": "^8", "doctrine/cache": "^1.10.2", "symfony/cache": "^4.4", @@ -27,7 +29,8 @@ "suggest": { "symfony/cache": "Allows use of Symfony Cache adapters to store transactions", "doctrine/cache": "Allows use of Doctrine Cache adapters to store transactions", - "ext-apcu": "Allows use of APCu adapter (performant) to store transactions" + "ext-apcu": "Allows use of APCu adapter (performant) to store transactions", + "guzzlehttp/guzzle": "Allows use of Guzzle to perform HTTP requests instead of Symfony HttpClient" }, "autoload": { "psr-4": { @@ -41,10 +44,16 @@ }, "scripts": { "cs-fix": "@php ./vendor/bin/php-cs-fixer fix", - "test": "@php ./vendor/bin/phpunit" + "test": "@php ./vendor/bin/phpunit", + "test-common": "@php ./vendor/bin/phpunit --group=common", + "test-guzzle-client": "@php ./vendor/bin/phpunit --group=guzzle-client", + "test-symfony-http-client": "@php ./vendor/bin/phpunit --group=symfony-http-client" }, "scripts-descriptions": { "cs-fix": "Check and fix coding styles using PHP CS Fixer", - "test": "Launch PHPUnit test suite" + "tests": "Launch PHPUnit test suite", + "tests-common": "Launch PHPUnit test suite for commons", + "tests-guzzle": "Launch PHPUnit test suite for Guzzle", + "tests-symfony-http-client": "Launch PHPUnit test suite for Symfony Http Client" } } From 2757706e775b89928dfc5953a7e25e5254dc6dcf Mon Sep 17 00:00:00 2001 From: boherm Date: Mon, 6 Nov 2023 11:46:10 +0100 Subject: [PATCH 2/4] Use Symfony Http Client by default in all factories --- src/AdvancedCircuitBreakerFactory.php | 4 +- src/Client/SymfonyHttpClient.php | 120 ++++++++++++++++++ src/PartialCircuitBreaker.php | 12 +- src/SimpleCircuitBreakerFactory.php | 4 +- .../AdvancedCircuitBreakerFactoryTest.php | 0 .../AdvancedCircuitBreakerTest.php | 0 .../GuzzleClient}/CircuitBreakerTestCase.php | 0 .../CircuitBreakerWorkflowTest.php | 0 .../SimpleCircuitBreakerFactoryTest.php | 0 9 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 src/Client/SymfonyHttpClient.php rename tests/{ => Implementation/GuzzleClient}/AdvancedCircuitBreakerFactoryTest.php (100%) rename tests/{ => Implementation/GuzzleClient}/AdvancedCircuitBreakerTest.php (100%) rename tests/{ => Implementation/GuzzleClient}/CircuitBreakerTestCase.php (100%) rename tests/{ => Implementation/GuzzleClient}/CircuitBreakerWorkflowTest.php (100%) rename tests/{ => Implementation/GuzzleClient}/SimpleCircuitBreakerFactoryTest.php (100%) diff --git a/src/AdvancedCircuitBreakerFactory.php b/src/AdvancedCircuitBreakerFactory.php index 60bee37..5e87bbc 100644 --- a/src/AdvancedCircuitBreakerFactory.php +++ b/src/AdvancedCircuitBreakerFactory.php @@ -28,7 +28,7 @@ namespace PrestaShop\CircuitBreaker; -use PrestaShop\CircuitBreaker\Client\GuzzleClient; +use PrestaShop\CircuitBreaker\Client\SymfonyHttpClient; use PrestaShop\CircuitBreaker\Contract\CircuitBreakerInterface; use PrestaShop\CircuitBreaker\Contract\ClientInterface; use PrestaShop\CircuitBreaker\Contract\FactoryInterface; @@ -59,7 +59,7 @@ public function create(FactorySettingsInterface $settings): CircuitBreakerInterf $system = new MainSystem($closedPlace, $halfOpenPlace, $openPlace); /** @var ClientInterface $client */ - $client = $settings->getClient() ?: new GuzzleClient($settings->getClientOptions()); + $client = $settings->getClient() ?: new SymfonyHttpClient($settings->getClientOptions()); /** @var StorageInterface $storage */ $storage = $settings->getStorage() ?: new SimpleArray(); /** @var TransitionDispatcherInterface $dispatcher */ diff --git a/src/Client/SymfonyHttpClient.php b/src/Client/SymfonyHttpClient.php new file mode 100644 index 0000000..561be1f --- /dev/null +++ b/src/Client/SymfonyHttpClient.php @@ -0,0 +1,120 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ + +declare(strict_types=1); + +namespace PrestaShop\CircuitBreaker\Client; + +use Exception; +use PrestaShop\CircuitBreaker\Contract\ClientInterface; +use PrestaShop\CircuitBreaker\Exception\UnavailableServiceException; +use PrestaShop\CircuitBreaker\Exception\UnsupportedMethodException; +use Symfony\Component\HttpClient\HttpClient as OriginalSymfonyHttpClient; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * Symfony Http Client implementation. + * The possibility of extending this client is intended. + */ +class SymfonyHttpClient implements ClientInterface +{ + /** + * @var string by default, calls are sent using GET method + */ + public const DEFAULT_METHOD = 'GET'; + + /** + * Supported HTTP methods + */ + public const SUPPORTED_METHODS = [ + 'GET', + 'HEAD', + 'POST', + 'PUT', + 'DELETE', + 'OPTIONS', + ]; + + /** + * @var array the Client default options + */ + private $defaultOptions; + + /** + * @var HttpClientInterface|null + */ + private $client; + + public function __construct(array $defaultOptions = [], HttpClientInterface $client = null) + { + $this->defaultOptions = $defaultOptions; + $this->client = $client; + } + + /** + * {@inheritdoc} + * + * @throws UnavailableServiceException + */ + public function request(string $resource, array $options): string + { + try { + $options = array_merge($this->defaultOptions, $options); + $method = $this->getHttpMethod($options); + // Symfony Http Client not support "method" passed in options array. + unset($options['method']); + // If we haven't already injected a client, we create a new one. + if (!$this->client) { + $this->client = OriginalSymfonyHttpClient::create($options); + } + + return (string) $this->client->request($method, $resource, $options)->getContent(); + } catch (Exception|TransportExceptionInterface $e) { + throw new UnavailableServiceException($e->getMessage(), (int) $e->getCode(), $e); + } + } + + /** + * @param array $options the list of options + * + * @return string the method + * + * @throws UnsupportedMethodException + */ + private function getHttpMethod(array $options): string + { + if (isset($options['method'])) { + if (!in_array($options['method'], self::SUPPORTED_METHODS)) { + throw UnsupportedMethodException::unsupportedMethod($options['method']); + } + + return $options['method']; + } + + return self::DEFAULT_METHOD; + } +} diff --git a/src/PartialCircuitBreaker.php b/src/PartialCircuitBreaker.php index 2b5621c..9a589bd 100644 --- a/src/PartialCircuitBreaker.php +++ b/src/PartialCircuitBreaker.php @@ -29,6 +29,7 @@ namespace PrestaShop\CircuitBreaker; use DateTime; +use PrestaShop\CircuitBreaker\Client\GuzzleClient; use PrestaShop\CircuitBreaker\Contract\CircuitBreakerInterface; use PrestaShop\CircuitBreaker\Contract\ClientInterface; use PrestaShop\CircuitBreaker\Contract\PlaceInterface; @@ -178,12 +179,15 @@ protected function canAccessService(TransactionInterface $transaction): bool */ protected function request(string $service, array $parameters = []): string { + $forcedParameters = ['timeout' => $this->currentPlace->getTimeout()]; + + if ($this->client instanceof GuzzleClient) { + $forcedParameters['connect_timeout'] = $this->currentPlace->getTimeout(); + } + return $this->client->request( $service, - array_merge($parameters, [ - 'connect_timeout' => $this->currentPlace->getTimeout(), - 'timeout' => $this->currentPlace->getTimeout(), - ]) + array_merge($parameters, $forcedParameters) ); } } diff --git a/src/SimpleCircuitBreakerFactory.php b/src/SimpleCircuitBreakerFactory.php index e555ddf..5988dfc 100644 --- a/src/SimpleCircuitBreakerFactory.php +++ b/src/SimpleCircuitBreakerFactory.php @@ -28,7 +28,7 @@ namespace PrestaShop\CircuitBreaker; -use PrestaShop\CircuitBreaker\Client\GuzzleClient; +use PrestaShop\CircuitBreaker\Client\SymfonyHttpClient; use PrestaShop\CircuitBreaker\Contract\CircuitBreakerInterface; use PrestaShop\CircuitBreaker\Contract\ClientInterface; use PrestaShop\CircuitBreaker\Contract\FactoryInterface; @@ -53,7 +53,7 @@ public function create(FactorySettingsInterface $settings): CircuitBreakerInterf $halfOpenPlace = new HalfOpenPlace($settings->getFailures(), $settings->getStrippedTimeout(), 0); /** @var ClientInterface $client */ - $client = $settings->getClient() ?: new GuzzleClient($settings->getClientOptions()); + $client = $settings->getClient() ?: new SymfonyHttpClient($settings->getClientOptions()); return new SimpleCircuitBreaker( $openPlace, diff --git a/tests/AdvancedCircuitBreakerFactoryTest.php b/tests/Implementation/GuzzleClient/AdvancedCircuitBreakerFactoryTest.php similarity index 100% rename from tests/AdvancedCircuitBreakerFactoryTest.php rename to tests/Implementation/GuzzleClient/AdvancedCircuitBreakerFactoryTest.php diff --git a/tests/AdvancedCircuitBreakerTest.php b/tests/Implementation/GuzzleClient/AdvancedCircuitBreakerTest.php similarity index 100% rename from tests/AdvancedCircuitBreakerTest.php rename to tests/Implementation/GuzzleClient/AdvancedCircuitBreakerTest.php diff --git a/tests/CircuitBreakerTestCase.php b/tests/Implementation/GuzzleClient/CircuitBreakerTestCase.php similarity index 100% rename from tests/CircuitBreakerTestCase.php rename to tests/Implementation/GuzzleClient/CircuitBreakerTestCase.php diff --git a/tests/CircuitBreakerWorkflowTest.php b/tests/Implementation/GuzzleClient/CircuitBreakerWorkflowTest.php similarity index 100% rename from tests/CircuitBreakerWorkflowTest.php rename to tests/Implementation/GuzzleClient/CircuitBreakerWorkflowTest.php diff --git a/tests/SimpleCircuitBreakerFactoryTest.php b/tests/Implementation/GuzzleClient/SimpleCircuitBreakerFactoryTest.php similarity index 100% rename from tests/SimpleCircuitBreakerFactoryTest.php rename to tests/Implementation/GuzzleClient/SimpleCircuitBreakerFactoryTest.php From 8092bbbdc5b617620c10a9b66ac496dc67809ff6 Mon Sep 17 00:00:00 2001 From: boherm Date: Mon, 6 Nov 2023 11:46:40 +0100 Subject: [PATCH 3/4] Update tests to use Symfony Http Client instead of Guzzle Client --- .github/workflows/php.yml | 111 ++++++- tests/Client/GuzzleClientTest.php | 41 +++ tests/Client/SymfonyHttpClientTest.php | 102 ++++++ tests/Event/TransitionEventTest.php | 3 + tests/Exception/InvalidPlaceTest.php | 3 + tests/Exception/InvalidTransactionTest.php | 3 + tests/FactorySettingsTest.php | 3 + .../AdvancedCircuitBreakerFactoryTest.php | 5 +- .../AdvancedCircuitBreakerTest.php | 5 +- .../GuzzleClient/CircuitBreakerTestCase.php | 2 +- .../CircuitBreakerWorkflowTest.php | 5 +- .../SimpleCircuitBreakerFactoryTest.php | 5 +- .../AdvancedCircuitBreakerFactoryTest.php | 158 ++++++++++ .../AdvancedCircuitBreakerTest.php | 295 ++++++++++++++++++ .../CircuitBreakerTestCase.php | 80 +++++ .../CircuitBreakerWorkflowTest.php | 290 +++++++++++++++++ .../SimpleCircuitBreakerFactoryTest.php | 79 +++++ tests/Place/ClosedPlaceTest.php | 3 + tests/Place/HalfOpenPlaceTest.php | 3 + tests/Place/OpenPlaceTest.php | 3 + tests/Place/PlaceTestCase.php | 2 + tests/Storage/DoctrineCacheTest.php | 3 + tests/Storage/SimpleArrayTest.php | 3 + tests/Storage/SymfonyCacheTest.php | 3 + tests/SymfonyCircuitBreakerEventsTest.php | 4 + tests/System/MainSystemTest.php | 3 + tests/Transaction/SimpleTransactionTest.php | 3 + tests/Util/AssertTest.php | 3 + 28 files changed, 1206 insertions(+), 17 deletions(-) create mode 100644 tests/Client/SymfonyHttpClientTest.php create mode 100644 tests/Implementation/SymfonyHttpClient/AdvancedCircuitBreakerFactoryTest.php create mode 100644 tests/Implementation/SymfonyHttpClient/AdvancedCircuitBreakerTest.php create mode 100644 tests/Implementation/SymfonyHttpClient/CircuitBreakerTestCase.php create mode 100644 tests/Implementation/SymfonyHttpClient/CircuitBreakerWorkflowTest.php create mode 100644 tests/Implementation/SymfonyHttpClient/SimpleCircuitBreakerFactoryTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 5ea2b9b..1b8bc8d 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -3,7 +3,23 @@ name: PHP on: [push, pull_request] jobs: - build: + cs: + runs-on: ubuntu-latest + steps: + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + + - uses: actions/checkout@v2 + + - name: Install dependencies + run: composer install --no-interaction + + - name: PHP CS Fixer + run: ./vendor/bin/php-cs-fixer fix --dry-run + + tests-common: runs-on: ubuntu-latest strategy: matrix: @@ -17,25 +33,96 @@ jobs: - uses: actions/checkout@v2 - - name: Get composer cache directory - id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Install dependencies + run: composer install --no-interaction + + - name: PHPUnit + run: ./vendor/bin/phpunit --group=common --coverage-clover build/clover.xml - - name: Cache dependencies - uses: actions/cache@v2 + - name: Upload coverage results to Coveralls + if: matrix.php-versions == '7.4' + env: + COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + wget https://github.com/php-coveralls/php-coveralls/releases/download/v2.4.3/php-coveralls.phar + chmod +x php-coveralls.phar + php php-coveralls.phar --coverage_clover=build/clover.xml --json_path=build/coveralls-upload.json -vvv + + tests-guzzle-client: + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2'] + steps: + - name: Setup PHP + uses: shivammathur/setup-php@v2 with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ runner.os }}-composer- + php-version: ${{ matrix.php-versions }} + coverage: xdebug + + - uses: actions/checkout@v2 - name: Install dependencies run: composer install --no-interaction - name: PHPUnit - run: ./vendor/bin/phpunit --coverage-clover build/clover.xml + run: ./vendor/bin/phpunit --group=guzzle-client --coverage-clover build/clover.xml - - name: PHP CS Fixer - run: PHP_CS_FIXER_IGNORE_ENV=1 ./vendor/bin/php-cs-fixer fix --dry-run + - name: Upload coverage results to Coveralls + if: matrix.php-versions == '7.4' + env: + COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + wget https://github.com/php-coveralls/php-coveralls/releases/download/v2.4.3/php-coveralls.phar + chmod +x php-coveralls.phar + php php-coveralls.phar --coverage_clover=build/clover.xml --json_path=build/coveralls-upload.json -vvv + + tests-symfony-http-client: + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2'] + symfony-http-client-versions: ['5.4', '6.0', '6.1', '6.2'] + exclude: + - php-versions: '7.2' + symfony-http-client-versions: '6.0' + - php-versions: '7.3' + symfony-http-client-versions: '6.0' + - php-versions: '7.4' + symfony-http-client-versions: '6.0' + - php-versions: '7.2' + symfony-http-client-versions: '6.1' + - php-versions: '7.3' + symfony-http-client-versions: '6.1' + - php-versions: '7.4' + symfony-http-client-versions: '6.1' + - php-versions: '8.0' + symfony-http-client-versions: '6.1' + - php-versions: '7.2' + symfony-http-client-versions: '6.2' + - php-versions: '7.3' + symfony-http-client-versions: '6.2' + - php-versions: '7.4' + symfony-http-client-versions: '6.2' + - php-versions: '8.0' + symfony-http-client-versions: '6.2' + steps: + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + coverage: xdebug + + - uses: actions/checkout@v2 + + - name: Require Symfony Http Client version + run: composer require symfony/http-client:${{ matrix.symfony-http-client-versions }} --no-interaction --no-update + + - name: Install dependencies + run: composer install --no-interaction + + - name: PHPUnit + run: ./vendor/bin/phpunit --group=symfony-http-client --coverage-clover build/clover.xml - name: Upload coverage results to Coveralls if: matrix.php-versions == '7.4' diff --git a/tests/Client/GuzzleClientTest.php b/tests/Client/GuzzleClientTest.php index e8a2624..0082fb5 100644 --- a/tests/Client/GuzzleClientTest.php +++ b/tests/Client/GuzzleClientTest.php @@ -28,10 +28,15 @@ namespace Tests\PrestaShop\CircuitBreaker\Client; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\TestCase; use PrestaShop\CircuitBreaker\Client\GuzzleClient; use PrestaShop\CircuitBreaker\Exception\UnavailableServiceException; +/** + * @group common + */ class GuzzleClientTest extends TestCase { public function testRequestWorksAsExpected() @@ -59,4 +64,40 @@ public function testTheClientAcceptsHttpMethodOverride() $this->assertEmpty($client->request('https://www.google.fr', [])); } + + /** + * @dataProvider getSupportedMethods + */ + public function testSupportedMethod(string $method, bool $throwException) + { + if ($throwException) { + $this->expectException(UnavailableServiceException::class); + $this->expectExceptionMessage(sprintf('Unsupported method: "%s"', $method)); + } else { + $this->expectNotToPerformAssertions(); + } + + $mock = new MockHandler([ + new Response(200), + ]); + + $client = new GuzzleClient([ + 'method' => $method, + 'handler' => $mock, + ]); + $client->request('https://www.google.fr', []); + } + + public function getSupportedMethods() + { + return [ + ['HEAD', false], + ['GET', false], + ['POST', false], + ['PUT', false], + ['DELETE', false], + ['OPTIONS', false], + ['UNKNOWN_METHOD', true], + ]; + } } diff --git a/tests/Client/SymfonyHttpClientTest.php b/tests/Client/SymfonyHttpClientTest.php new file mode 100644 index 0000000..76b0f45 --- /dev/null +++ b/tests/Client/SymfonyHttpClientTest.php @@ -0,0 +1,102 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ + +declare(strict_types=1); + +namespace Tests\PrestaShop\CircuitBreaker\Client; + +use PHPUnit\Framework\TestCase; +use PrestaShop\CircuitBreaker\Client\SymfonyHttpClient; +use PrestaShop\CircuitBreaker\Exception\UnavailableServiceException; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; + +/** + * @group common + */ +class SymfonyHttpClientTest extends TestCase +{ + public function testRequestWorksAsExpected() + { + $client = new SymfonyHttpClient(); + + $this->assertNotNull($client->request('https://www.google.com', [ + 'method' => 'GET', + ])); + } + + public function testWrongRequestThrowsAnException() + { + $this->expectException(UnavailableServiceException::class); + + $client = new SymfonyHttpClient(); + $client->request('http://not-even-a-valid-domain.xxx', []); + } + + public function testTheClientAcceptsHttpMethodOverride() + { + $client = new SymfonyHttpClient([ + 'method' => 'HEAD', + ]); + + $this->assertEmpty($client->request('https://www.google.fr', [])); + } + + /** + * @dataProvider getSupportedMethods + */ + public function testSupportedMethod(string $method, bool $throwException) + { + if ($throwException) { + $this->expectException(UnavailableServiceException::class); + $this->expectExceptionMessage(sprintf('Unsupported method: "%s"', $method)); + } else { + $this->expectNotToPerformAssertions(); + } + + $mock = new MockHttpClient([ + new MockResponse('', ['http_code' => 200]), + ]); + + $client = new SymfonyHttpClient([ + 'method' => $method, + ], $mock); + $client->request('https://www.google.fr', []); + } + + public function getSupportedMethods() + { + return [ + ['HEAD', false], + ['GET', false], + ['POST', false], + ['PUT', false], + ['DELETE', false], + ['OPTIONS', false], + ['UNKNOWN_METHOD', true], + ]; + } +} diff --git a/tests/Event/TransitionEventTest.php b/tests/Event/TransitionEventTest.php index 6c8ece7..a10a193 100644 --- a/tests/Event/TransitionEventTest.php +++ b/tests/Event/TransitionEventTest.php @@ -31,6 +31,9 @@ use PHPUnit\Framework\TestCase; use PrestaShop\CircuitBreaker\Event\TransitionEvent; +/** + * @group common + */ class TransitionEventTest extends TestCase { public function testCreation() diff --git a/tests/Exception/InvalidPlaceTest.php b/tests/Exception/InvalidPlaceTest.php index 9a1b4c5..2ae4ae6 100644 --- a/tests/Exception/InvalidPlaceTest.php +++ b/tests/Exception/InvalidPlaceTest.php @@ -31,6 +31,9 @@ use PHPUnit\Framework\TestCase; use PrestaShop\CircuitBreaker\Exception\InvalidPlaceException; +/** + * @group common + */ class InvalidPlaceTest extends TestCase { public function testCreation() diff --git a/tests/Exception/InvalidTransactionTest.php b/tests/Exception/InvalidTransactionTest.php index b918fa4..8d02b80 100644 --- a/tests/Exception/InvalidTransactionTest.php +++ b/tests/Exception/InvalidTransactionTest.php @@ -31,6 +31,9 @@ use PHPUnit\Framework\TestCase; use PrestaShop\CircuitBreaker\Exception\InvalidTransactionException; +/** + * @group common + */ class InvalidTransactionTest extends TestCase { public function testCreation(): void diff --git a/tests/FactorySettingsTest.php b/tests/FactorySettingsTest.php index 28ba9a4..0798b78 100644 --- a/tests/FactorySettingsTest.php +++ b/tests/FactorySettingsTest.php @@ -31,6 +31,9 @@ use PHPUnit\Framework\TestCase; use PrestaShop\CircuitBreaker\FactorySettings; +/** + * @group common + */ class FactorySettingsTest extends TestCase { public function testSimpleSettings() diff --git a/tests/Implementation/GuzzleClient/AdvancedCircuitBreakerFactoryTest.php b/tests/Implementation/GuzzleClient/AdvancedCircuitBreakerFactoryTest.php index 64510a5..0aa02d0 100644 --- a/tests/Implementation/GuzzleClient/AdvancedCircuitBreakerFactoryTest.php +++ b/tests/Implementation/GuzzleClient/AdvancedCircuitBreakerFactoryTest.php @@ -26,7 +26,7 @@ declare(strict_types=1); -namespace Tests\PrestaShop\CircuitBreaker; +namespace Tests\PrestaShop\CircuitBreaker\Implementation\GuzzleClient; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\Psr7\Response; @@ -42,6 +42,9 @@ use PrestaShop\CircuitBreaker\State; use PrestaShop\CircuitBreaker\Transition; +/** + * @group guzzle-client + */ class AdvancedCircuitBreakerFactoryTest extends TestCase { /** diff --git a/tests/Implementation/GuzzleClient/AdvancedCircuitBreakerTest.php b/tests/Implementation/GuzzleClient/AdvancedCircuitBreakerTest.php index 64cbc4a..feb9574 100644 --- a/tests/Implementation/GuzzleClient/AdvancedCircuitBreakerTest.php +++ b/tests/Implementation/GuzzleClient/AdvancedCircuitBreakerTest.php @@ -25,7 +25,7 @@ */ declare(strict_types=1); -namespace Tests\PrestaShop\CircuitBreaker; +namespace Tests\PrestaShop\CircuitBreaker\Implementation\GuzzleClient; use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Handler\MockHandler; @@ -45,6 +45,9 @@ use PrestaShop\CircuitBreaker\Transition\NullDispatcher; use Symfony\Component\Cache\Simple\ArrayCache; +/** + * @group guzzle-client + */ class AdvancedCircuitBreakerTest extends CircuitBreakerTestCase { /** diff --git a/tests/Implementation/GuzzleClient/CircuitBreakerTestCase.php b/tests/Implementation/GuzzleClient/CircuitBreakerTestCase.php index 1109a13..6601c31 100644 --- a/tests/Implementation/GuzzleClient/CircuitBreakerTestCase.php +++ b/tests/Implementation/GuzzleClient/CircuitBreakerTestCase.php @@ -25,7 +25,7 @@ */ declare(strict_types=1); -namespace Tests\PrestaShop\CircuitBreaker; +namespace Tests\PrestaShop\CircuitBreaker\Implementation\GuzzleClient; use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Handler\MockHandler; diff --git a/tests/Implementation/GuzzleClient/CircuitBreakerWorkflowTest.php b/tests/Implementation/GuzzleClient/CircuitBreakerWorkflowTest.php index 9ec7e1e..59863dc 100644 --- a/tests/Implementation/GuzzleClient/CircuitBreakerWorkflowTest.php +++ b/tests/Implementation/GuzzleClient/CircuitBreakerWorkflowTest.php @@ -26,7 +26,7 @@ declare(strict_types=1); -namespace Tests\PrestaShop\CircuitBreaker; +namespace Tests\PrestaShop\CircuitBreaker\Implementation\GuzzleClient; use PrestaShop\CircuitBreaker\AdvancedCircuitBreaker; use PrestaShop\CircuitBreaker\Client\GuzzleClient; @@ -45,6 +45,9 @@ use Symfony\Component\Cache\Simple\ArrayCache; use Symfony\Component\EventDispatcher\EventDispatcher; +/** + * @group guzzle-client + */ class CircuitBreakerWorkflowTest extends CircuitBreakerTestCase { public const OPEN_THRESHOLD = 1; diff --git a/tests/Implementation/GuzzleClient/SimpleCircuitBreakerFactoryTest.php b/tests/Implementation/GuzzleClient/SimpleCircuitBreakerFactoryTest.php index ef798ce..4c9d059 100644 --- a/tests/Implementation/GuzzleClient/SimpleCircuitBreakerFactoryTest.php +++ b/tests/Implementation/GuzzleClient/SimpleCircuitBreakerFactoryTest.php @@ -26,7 +26,7 @@ declare(strict_types=1); -namespace Tests\PrestaShop\CircuitBreaker; +namespace Tests\PrestaShop\CircuitBreaker\Implementation\GuzzleClient; use PHPUnit\Framework\TestCase; use PrestaShop\CircuitBreaker\Contract\FactorySettingsInterface; @@ -34,6 +34,9 @@ use PrestaShop\CircuitBreaker\SimpleCircuitBreaker; use PrestaShop\CircuitBreaker\SimpleCircuitBreakerFactory; +/** + * @group guzzle-client + */ class SimpleCircuitBreakerFactoryTest extends TestCase { public function testCreation(): void diff --git a/tests/Implementation/SymfonyHttpClient/AdvancedCircuitBreakerFactoryTest.php b/tests/Implementation/SymfonyHttpClient/AdvancedCircuitBreakerFactoryTest.php new file mode 100644 index 0000000..1b4b943 --- /dev/null +++ b/tests/Implementation/SymfonyHttpClient/AdvancedCircuitBreakerFactoryTest.php @@ -0,0 +1,158 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ + +declare(strict_types=1); + +namespace Tests\PrestaShop\CircuitBreaker\Implementation\SymfonyHttpClient; + +use PHPUnit\Framework\TestCase; +use PrestaShop\CircuitBreaker\AdvancedCircuitBreaker; +use PrestaShop\CircuitBreaker\AdvancedCircuitBreakerFactory; +use PrestaShop\CircuitBreaker\Client\SymfonyHttpClient; +use PrestaShop\CircuitBreaker\Contract\FactorySettingsInterface; +use PrestaShop\CircuitBreaker\Contract\StorageInterface; +use PrestaShop\CircuitBreaker\Contract\TransitionDispatcherInterface; +use PrestaShop\CircuitBreaker\FactorySettings; +use PrestaShop\CircuitBreaker\State; +use PrestaShop\CircuitBreaker\Transition; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; + +/** + * @group symfony-http-client + */ +class AdvancedCircuitBreakerFactoryTest extends TestCase +{ + /** + * @dataProvider getSettings + * + * @param FactorySettingsInterface $settings the Circuit Breaker settings + */ + public function testCircuitBreakerCreation(FactorySettingsInterface $settings): void + { + $factory = new AdvancedCircuitBreakerFactory(); + $circuitBreaker = $factory->create($settings); + + $this->assertInstanceOf(AdvancedCircuitBreaker::class, $circuitBreaker); + } + + public function testCircuitBreakerWithDispatcher(): void + { + $dispatcher = $this->getMockBuilder(TransitionDispatcherInterface::class) + ->disableOriginalConstructor() + ->getMock() + ; + + $localeService = 'https://prestashop-projects.org/'; + $expectedParameters = ['toto' => 'titi', 42 => 51]; + + $dispatcher + ->expects($this->exactly(3)) + ->method('dispatchTransition') + ->withConsecutive( + [ + $this->equalTo(Transition::INITIATING_TRANSITION), + $this->equalTo($localeService), + $this->equalTo([]), + ], + [ + $this->equalTo(Transition::TRIAL_TRANSITION), + $this->equalTo($localeService), + $this->equalTo($expectedParameters), + ] + ) + ; + + $factory = new AdvancedCircuitBreakerFactory(); + $settings = new FactorySettings(2, 0.1, 10); + $settings + ->setStrippedTimeout(0.2) + ->setDispatcher($dispatcher) + ; + + $mock = new MockHttpClient([ + new MockResponse('{"hello": "world"}', ['http_code' => 200]), + ]); + $client = new SymfonyHttpClient([], $mock); + $settings->setClient($client); + + $circuitBreaker = $factory->create($settings); + + $this->assertInstanceOf(AdvancedCircuitBreaker::class, $circuitBreaker); + $circuitBreaker->call($localeService, $expectedParameters, function () { + return false; + }); + } + + public function testCircuitBreakerWithStorage(): void + { + $storage = $this->getMockBuilder(StorageInterface::class) + ->disableOriginalConstructor() + ->getMock() + ; + + $factory = new AdvancedCircuitBreakerFactory(); + $settings = new FactorySettings(2, 0.1, 10); + $settings + ->setStrippedTimeout(0.2) + ->setStorage($storage) + ; + $circuitBreaker = $factory->create($settings); + + $this->assertInstanceOf(AdvancedCircuitBreaker::class, $circuitBreaker); + } + + public function testCircuitBreakerWithDefaultFallback(): void + { + $factory = new AdvancedCircuitBreakerFactory(); + $settings = new FactorySettings(2, 0.1, 10); + $settings->setDefaultFallback(function () { + return 'default_fallback'; + }); + $circuitBreaker = $factory->create($settings); + + $this->assertInstanceOf(AdvancedCircuitBreaker::class, $circuitBreaker); + $response = $circuitBreaker->call('unknown_service'); + $this->assertEquals(State::OPEN_STATE, $circuitBreaker->getState()); + $this->assertEquals('default_fallback', $response); + } + + public function getSettings(): array + { + return [ + [ + (new FactorySettings(2, 0.1, 10)) + ->setStrippedTimeout(0.2) + ->setClientOptions(['proxy' => '192.168.16.1:10']), + ], + [ + (new FactorySettings(2, 0.1, 10)) + ->setStrippedTimeout(0.2) + ->setClient(new SymfonyHttpClient(['proxy' => '192.168.16.1:10'])), + ], + ]; + } +} diff --git a/tests/Implementation/SymfonyHttpClient/AdvancedCircuitBreakerTest.php b/tests/Implementation/SymfonyHttpClient/AdvancedCircuitBreakerTest.php new file mode 100644 index 0000000..48e1dc2 --- /dev/null +++ b/tests/Implementation/SymfonyHttpClient/AdvancedCircuitBreakerTest.php @@ -0,0 +1,295 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ +declare(strict_types=1); + +namespace Tests\PrestaShop\CircuitBreaker\Implementation\SymfonyHttpClient; + +use PHPUnit\Framework\MockObject\Rule\AnyInvokedCount; +use PrestaShop\CircuitBreaker\AdvancedCircuitBreaker; +use PrestaShop\CircuitBreaker\Client\SymfonyHttpClient; +use PrestaShop\CircuitBreaker\Contract\TransitionDispatcherInterface; +use PrestaShop\CircuitBreaker\Place\ClosedPlace; +use PrestaShop\CircuitBreaker\Place\HalfOpenPlace; +use PrestaShop\CircuitBreaker\Place\OpenPlace; +use PrestaShop\CircuitBreaker\State; +use PrestaShop\CircuitBreaker\Storage\SymfonyCache; +use PrestaShop\CircuitBreaker\System\MainSystem; +use PrestaShop\CircuitBreaker\Transition\NullDispatcher; +use Symfony\Component\Cache\Simple\ArrayCache; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; + +/** + * @group symfony-http-client + */ +class AdvancedCircuitBreakerTest extends CircuitBreakerTestCase +{ + /** + * Used to track the dispatched events. + * + * @var AnyInvokedCount + */ + private $spy; + + /** + * We should see the circuit breaker initialized, + * a call being done and then the circuit breaker closed. + */ + public function testCircuitBreakerEventsOnFirstFailedCall(): void + { + $circuitBreaker = $this->createCircuitBreaker(); + + $circuitBreaker->call( + 'https://httpbin.org/get/foo', + ['toto' => 'titi'], + function () { + return '{}'; + } + ); + + /** + * The circuit breaker is initiated + * the 2 failed trials are done + * then the conditions are met to open the circuit breaker + */ + $invocations = self::invocations($this->spy); + + $this->assertCount(4, $invocations); + $this->assertSame('INITIATING', $invocations[0]->getParameters()[0]); + $this->assertSame('TRIAL', $invocations[1]->getParameters()[0]); + $this->assertSame('TRIAL', $invocations[2]->getParameters()[0]); + $this->assertSame('OPENING', $invocations[3]->getParameters()[0]); + } + + public function testSimpleCall(): void + { + $system = new MainSystem( + new ClosedPlace(2, 0.2, 0), + new HalfOpenPlace(0, 0.2, 0), + new OpenPlace(0, 0, 1) + ); + $symfonyCache = new SymfonyCache(new ArrayCache()); + $mock = new MockHttpClient([ + new MockResponse('{"hello": "world"}', ['http_code' => 200]), + ]); + $client = new SymfonyHttpClient([], $mock); + + $circuitBreaker = new AdvancedCircuitBreaker( + $system, + $client, + $symfonyCache, + new NullDispatcher() + ); + + $response = $circuitBreaker->call('anything', [], function () { + return false; + }); + $this->assertSame(State::CLOSED_STATE, $circuitBreaker->getState()); + $this->assertEquals(1, $mock->getRequestsCount()); + $this->assertEquals('{"hello": "world"}', $response); + } + + public function testOpenStateAfterTooManyFailures(): void + { + $system = new MainSystem( + new ClosedPlace(2, 0.2, 0), + new HalfOpenPlace(0, 0.2, 0), + new OpenPlace(0, 0, 1) + ); + $symfonyCache = new SymfonyCache(new ArrayCache()); + $mock = new MockHttpClient([ + new MockResponse('', ['http_code' => 503]), + new MockResponse('', ['http_code' => 503]), + ]); + $client = new SymfonyHttpClient([], $mock); + + $circuitBreaker = new AdvancedCircuitBreaker( + $system, + $client, + $symfonyCache, + new NullDispatcher() + ); + + $response = $circuitBreaker->call('anything'); + + $this->assertEquals(2, $mock->getRequestsCount()); + $this->assertEquals(false, $response); + $this->assertSame(State::OPEN_STATE, $circuitBreaker->getState()); + } + + public function testNoFallback() + { + $system = new MainSystem( + new ClosedPlace(2, 0.2, 0), + new HalfOpenPlace(0, 0.2, 0), + new OpenPlace(0, 0, 1) + ); + $symfonyCache = new SymfonyCache(new ArrayCache()); + + $mock = new MockHttpClient([ + new MockResponse('', ['http_code' => 503]), + new MockResponse('', ['http_code' => 503]), + ]); + $client = new SymfonyHttpClient([], $mock); + + $circuitBreaker = new AdvancedCircuitBreaker( + $system, + $client, + $symfonyCache, + new NullDispatcher() + ); + + $response = $circuitBreaker->call('anything'); + $this->assertEquals(2, $mock->getRequestsCount()); + $this->assertEquals('', $response); + $this->assertSame(State::OPEN_STATE, $circuitBreaker->getState()); + } + + public function testBackToClosedStateAfterSuccess(): void + { + $system = new MainSystem( + new ClosedPlace(2, 0.2, 0), + new HalfOpenPlace(0, 0.2, 0), + new OpenPlace(0, 0, 1) + ); + $symfonyCache = new SymfonyCache(new ArrayCache()); + $mock = new MockHttpClient([ + new MockResponse('', ['http_code' => 503]), + new MockResponse('', ['http_code' => 503]), + new MockResponse('{"hello": "world"}', ['http_code' => 200]), + ]); + $client = new SymfonyHttpClient([], $mock); + + $circuitBreaker = new AdvancedCircuitBreaker( + $system, + $client, + $symfonyCache, + new NullDispatcher() + ); + + $response = $circuitBreaker->call('anything', [], function () { + return false; + }); + $this->assertEquals(2, $mock->getRequestsCount()); + $this->assertEquals(false, $response); + $this->assertSame(State::OPEN_STATE, $circuitBreaker->getState()); + $mock->reset(); + + //Stay in OPEN state + $response = $circuitBreaker->call('anything', [], function () { + return false; + }); + $this->assertEquals(0, $mock->getRequestsCount()); + $this->assertEquals(false, $response); + $this->assertSame(State::OPEN_STATE, $circuitBreaker->getState()); + $mock->reset(); + + sleep(2); + //Switch to CLOSED state on success + $response = $circuitBreaker->call('anything', [], function () { + return false; + }); + $this->assertEquals(1, $mock->getRequestsCount()); + $this->assertEquals('{"hello": "world"}', $response); + $this->assertSame(State::CLOSED_STATE, $circuitBreaker->getState()); + } + + public function testStayInOpenStateAfterFailure(): void + { + $system = new MainSystem( + new ClosedPlace(2, 0.2, 0), + new HalfOpenPlace(0, 0.2, 0), + new OpenPlace(0, 0, 1) + ); + $symfonyCache = new SymfonyCache(new ArrayCache()); + + $mock = new MockHttpClient([ + new MockResponse('', ['http_code' => 503]), + new MockResponse('', ['http_code' => 503]), + new MockResponse('', ['http_code' => 503]), + ]); + $client = new SymfonyHttpClient([], $mock); + + $circuitBreaker = new AdvancedCircuitBreaker( + $system, + $client, + $symfonyCache, + new NullDispatcher() + ); + + $response = $circuitBreaker->call('anything', [], function () { + return false; + }); + $this->assertEquals(2, $mock->getRequestsCount()); + $this->assertEquals(false, $response); + $this->assertSame(State::OPEN_STATE, $circuitBreaker->getState()); + $mock->reset(); + + //Stay in OPEN state + $response = $circuitBreaker->call('anything', [], function () { + return false; + }); + $this->assertEquals(0, $mock->getRequestsCount()); + $this->assertEquals(false, $response); + $this->assertSame(State::OPEN_STATE, $circuitBreaker->getState()); + $mock->reset(); + + sleep(2); + //Switch to OPEN state on failure + $response = $circuitBreaker->call('anything', [], function () { + return false; + }); + $this->assertEquals(1, $mock->getRequestsCount()); + $this->assertEquals(false, $response); + $this->assertSame(State::OPEN_STATE, $circuitBreaker->getState()); + } + + /** + * @return AdvancedCircuitBreaker the circuit breaker for testing purposes + */ + private function createCircuitBreaker(): AdvancedCircuitBreaker + { + $system = new MainSystem( + new ClosedPlace(2, 0.2, 0), + new HalfOpenPlace(0, 0.2, 0), + new OpenPlace(0, 0, 1) + ); + + $symfonyCache = new SymfonyCache(new ArrayCache()); + /** @var TransitionDispatcherInterface $dispatcher */ + $dispatcher = $this->createMock(TransitionDispatcherInterface::class); + $dispatcher->expects($this->spy = $this->any()) + ->method('dispatchTransition') + ; + + return new AdvancedCircuitBreaker( + $system, + $this->getTestClient(), + $symfonyCache, + $dispatcher + ); + } +} diff --git a/tests/Implementation/SymfonyHttpClient/CircuitBreakerTestCase.php b/tests/Implementation/SymfonyHttpClient/CircuitBreakerTestCase.php new file mode 100644 index 0000000..16e408c --- /dev/null +++ b/tests/Implementation/SymfonyHttpClient/CircuitBreakerTestCase.php @@ -0,0 +1,80 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ +declare(strict_types=1); + +namespace Tests\PrestaShop\CircuitBreaker\Implementation\SymfonyHttpClient; + +use PHPUnit\Framework\MockObject\Rule\AnyInvokedCount; +use PHPUnit\Framework\TestCase; +use PrestaShop\CircuitBreaker\Client\SymfonyHttpClient; +use ReflectionClass; +use ReflectionException; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; + +/** + * Helper to get a fake Symfony Http Client. + */ +abstract class CircuitBreakerTestCase extends TestCase +{ + /** + * Returns an instance of Client able to emulate + * available and not available services. + */ + protected function getTestClient(): SymfonyHttpClient + { + $mock = new MockHttpClient([ + new MockResponse('', ['http_code' => 503]), + new MockResponse('', ['http_code' => 503]), + new MockResponse('{"hello": "world"}', ['http_code' => 200]), + ]); + + return new SymfonyHttpClient([], $mock); + } + + /** + * @see https://github.com/sebastianbergmann/phpunit/issues/3888 + * + * @throws ReflectionException + */ + protected static function invocations(AnyInvokedCount $anyInvokedCount): array + { + $reflectionClass = new ReflectionClass(get_class($anyInvokedCount)); + $parentReflectionClass = $reflectionClass->getParentClass(); + + if ($parentReflectionClass instanceof ReflectionClass) { + foreach ($parentReflectionClass->getProperties() as $property) { + if ($property->getName() === 'invocations') { + $property->setAccessible(true); + + return $property->getValue($anyInvokedCount); + } + } + } + + return []; + } +} diff --git a/tests/Implementation/SymfonyHttpClient/CircuitBreakerWorkflowTest.php b/tests/Implementation/SymfonyHttpClient/CircuitBreakerWorkflowTest.php new file mode 100644 index 0000000..155da85 --- /dev/null +++ b/tests/Implementation/SymfonyHttpClient/CircuitBreakerWorkflowTest.php @@ -0,0 +1,290 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ + +declare(strict_types=1); + +namespace Tests\PrestaShop\CircuitBreaker\Implementation\SymfonyHttpClient; + +use PrestaShop\CircuitBreaker\AdvancedCircuitBreaker; +use PrestaShop\CircuitBreaker\Client\SymfonyHttpClient; +use PrestaShop\CircuitBreaker\Contract\CircuitBreakerInterface; +use PrestaShop\CircuitBreaker\Exception\UnavailableServiceException; +use PrestaShop\CircuitBreaker\Place\ClosedPlace; +use PrestaShop\CircuitBreaker\Place\HalfOpenPlace; +use PrestaShop\CircuitBreaker\Place\OpenPlace; +use PrestaShop\CircuitBreaker\SimpleCircuitBreaker; +use PrestaShop\CircuitBreaker\State; +use PrestaShop\CircuitBreaker\Storage\SimpleArray; +use PrestaShop\CircuitBreaker\Storage\SymfonyCache; +use PrestaShop\CircuitBreaker\SymfonyCircuitBreaker; +use PrestaShop\CircuitBreaker\System\MainSystem; +use PrestaShop\CircuitBreaker\Transition\NullDispatcher; +use Symfony\Component\Cache\Simple\ArrayCache; +use Symfony\Component\EventDispatcher\EventDispatcher; + +/** + * @group symfony-http-client + */ +class CircuitBreakerWorkflowTest extends CircuitBreakerTestCase +{ + public const OPEN_THRESHOLD = 1; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + parent::setUp(); + + //For SimpleCircuitBreaker tests we need to clear the storage cache because it is stored in a static variable + $storage = new SimpleArray(); + $storage->clear(); + } + + /** + * When we use the circuit breaker on unreachable service + * the fallback response is used. + * + * @dataProvider getCircuitBreakers + * + * @param CircuitBreakerInterface $circuitBreaker + */ + public function testCircuitBreakerIsInClosedStateAtStart($circuitBreaker): void + { + $this->assertSame(State::CLOSED_STATE, $circuitBreaker->getState()); + } + + /** + * Once the number of failures is reached, the circuit breaker + * is open. This time no calls to the services are done. + * + * @dataProvider getCircuitBreakers + * + * @param CircuitBreakerInterface $circuitBreaker + */ + public function testCircuitBreakerWillBeOpenInCaseOfFailures($circuitBreaker): void + { + // CLOSED + $this->assertSame(State::CLOSED_STATE, $circuitBreaker->getState()); + $response = $circuitBreaker->call('https://httpbin.org/get/foo', [], $this->createFallbackResponse()); + $this->assertSame('{}', $response); + + //After two failed calls switch to OPEN state + $this->assertSame(State::OPEN_STATE, $circuitBreaker->getState()); + $this->assertSame( + '{}', + $circuitBreaker->call( + 'https://httpbin.org/get/foo', + [], + $this->createFallbackResponse() + ) + ); + } + + /** + * Once the number of failures is reached, the circuit breaker + * is open. This time no calls to the services are done. + * + * @dataProvider getCircuitBreakers + * + * @param CircuitBreakerInterface $circuitBreaker + */ + public function testCircuitBreakerWillBeOpenWithoutFallback($circuitBreaker): void + { + // CLOSED + $this->assertSame(State::CLOSED_STATE, $circuitBreaker->getState()); + $response = $circuitBreaker->call('https://httpbin.org/get/foo'); + $this->assertSame('', $response); + + //After two failed calls switch to OPEN state + $this->assertSame(State::OPEN_STATE, $circuitBreaker->getState()); + $this->assertSame( + '{}', + $circuitBreaker->call( + 'https://httpbin.org/get/foo', + [], + $this->createFallbackResponse() + ) + ); + } + + /** + * In HalfOpen state, if the service is back we can + * close the CircuitBreaker. + * + * @dataProvider getCircuitBreakers + * + * @param CircuitBreakerInterface $circuitBreaker + */ + public function testOnceInHalfOpenModeServiceIsFinallyReachable($circuitBreaker): void + { + // CLOSED - first call fails (twice) + $this->assertSame(State::CLOSED_STATE, $circuitBreaker->getState()); + $response = $circuitBreaker->call('https://httpbin.org/get/foo', [], $this->createFallbackResponse()); + $this->assertSame('{}', $response); + $this->assertSame(State::OPEN_STATE, $circuitBreaker->getState()); + + // OPEN - no call to client + $response = $circuitBreaker->call('https://httpbin.org/get/foo', [], $this->createFallbackResponse()); + $this->assertSame('{}', $response); + $this->assertSame(State::OPEN_STATE, $circuitBreaker->getState()); + + sleep(2 * self::OPEN_THRESHOLD); + // SWITCH TO HALF OPEN - retry to call the service + $this->assertSame( + '{"hello": "world"}', + $circuitBreaker->call( + 'https://httpbin.org/get/foo', + [], + $this->createFallbackResponse() + ) + ); + $this->assertSame(State::CLOSED_STATE, $circuitBreaker->getState()); + $this->assertTrue($circuitBreaker->isClosed()); + } + + /** + * This is not useful for SimpleCircuitBreaker since it has a SimpleArray storage + */ + public function testRememberLastTransactionState(): void + { + $system = new MainSystem( + new ClosedPlace(1, 0.2, 0), + new HalfOpenPlace(0, 0.2, 0), + new OpenPlace(0, 0, 1) + ); + $storage = new SymfonyCache(new ArrayCache()); + $client = $this->createMock(SymfonyHttpClient::class); + $client + ->expects($this->once()) + ->method('request') + ->willThrowException(new UnavailableServiceException()) + ; + + $firstCircuitBreaker = new AdvancedCircuitBreaker( + $system, + $client, + $storage, + new NullDispatcher() + ); + $this->assertEquals(State::CLOSED_STATE, $firstCircuitBreaker->getState()); + $firstCircuitBreaker->call('fake_service', [], function () { + return false; + }); + $this->assertEquals(State::OPEN_STATE, $firstCircuitBreaker->getState()); + $this->assertTrue($storage->hasTransaction('fake_service')); + + $secondCircuitBreaker = new AdvancedCircuitBreaker( + $system, + $client, + $storage, + new NullDispatcher() + ); + $this->assertEquals(State::CLOSED_STATE, $secondCircuitBreaker->getState()); + $secondCircuitBreaker->call('fake_service', [], function () { + return false; + }); + $this->assertEquals(State::OPEN_STATE, $secondCircuitBreaker->getState()); + } + + /** + * Return the list of supported circuit breakers + */ + public function getCircuitBreakers(): array + { + return [ + 'simple' => [$this->createSimpleCircuitBreaker()], + 'symfony' => [$this->createSymfonyCircuitBreaker()], + 'advanced' => [$this->createAdvancedCircuitBreaker()], + ]; + } + + /** + * @return SimpleCircuitBreaker the circuit breaker for testing purposes + */ + private function createSimpleCircuitBreaker(): SimpleCircuitBreaker + { + return new SimpleCircuitBreaker( + new OpenPlace(0, 0, self::OPEN_THRESHOLD), // threshold 1s + new HalfOpenPlace(0, 0.2, 0), // timeout 0.2s to test the service + new ClosedPlace(2, 0.2, 0), // 2 failures allowed, 0.2s timeout + $this->getTestClient() + ); + } + + /** + * @return AdvancedCircuitBreaker the circuit breaker for testing purposes + */ + private function createAdvancedCircuitBreaker(): AdvancedCircuitBreaker + { + $system = new MainSystem( + new ClosedPlace(2, 0.2, 0), + new HalfOpenPlace(0, 0.2, 0), + new OpenPlace(0, 0, self::OPEN_THRESHOLD) + ); + + $symfonyCache = new SymfonyCache(new ArrayCache()); + + return new AdvancedCircuitBreaker( + $system, + $this->getTestClient(), + $symfonyCache, + new NullDispatcher() + ); + } + + /** + * @return SymfonyCircuitBreaker the circuit breaker for testing purposes + */ + private function createSymfonyCircuitBreaker(): SymfonyCircuitBreaker + { + $system = new MainSystem( + new ClosedPlace(2, 0.2, 0), + new HalfOpenPlace(0, 0.2, 0), + new OpenPlace(0, 0, self::OPEN_THRESHOLD) + ); + + $symfonyCache = new SymfonyCache(new ArrayCache()); + $eventDispatcherS = $this->createMock(EventDispatcher::class); + + return new SymfonyCircuitBreaker( + $system, + $this->getTestClient(), + $symfonyCache, + $eventDispatcherS + ); + } + + /** + * @return callable the fallback callable + */ + private function createFallbackResponse(): callable + { + return function () { + return '{}'; + }; + } +} diff --git a/tests/Implementation/SymfonyHttpClient/SimpleCircuitBreakerFactoryTest.php b/tests/Implementation/SymfonyHttpClient/SimpleCircuitBreakerFactoryTest.php new file mode 100644 index 0000000..c113b24 --- /dev/null +++ b/tests/Implementation/SymfonyHttpClient/SimpleCircuitBreakerFactoryTest.php @@ -0,0 +1,79 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ + +declare(strict_types=1); + +namespace Tests\PrestaShop\CircuitBreaker\Implementation\SymfonyHttpClient; + +use PHPUnit\Framework\TestCase; +use PrestaShop\CircuitBreaker\Contract\FactorySettingsInterface; +use PrestaShop\CircuitBreaker\FactorySettings; +use PrestaShop\CircuitBreaker\SimpleCircuitBreaker; +use PrestaShop\CircuitBreaker\SimpleCircuitBreakerFactory; + +/** + * @group symfony-http-client + */ +class SimpleCircuitBreakerFactoryTest extends TestCase +{ + public function testCreation(): void + { + $factory = new SimpleCircuitBreakerFactory(); + + $this->assertInstanceOf(SimpleCircuitBreakerFactory::class, $factory); + } + + /** + * @depends testCreation + * @dataProvider getSettings + * + * @param FactorySettingsInterface $settings the Circuit Breaker settings + */ + public function testCircuitBreakerCreation(FactorySettingsInterface $settings): void + { + $factory = new SimpleCircuitBreakerFactory(); + $circuitBreaker = $factory->create($settings); + + $this->assertInstanceOf(SimpleCircuitBreaker::class, $circuitBreaker); + } + + public function getSettings(): array + { + return [ + [ + (new FactorySettings(2, 0.1, 10)) + ->setStrippedTimeout(0.2) + ->setStrippedFailures(1), + ], + [ + (new FactorySettings(2, 0.1, 10)) + ->setStrippedTimeout(0.2) + ->setStrippedFailures(1) + ->setClientOptions(['proxy' => '192.168.16.1:10']), + ], + ]; + } +} diff --git a/tests/Place/ClosedPlaceTest.php b/tests/Place/ClosedPlaceTest.php index 54ec5b7..5b09dbc 100644 --- a/tests/Place/ClosedPlaceTest.php +++ b/tests/Place/ClosedPlaceTest.php @@ -31,6 +31,9 @@ use PrestaShop\CircuitBreaker\Place\ClosedPlace; use PrestaShop\CircuitBreaker\State; +/** + * @group common + */ class ClosedPlaceTest extends PlaceTestCase { /** diff --git a/tests/Place/HalfOpenPlaceTest.php b/tests/Place/HalfOpenPlaceTest.php index d90523f..d251e6f 100644 --- a/tests/Place/HalfOpenPlaceTest.php +++ b/tests/Place/HalfOpenPlaceTest.php @@ -32,6 +32,9 @@ use PrestaShop\CircuitBreaker\Place\HalfOpenPlace; use PrestaShop\CircuitBreaker\State; +/** + * @group common + */ class HalfOpenPlaceTest extends PlaceTestCase { /** diff --git a/tests/Place/OpenPlaceTest.php b/tests/Place/OpenPlaceTest.php index 97a4b4f..c255e57 100644 --- a/tests/Place/OpenPlaceTest.php +++ b/tests/Place/OpenPlaceTest.php @@ -32,6 +32,9 @@ use PrestaShop\CircuitBreaker\Place\OpenPlace; use PrestaShop\CircuitBreaker\State; +/** + * @group common + */ class OpenPlaceTest extends PlaceTestCase { /** diff --git a/tests/Place/PlaceTestCase.php b/tests/Place/PlaceTestCase.php index de0b70c..1ae4587 100644 --- a/tests/Place/PlaceTestCase.php +++ b/tests/Place/PlaceTestCase.php @@ -31,6 +31,8 @@ /** * Helper to share fixtures accross Place tests. + * + * @group common */ class PlaceTestCase extends TestCase { diff --git a/tests/Storage/DoctrineCacheTest.php b/tests/Storage/DoctrineCacheTest.php index 585d43f..c7afc23 100644 --- a/tests/Storage/DoctrineCacheTest.php +++ b/tests/Storage/DoctrineCacheTest.php @@ -35,6 +35,9 @@ use PrestaShop\CircuitBreaker\Exception\TransactionNotFoundException; use PrestaShop\CircuitBreaker\Storage\DoctrineCache; +/** + * @group common + */ class DoctrineCacheTest extends TestCase { /** diff --git a/tests/Storage/SimpleArrayTest.php b/tests/Storage/SimpleArrayTest.php index c3add9e..56a281a 100644 --- a/tests/Storage/SimpleArrayTest.php +++ b/tests/Storage/SimpleArrayTest.php @@ -33,6 +33,9 @@ use PrestaShop\CircuitBreaker\Exception\TransactionNotFoundException; use PrestaShop\CircuitBreaker\Storage\SimpleArray; +/** + * @group common + */ class SimpleArrayTest extends TestCase { /** diff --git a/tests/Storage/SymfonyCacheTest.php b/tests/Storage/SymfonyCacheTest.php index 602b215..8e88a6d 100644 --- a/tests/Storage/SymfonyCacheTest.php +++ b/tests/Storage/SymfonyCacheTest.php @@ -34,6 +34,9 @@ use PrestaShop\CircuitBreaker\Storage\SymfonyCache; use Symfony\Component\Cache\Simple\FilesystemCache; +/** + * @group common + */ class SymfonyCacheTest extends TestCase { /** diff --git a/tests/SymfonyCircuitBreakerEventsTest.php b/tests/SymfonyCircuitBreakerEventsTest.php index 57fc75c..8455c19 100644 --- a/tests/SymfonyCircuitBreakerEventsTest.php +++ b/tests/SymfonyCircuitBreakerEventsTest.php @@ -37,7 +37,11 @@ use PrestaShop\CircuitBreaker\System\MainSystem; use Symfony\Component\Cache\Simple\ArrayCache; use Symfony\Component\EventDispatcher\EventDispatcher; +use Tests\PrestaShop\CircuitBreaker\Implementation\SymfonyHttpClient\CircuitBreakerTestCase; +/** + * @group common + */ class SymfonyCircuitBreakerEventsTest extends CircuitBreakerTestCase { /** diff --git a/tests/System/MainSystemTest.php b/tests/System/MainSystemTest.php index ec03b2b..a67f4ec 100644 --- a/tests/System/MainSystemTest.php +++ b/tests/System/MainSystemTest.php @@ -36,6 +36,9 @@ use PrestaShop\CircuitBreaker\State; use PrestaShop\CircuitBreaker\System\MainSystem; +/** + * @group common + */ class MainSystemTest extends TestCase { public function testCreation(): void diff --git a/tests/Transaction/SimpleTransactionTest.php b/tests/Transaction/SimpleTransactionTest.php index 8a753d7..583dd5d 100644 --- a/tests/Transaction/SimpleTransactionTest.php +++ b/tests/Transaction/SimpleTransactionTest.php @@ -34,6 +34,9 @@ use PrestaShop\CircuitBreaker\Contract\PlaceInterface; use PrestaShop\CircuitBreaker\Transaction\SimpleTransaction; +/** + * @group common + */ class SimpleTransactionTest extends TestCase { public function testCreation(): void diff --git a/tests/Util/AssertTest.php b/tests/Util/AssertTest.php index 3efb8a2..d99c8b2 100644 --- a/tests/Util/AssertTest.php +++ b/tests/Util/AssertTest.php @@ -32,6 +32,9 @@ use PrestaShop\CircuitBreaker\Util\Assert; use stdClass; +/** + * @group common + */ class AssertTest extends TestCase { /** From a38e6351f7e4d85e48e4cc07a3142d96fa6061cd Mon Sep 17 00:00:00 2001 From: boherm Date: Mon, 6 Nov 2023 11:46:47 +0100 Subject: [PATCH 4/4] Update docs --- README.md | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 549e2f3..35a628c 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,29 @@ composer require prestashop/circuit-breaker ## Use +### Symfony Http Client and Guzzle Client implementations + +By default, Circuit Breaker use the Symfony Http Client library, and all the client options are described in the [official documentation](https://symfony.com/doc/current/http_client.html). + +For retro-compatibility, we let you use Guzzle Client instead of Symfony Http Client. To use Guzzle, you need to set the Guzzle client with `setClient()` of the settings factory, like this example below: + +```php +use PrestaShop\CircuitBreaker\SimpleCircuitBreakerFactory; +use PrestaShop\CircuitBreaker\FactorySettings; +use PrestaShop\CircuitBreaker\Client\GuzzleClient + +$circuitBreakerFactory = new SimpleCircuitBreakerFactory(); +$factorySettings = new FactorySettings(2, 0.1, 10); +$factorySettings->setClient(new GuzzleHttpClient()); + +$circuitBreaker = $circuitBreakerFactory->create($factorySettings); +``` + +Be aware, that the client options depend on the client implementation you choose! + +> For the Guzzle implementation, the Client options are described +> in the [HttpGuzzle documentation](http://docs.guzzlephp.org/en/stable/index.html). + ### Simple Circuit Breaker You can use the factory to create a simple circuit breaker. @@ -71,9 +94,6 @@ $circuitBreaker = $circuitBreakerFactory->create($settings); $response = $circuitBreaker->call('https://api.domain.com/create/user', ['body' => ['firstname' => 'John', 'lastname' => 'Doe']]); ``` -> For the Guzzle implementation, the Client options are described -> in the [HttpGuzzle documentation](http://docs.guzzlephp.org/en/stable/index.html). - ### Advanced Circuit Breaker If you need more control on your circuit breaker, you should use the `AdvancedCircuitBreaker` which manages more features: