From d905ce145067376d9d6d1d7e25dadd1612b10bd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Glawaty?= Date: Tue, 21 Nov 2023 06:39:54 +0100 Subject: [PATCH] Nette FW integration, Tests - added compiler extension `AmpClientExtension` for Nette DIC - added cache storage implementation `NetteCacheStorage` - fixed errors in codebase - added more tests - added PHP extension `uopz` for images in Dockerfile (for testing purposes only) - added dev dependencies on `nette/bootstrap`, `nette/caching`, `nette/di`, `psr/log` and `slope-it/clock-mock` - added GH action for code coverage --- .github/workflows/coding-style.yml | 2 + .github/workflows/coverage.yml | 40 ++ .github/workflows/tests.yml | 1 + Dockerfile | 12 +- composer.json | 5 + src/AmpClient.php | 5 +- src/Bridge/Nette/DI/AmpClientExtension.php | 190 ++++++ .../Nette/DI/Config/AmpClientConfig.php | 29 + src/Bridge/Nette/DI/Config/CacheConfig.php | 17 + src/Bridge/Nette/DI/Config/HttpConfig.php | 11 + src/Bridge/Nette/NetteCacheStorage.php | 93 +++ src/ClientConfig.php | 4 +- src/Http/Cache/CacheControlHeader.php | 5 + src/Http/Cache/CachedResponse.php | 5 + src/Http/HttpClient.php | 8 +- tests/AmpClientTest.php | 579 ++++++++++++++++++ .../Nette/DI/AmpClientExtensionTest.php | 157 +++++ tests/Bridge/Nette/DI/ContainerFactory.php | 36 ++ tests/Bridge/Nette/DI/config.minimal.neon | 6 + tests/Bridge/Nette/DI/config.withCache.neon | 10 + .../Nette/DI/config.withDefaultResources.neon | 11 + .../Nette/DI/config.withGuzzleConfig.neon | 9 + tests/Bridge/Nette/DI/config.withLocale.neon | 7 + tests/Bridge/Nette/DI/config.withMethod.neon | 7 + tests/Bridge/Nette/DI/config.withOrigin.neon | 7 + tests/Bridge/Nette/DI/config.withVersion.neon | 7 + tests/Http/Cache/CacheControlHeaderTest.php | 2 + tests/Http/Cache/EtagTest.php | 10 +- tests/Http/Cache/ExpirationTest.php | 54 +- tests/Http/Cache/InMemoryCacheStorageTest.php | 103 ++++ tests/Http/Cache/NoCacheStorageTest.php | 36 ++ tests/Http/HttpClientFactoryTest.php | 117 ++++ tests/Http/HttpClientTest.php | 433 +++++++++++++ tests/Http/Middleware/MiddlewareFixture.php | 47 ++ tests/Http/MiddlewaresTest.php | 50 ++ tests/bootstrap.php | 1 + 36 files changed, 2095 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/coverage.yml create mode 100644 src/Bridge/Nette/DI/AmpClientExtension.php create mode 100644 src/Bridge/Nette/DI/Config/AmpClientConfig.php create mode 100644 src/Bridge/Nette/DI/Config/CacheConfig.php create mode 100644 src/Bridge/Nette/DI/Config/HttpConfig.php create mode 100644 src/Bridge/Nette/NetteCacheStorage.php create mode 100644 tests/AmpClientTest.php create mode 100644 tests/Bridge/Nette/DI/AmpClientExtensionTest.php create mode 100644 tests/Bridge/Nette/DI/ContainerFactory.php create mode 100644 tests/Bridge/Nette/DI/config.minimal.neon create mode 100644 tests/Bridge/Nette/DI/config.withCache.neon create mode 100644 tests/Bridge/Nette/DI/config.withDefaultResources.neon create mode 100644 tests/Bridge/Nette/DI/config.withGuzzleConfig.neon create mode 100644 tests/Bridge/Nette/DI/config.withLocale.neon create mode 100644 tests/Bridge/Nette/DI/config.withMethod.neon create mode 100644 tests/Bridge/Nette/DI/config.withOrigin.neon create mode 100644 tests/Bridge/Nette/DI/config.withVersion.neon create mode 100644 tests/Http/Cache/InMemoryCacheStorageTest.php create mode 100644 tests/Http/Cache/NoCacheStorageTest.php create mode 100644 tests/Http/HttpClientFactoryTest.php create mode 100644 tests/Http/HttpClientTest.php create mode 100644 tests/Http/Middleware/MiddlewareFixture.php create mode 100644 tests/Http/MiddlewaresTest.php diff --git a/.github/workflows/coding-style.yml b/.github/workflows/coding-style.yml index 30dfd38..18ca3f6 100644 --- a/.github/workflows/coding-style.yml +++ b/.github/workflows/coding-style.yml @@ -23,6 +23,7 @@ jobs: with: php-version: 7.4 tools: composer:v2 + extensions: uopz - name: Install dependencies run: composer update --no-progress --prefer-dist --prefer-stable --optimize-autoloader --quiet @@ -42,6 +43,7 @@ jobs: with: php-version: 7.4 tools: composer:v2 + extensions: uopz - name: Install dependencies run: composer update --no-progress --prefer-dist --prefer-stable --optimize-autoloader --quiet diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..a57c546 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,40 @@ +name: Coverage + +on: + push: + branches: + - main + tags: + - v* + pull_request: + branches: + - main + +jobs: + coverage: + name: Coverage + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + coverage: pcov + extensions: tokenizer, uopz + tools: composer:v2 + + - name: Install dependencies + run: composer update --no-progress --prefer-dist --prefer-stable --optimize-autoloader + + - name: Generate the coverage report + run: vendor/bin/tester -C -s --coverage ./coverage.xml --coverage-src ./src ./tests + + - name: Upload the coverage report + env: + COVERALLS_REPO_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + run: | + wget https://github.com/php-coveralls/php-coveralls/releases/download/v2.5.3/php-coveralls.phar + php php-coveralls.phar --verbose --config tests/.coveralls.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f9223f5..eecce61 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,6 +26,7 @@ jobs: with: php-version: ${{ matrix.php-versions }} tools: composer:v2 + extensions: uopz - name: Install dependencies run: composer update --no-progress --prefer-dist --prefer-stable --optimize-autoloader diff --git a/Dockerfile b/Dockerfile index a9a2061..41d9c94 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,8 @@ RUN apk add --no-cache --update git RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer RUN apk add --no-cache ${PHPIZE_DEPS} \ && pecl install pcov \ - && docker-php-ext-enable pcov + && pecl install uopz-6.1.2 \ + && docker-php-ext-enable pcov uopz CMD tail -f /dev/null @@ -20,7 +21,8 @@ RUN apk add --no-cache --update git RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer RUN apk add --no-cache ${PHPIZE_DEPS} \ && pecl install pcov \ - && docker-php-ext-enable pcov + && pecl install uopz-7.1.1 \ + && docker-php-ext-enable pcov uopz CMD tail -f /dev/null @@ -33,7 +35,8 @@ RUN apk add --no-cache --update git RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer RUN apk add --no-cache ${PHPIZE_DEPS} \ && pecl install pcov \ - && docker-php-ext-enable pcov + && pecl install uopz-7.1.1 \ + && docker-php-ext-enable pcov uopz CMD tail -f /dev/null @@ -46,6 +49,7 @@ RUN apk add --no-cache --update git RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer RUN apk add --no-cache ${PHPIZE_DEPS} \ && pecl install pcov \ - && docker-php-ext-enable pcov + && pecl install uopz-7.1.1 \ + && docker-php-ext-enable pcov uopz CMD tail -f /dev/null diff --git a/composer.json b/composer.json index d3f292b..5412605 100644 --- a/composer.json +++ b/composer.json @@ -18,10 +18,15 @@ "friendsofphp/php-cs-fixer": "^3.17", "kubawerlos/php-cs-fixer-custom-fixers": "^3.14", "mockery/mockery": "^1.6", + "nette/bootstrap": "^3.0", + "nette/caching": "^3.1", + "nette/di": "^3.0", "nette/tester": "^2.4", "phpstan/phpstan": "^1.10", "phpstan/phpstan-nette": "^1.2", + "psr/log": "^1.1", "roave/security-advisories": "dev-latest", + "slope-it/clock-mock": "^0.4.0", "symplify/phpstan-rules": "12.0.2.72" }, "config": { diff --git a/src/AmpClient.php b/src/AmpClient.php index d8c34ce..bc2d648 100644 --- a/src/AmpClient.php +++ b/src/AmpClient.php @@ -22,6 +22,7 @@ use SixtyEightPublishers\AmpClient\Response\BannersResponse; use SixtyEightPublishers\AmpClient\Response\Hydrator\BannersResponseHydratorHandler; use SixtyEightPublishers\AmpClient\Response\Hydrator\ResponseHydrator; +use stdClass; use function array_map; use function count; use function sprintf; @@ -110,10 +111,12 @@ public function fetchBanners(BannersRequest $request): BannersResponse foreach ($positions as $position) { $position = $position->withResources($defaultResources); - $queryParam[$position->getCode()] = array_map( + $resources = array_map( static fn (BannerResource $resource): array => $resource->getValues(), $position->getResources(), ); + + $queryParam[$position->getCode()] = 0 >= count($resources) ? new stdClass() : $resources; } $options = [ diff --git a/src/Bridge/Nette/DI/AmpClientExtension.php b/src/Bridge/Nette/DI/AmpClientExtension.php new file mode 100644 index 0000000..a0fb517 --- /dev/null +++ b/src/Bridge/Nette/DI/AmpClientExtension.php @@ -0,0 +1,190 @@ + Expect::anyOf(...ClientConfig::Methods) + ->dynamic(), + 'url' => Expect::string() + ->required() + ->dynamic(), + 'channel' => Expect::string() + ->required() + ->dynamic(), + 'version' => Expect::anyOf(...ClientConfig::Versions) + ->dynamic(), + 'locale' => Expect::string() + ->nullable() + ->dynamic(), + 'default_resources' => Expect::arrayOf( + Expect::anyOf(Expect::string(), Expect::listOf('string')), + Expect::string(), + ), + 'origin' => Expect::string() + ->nullable() + ->dynamic(), + 'cache' => Expect::structure([ + 'storage' => Expect::anyOf(Expect::string(), Expect::type(Statement::class)) + ->nullable() + ->before(static function ($factory): Statement { + return $factory instanceof Statement ? $factory : new Statement($factory); + }), + 'expiration' => Expect::anyOf(Expect::string(), Expect::int())->dynamic(), + 'cache_control_header_override' => Expect::string() + ->nullable(), + ])->castTo(CacheConfig::class), + 'http' => Expect::structure([ + 'guzzle_config' => Expect::array(), + ])->castTo(HttpConfig::class), + ])->castTo(AmpClientConfig::class); + } + + public function loadConfiguration(): void + { + $builder = $this->getContainerBuilder(); + $config = $this->getConfig(); + assert($config instanceof AmpClientConfig); + + $builder->addDefinition($this->prefix('config')) + ->setAutowired(false) + ->setType(ClientConfig::class) + ->setCreator($this->createClientConfigCreator($config)); + + $cacheStorageCreator = null === $config->cache->storage + ? new Statement(NoCacheStorage::class) + : new Statement(NetteCacheStorage::class, [ + 'storage' => $config->cache->storage, + ]); + + $builder->addDefinition($this->prefix('cacheStorage')) + ->setAutowired(false) + ->setType(CacheStorageInterface::class) + ->setCreator($cacheStorageCreator); + + $builder->addDefinition($this->prefix('responseHydrator')) + ->setAutowired(false) + ->setType(ResponseHydratorInterface::class) + ->setCreator(ResponseHydrator::class); + + $builder->addDefinition($this->prefix('responseHydrator.handler.bannersRequest')) + ->setAutowired(false) + ->setType(ResponseHydratorHandlerInterface::class) + ->setCreator(BannersResponseHydratorHandler::class); + + $builder->addDefinition($this->prefix('httpClientFactory')) + ->setAutowired(false) + ->setType(HttpClientFactoryInterface::class) + ->setCreator(HttpClientFactory::class, [ + 'responseHydrator' => $this->prefix('@responseHydrator'), + 'guzzleClientConfig' => $config->http->guzzle_config, + ]); + + $builder->addDefinition($this->prefix('ampClient')) + ->setType(AmpClientInterface::class) + ->setCreator(AmpClient::class, [ + 'config' => $this->prefix('@config'), + 'httpClientFactory' => $this->prefix('@httpClientFactory'), + 'cacheStorage' => $this->prefix('@cacheStorage'), + ]); + } + + public function beforeCompile(): void + { + $builder = $this->getContainerBuilder(); + + $responseHydratorHandlers = $builder->findByType(ResponseHydratorHandlerInterface::class); + $responseHydratorService = $builder->getDefinition($this->prefix('responseHydrator')); + assert($responseHydratorService instanceof ServiceDefinition); + + $responseHydratorService->setArgument('handlers', array_values($responseHydratorHandlers)); + } + + private function createClientConfigCreator(AmpClientConfig $config): Statement + { + $clientConfigFactory = new Statement([ClientConfig::class, 'create'], [ + 'url' => $config->url, + 'channel' => $config->channel, + ]); + + if (null !== $config->method) { + $clientConfigFactory = new Statement([$clientConfigFactory, 'withMethod'], [ + 'method' => $config->method, + ]); + } + + if (null !== $config->version) { + $clientConfigFactory = new Statement([$clientConfigFactory, 'withVersion'], [ + 'version' => $config->version, + ]); + } + + if (null !== $config->locale) { + $clientConfigFactory = new Statement([$clientConfigFactory, 'withLocale'], [ + 'locale' => $config->locale, + ]); + } + + if (0 < count($config->default_resources)) { + $defaultResources = []; + + foreach ($config->default_resources as $resourceCode => $resourceValues) { + $defaultResources[] = new Statement(BannerResource::class, [$resourceCode, $resourceValues]); + } + + $clientConfigFactory = new Statement([$clientConfigFactory, 'withDefaultResources'], [ + 'resources' => $defaultResources, + ]); + } + + if (null !== $config->origin) { + $clientConfigFactory = new Statement([$clientConfigFactory, 'withOrigin'], [ + 'origin' => $config->origin, + ]); + } + + if (null !== $config->cache->expiration) { + $clientConfigFactory = new Statement([$clientConfigFactory, 'withCacheExpiration'], [ + 'cacheExpiration' => $config->cache->expiration, + ]); + } + + if (null !== $config->cache->cache_control_header_override) { + $clientConfigFactory = new Statement([$clientConfigFactory, 'withCacheControlHeaderOverride'], [ + 'cacheControlHeaderOverride' => $config->cache->cache_control_header_override, + ]); + } + + return $clientConfigFactory; + } +} diff --git a/src/Bridge/Nette/DI/Config/AmpClientConfig.php b/src/Bridge/Nette/DI/Config/AmpClientConfig.php new file mode 100644 index 0000000..573cbac --- /dev/null +++ b/src/Bridge/Nette/DI/Config/AmpClientConfig.php @@ -0,0 +1,29 @@ + */ + public array $default_resources = []; + + public ?string $origin = null; + + public CacheConfig $cache; + + public HttpConfig $http; +} diff --git a/src/Bridge/Nette/DI/Config/CacheConfig.php b/src/Bridge/Nette/DI/Config/CacheConfig.php new file mode 100644 index 0000000..e968b06 --- /dev/null +++ b/src/Bridge/Nette/DI/Config/CacheConfig.php @@ -0,0 +1,17 @@ + */ + public array $guzzle_config = []; +} diff --git a/src/Bridge/Nette/NetteCacheStorage.php b/src/Bridge/Nette/NetteCacheStorage.php new file mode 100644 index 0000000..5591f6c --- /dev/null +++ b/src/Bridge/Nette/NetteCacheStorage.php @@ -0,0 +1,93 @@ +cache = new Cache($storage, self::class); + $this->logger = $logger; + } + + public function get(CacheKey $key): ?CachedResponse + { + try { + $response = $this->cache->load($key->getValue()); + + if (!$response instanceof CachedResponse) { + $this->delete($key); + + return null; + } + + return $response; + } catch (Throwable $e) { + if (null !== $this->logger) { + $this->logger->error('[AMP] Unable to load response from cache: ' . $e->getMessage(), [ + 'exception' => $e, + ]); + } + + return null; + } + } + + public function save(CachedResponse $response, Expiration $expiration): void + { + try { + $this->cache->save($response->getKey()->getValue(), $response, [ + Cache::EXPIRE => $expiration->getValue(), + ]); + } catch (Throwable $e) { + if (null !== $this->logger) { + $this->logger->error('[AMP] Unable to save response to cache: ' . $e->getMessage(), [ + 'exception' => $e, + ]); + } + } + } + + public function delete(CacheKey $key): void + { + try { + $this->cache->remove($key->getValue()); + } catch (Throwable $e) { + if (null !== $this->logger) { + $this->logger->error('[AMP] Unable to delete response from cache: ' . $e->getMessage(), [ + 'exception' => $e, + ]); + } + } + } + + public function clear(): void + { + try { + $this->cache->clean([ + Cache::ALL, + ]); + } catch (Throwable $e) { + if (null !== $this->logger) { + $this->logger->error('[AMP] Unable to clear cache: ' . $e->getMessage(), [ + 'exception' => $e, + ]); + } + } + } +} diff --git a/src/ClientConfig.php b/src/ClientConfig.php index 8c6a97f..1fa064d 100644 --- a/src/ClientConfig.php +++ b/src/ClientConfig.php @@ -31,12 +31,12 @@ final class ClientConfig public const Version1 = 1; - private const Methods = [ + public const Methods = [ self::MethodGet, self::MethodPost, ]; - private const Versions = [ + public const Versions = [ self::Version1, ]; diff --git a/src/Http/Cache/CacheControlHeader.php b/src/Http/Cache/CacheControlHeader.php index 4e98c00..43d8bf9 100644 --- a/src/Http/Cache/CacheControlHeader.php +++ b/src/Http/Cache/CacheControlHeader.php @@ -57,6 +57,11 @@ public function get(string $key, string $default = ''): string return $default; } + public function isEmpty(): bool + { + return 0 >= count($this->values); + } + /** * @return array */ diff --git a/src/Http/Cache/CachedResponse.php b/src/Http/Cache/CachedResponse.php index 1cc64af..bc7f648 100644 --- a/src/Http/Cache/CachedResponse.php +++ b/src/Http/Cache/CachedResponse.php @@ -41,6 +41,11 @@ public function getMaxAge(): Expiration return $this->maxAge; } + public function isFresh(): bool + { + return $this->getMaxAge()->isFresh(); + } + public function getEtag(): ?Etag { return $this->etag; diff --git a/src/Http/HttpClient.php b/src/Http/HttpClient.php index dd7b1db..dbe1389 100644 --- a/src/Http/HttpClient.php +++ b/src/Http/HttpClient.php @@ -57,7 +57,7 @@ public function __construct( */ public function request(HttpRequest $request, string $responseClassname): object { - $url = $this->baseUrl . '/' . ltrim($request->getUrl()); + $url = $this->baseUrl . '/' . ltrim($request->getUrl(), '/'); $cacheComponents = $request->getCacheComponents(); if (null !== $cacheComponents) { @@ -69,7 +69,7 @@ public function request(HttpRequest $request, string $responseClassname): object $cachedResponse = null !== $cacheKey ? $this->cacheStorage->get($cacheKey) : null; if (null !== $cachedResponse) { - if ($cachedResponse->getMaxAge()->isFresh()) { + if ($cachedResponse->isFresh()) { assert($cachedResponse->getResponse() instanceof $responseClassname); return $cachedResponse->getResponse(); @@ -95,7 +95,7 @@ public function request(HttpRequest $request, string $responseClassname): object $cacheControlHeader = $this->cacheControl->getCacheControlHeaderOverride() ?? CacheControlHeader::fromResponse($response); $etag = Etag::fromResponse($response); - $canBeStored = !$cacheControlHeader->has('no-store'); + $canBeStored = !$cacheControlHeader->isEmpty() && !$cacheControlHeader->has('no-store'); $maxAge = 0; if ($canBeStored && !$cacheControlHeader->has('no-cache')) { @@ -121,6 +121,8 @@ public function request(HttpRequest $request, string $responseClassname): object ); $this->cacheStorage->save($cachedResponse, $this->cacheControl->createExpiration()); + } elseif (!$canBeStored && null !== $cachedResponse && null !== $cacheKey) { + $this->cacheStorage->delete($cacheKey); } return $mappedResponse; diff --git a/tests/AmpClientTest.php b/tests/AmpClientTest.php new file mode 100644 index 0000000..f37ba38 --- /dev/null +++ b/tests/AmpClientTest.php @@ -0,0 +1,579 @@ +getConfig()); + Assert::type(NoCacheStorage::class, $client->getCacheStorage()); + + call_user_func(Closure::bind(static function () use ($client) { + Assert::equal( + new HttpClientFactory( + new ResponseHydrator([ + new BannersResponseHydratorHandler(), + ]), + [], + ), + $client->httpClientFactory, + ); + }, null, AmpClient::class)); + + $this->assertHttpClientIsNull($client); + } + + public function testClientImmutability(): void + { + $config1 = ClientConfig::create('https://www.example.com', 'test'); + $cacheStorage1 = Mockery::mock(CacheStorageInterface::class); + + $client1 = new AmpClient( + $config1, + Mockery::mock(HttpClientFactoryInterface::class), + $cacheStorage1, + ); + + $config2 = $config1->withChannel('demo'); + $cacheStorage2 = Mockery::mock(CacheStorageInterface::class); + + $client2 = $client1->withConfig($config2); + $client3 = $client2->withCacheStorage($cacheStorage2); + + Assert::notSame($client2, $client1); + Assert::notSame($client3, $client2); + + Assert::same($config1, $client1->getConfig()); + Assert::same($cacheStorage1, $client1->getCacheStorage()); + + Assert::same($config2, $client2->getConfig()); + Assert::same($cacheStorage1, $client2->getCacheStorage()); + + Assert::same($config2, $client3->getConfig()); + Assert::same($cacheStorage2, $client3->getCacheStorage()); + } + + public function testEmptyBannersResourceShouldBeReturnedWhenEmptyBannersRequestPassed(): void + { + $client = new AmpClient( + ClientConfig::create('https://www.example.com', 'test'), + Mockery::mock(HttpClientFactoryInterface::class), + Mockery::mock(CacheStorageInterface::class), + ); + + $request = new BannersRequest([]); + + Assert::equal(new BannersResponse([]), $client->fetchBanners($request)); + } + + /** + * @dataProvider bannersFetchParametersDataProvider + */ + public function testBannersFetching( + ClientConfig $config, + string $expectedBaseUrl, + Middlewares $expectedMiddlewares, + CacheControl $expectedCacheControl, + BannersRequest $bannersRequest, + HttpRequest $expectedHttpRequest + ): void { + $httpClientFactory = Mockery::mock(HttpClientFactoryInterface::class); + $httpClient = Mockery::mock(HttpClientInterface::class); + $cacheStorage = Mockery::mock(CacheStorageInterface::class); + + $client = new AmpClient($config, $httpClientFactory, $cacheStorage); + + $httpClientFactory + ->shouldReceive('create') + ->once() + ->with( + Mockery::type('string'), + Mockery::type(Middlewares::class), + Mockery::type(CacheStorageInterface::class), + Mockery::type(CacheControl::class), + ) + ->andReturnUsing(static function (string $baseUrl, Middlewares $middlewares, CacheStorageInterface $passedCacheStorage, CacheControl $cacheControl) use ($expectedBaseUrl, $expectedMiddlewares, $cacheStorage, $expectedCacheControl, $httpClient): HttpClientInterface { + Assert::same($expectedBaseUrl, $baseUrl); + Assert::equal($expectedMiddlewares, $middlewares); + Assert::same($cacheStorage, $passedCacheStorage); + Assert::equal($expectedCacheControl, $cacheControl); + + return $httpClient; + }); + + $httpClient + ->shouldReceive('request') + ->with(Mockery::type(HttpRequest::class), BannersResponse::class) + ->andReturnUsing(static function (HttpRequest $request) use ($expectedHttpRequest): BannersResponse { + Assert::equal($expectedHttpRequest, $request); + + return new BannersResponse([]); + }); + + Assert::noError(static fn () => $client->fetchBanners($bannersRequest)); + } + + public function bannersFetchParametersDataProvider(): array + { + # 0 => ClientConfig $config + # 1 => string $expectedBaseUrl + # 2 => Middlewares $expectedMiddlewares + # 3 => CacheControl $expectedCacheControl + # 4 => BannersRequest $bannersRequest + # 5 => HttpRequest $expectedHttpRequest + + return [ + 'GET, [Request]: single position, no resources' => [ + 0 => ClientConfig::create('https://www.example.com', 'test'), + 1 => 'https://www.example.com/api/v1', + 2 => new Middlewares([new UnexpectedErrorMiddleware(), new ResponseExceptionMiddleware()]), + 3 => new CacheControl(0, null), + 4 => new BannersRequest([ + new Position('homepage.top'), + ]), + 5 => new HttpRequest( + 'GET', + 'content/test', + [ + 'query' => [ + 'query' => '{"homepage.top":{}}', + ], + 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + ], + [ + 'query' => [ + 'homepage.top' => new stdClass(), + ], + 'locale' => null, + ], + ), + ], + 'POST, [Request]: single position, no resources' => [ + 0 => ClientConfig::create('https://www.example.com', 'test') + ->withMethod('POST'), + 1 => 'https://www.example.com/api/v1', + 2 => new Middlewares([new UnexpectedErrorMiddleware(), new ResponseExceptionMiddleware()]), + 3 => new CacheControl(0, null), + 4 => new BannersRequest([ + new Position('homepage.top'), + ]), + 5 => new HttpRequest( + 'POST', + 'content/test', + [ + 'body' => '{"query":{"homepage.top":{}}}', + 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + ], + [ + 'query' => [ + 'homepage.top' => new stdClass(), + ], + 'locale' => null, + ], + ), + ], + 'GET, [Request]: multiple positions, one with resources' => [ + 0 => ClientConfig::create('https://www.example.com', 'test'), + 1 => 'https://www.example.com/api/v1', + 2 => new Middlewares([new UnexpectedErrorMiddleware(), new ResponseExceptionMiddleware()]), + 3 => new CacheControl(0, null), + 4 => new BannersRequest([ + new Position('homepage.top', [ + new BannerResource('first', 'a'), + new BannerResource('second', ['a', 'b']), + ]), + new Position('homepage.bottom'), + ]), + 5 => new HttpRequest( + 'GET', + 'content/test', + [ + 'query' => [ + 'query' => '{"homepage.top":{"first":["a"],"second":["a","b"]},"homepage.bottom":{}}', + ], + 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + ], + [ + 'query' => [ + 'homepage.top' => [ + 'first' => ['a'], + 'second' => ['a', 'b'], + ], + 'homepage.bottom' => new stdClass(), + ], + 'locale' => null, + ], + ), + ], + 'POST, [Request]: multiple positions, one with resources' => [ + 0 => ClientConfig::create('https://www.example.com', 'test') + ->withMethod('POST'), + 1 => 'https://www.example.com/api/v1', + 2 => new Middlewares([new UnexpectedErrorMiddleware(), new ResponseExceptionMiddleware()]), + 3 => new CacheControl(0, null), + 4 => new BannersRequest([ + new Position('homepage.top', [ + new BannerResource('first', 'a'), + new BannerResource('second', ['a', 'b']), + ]), + new Position('homepage.bottom'), + ]), + 5 => new HttpRequest( + 'POST', + 'content/test', + [ + 'body' => '{"query":{"homepage.top":{"first":["a"],"second":["a","b"]},"homepage.bottom":{}}}', + 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + ], + [ + 'query' => [ + 'homepage.top' => [ + 'first' => ['a'], + 'second' => ['a', 'b'], + ], + 'homepage.bottom' => new stdClass(), + ], + 'locale' => null, + ], + ), + ], + 'GET, [Request]: multiple positions, one with resources, [Config]: default resources' => [ + 0 => ClientConfig::create('https://www.example.com', 'test') + ->withDefaultResources([ + new BannerResource('first', ['a', 'c']), + new BannerResource('third', ['a']), + ]), + 1 => 'https://www.example.com/api/v1', + 2 => new Middlewares([new UnexpectedErrorMiddleware(), new ResponseExceptionMiddleware()]), + 3 => new CacheControl(0, null), + 4 => new BannersRequest([ + new Position('homepage.top', [ + new BannerResource('first', 'a'), + new BannerResource('second', ['a', 'b']), + ]), + new Position('homepage.bottom'), + ]), + 5 => new HttpRequest( + 'GET', + 'content/test', + [ + 'query' => [ + 'query' => '{"homepage.top":{"first":["a","c"],"second":["a","b"],"third":["a"]},"homepage.bottom":{"first":["a","c"],"third":["a"]}}', + ], + 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + ], + [ + 'query' => [ + 'homepage.top' => [ + 'first' => ['a', 'c'], + 'second' => ['a', 'b'], + 'third' => ['a'], + ], + 'homepage.bottom' => [ + 'first' => ['a', 'c'], + 'third' => ['a'], + ], + ], + 'locale' => null, + ], + ), + ], + 'POST, [Request]: multiple positions, one with resources, [Config]: default resources' => [ + 0 => ClientConfig::create('https://www.example.com', 'test') + ->withMethod('POST') + ->withDefaultResources([ + new BannerResource('first', ['a', 'c']), + new BannerResource('third', ['a']), + ]), + 1 => 'https://www.example.com/api/v1', + 2 => new Middlewares([new UnexpectedErrorMiddleware(), new ResponseExceptionMiddleware()]), + 3 => new CacheControl(0, null), + 4 => new BannersRequest([ + new Position('homepage.top', [ + new BannerResource('first', 'a'), + new BannerResource('second', ['a', 'b']), + ]), + new Position('homepage.bottom'), + ]), + 5 => new HttpRequest( + 'POST', + 'content/test', + [ + 'body' => '{"query":{"homepage.top":{"first":["a","c"],"second":["a","b"],"third":["a"]},"homepage.bottom":{"first":["a","c"],"third":["a"]}}}', + 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + ], + [ + 'query' => [ + 'homepage.top' => [ + 'first' => ['a', 'c'], + 'second' => ['a', 'b'], + 'third' => ['a'], + ], + 'homepage.bottom' => [ + 'first' => ['a', 'c'], + 'third' => ['a'], + ], + ], + 'locale' => null, + ], + ), + ], + 'GET, [Request]: single position, no resources, [Config]: origin' => [ + 0 => ClientConfig::create('https://www.example.com', 'test') + ->withOrigin('https://example.io'), + 1 => 'https://www.example.com/api/v1', + 2 => new Middlewares([new UnexpectedErrorMiddleware(), new ResponseExceptionMiddleware(), new XAmpOriginHeaderMiddleware('https://example.io')]), + 3 => new CacheControl(0, null), + 4 => new BannersRequest([ + new Position('homepage.top'), + ]), + 5 => new HttpRequest( + 'GET', + 'content/test', + [ + 'query' => [ + 'query' => '{"homepage.top":{}}', + ], + 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + ], + [ + 'query' => [ + 'homepage.top' => new stdClass(), + ], + 'locale' => null, + ], + ), + ], + 'POST, [Request]: single position, no resources, [Config]: origin' => [ + 0 => ClientConfig::create('https://www.example.com', 'test') + ->withMethod('POST') + ->withOrigin('https://example.io'), + 1 => 'https://www.example.com/api/v1', + 2 => new Middlewares([new UnexpectedErrorMiddleware(), new ResponseExceptionMiddleware(), new XAmpOriginHeaderMiddleware('https://example.io')]), + 3 => new CacheControl(0, null), + 4 => new BannersRequest([ + new Position('homepage.top'), + ]), + 5 => new HttpRequest( + 'POST', + 'content/test', + [ + 'body' => '{"query":{"homepage.top":{}}}', + 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + ], + [ + 'query' => [ + 'homepage.top' => new stdClass(), + ], + 'locale' => null, + ], + ), + ], + 'GET, [Request]: single position, no resources, [Config]: locale' => [ + 0 => ClientConfig::create('https://www.example.com', 'test') + ->withLocale('en'), + 1 => 'https://www.example.com/api/v1', + 2 => new Middlewares([new UnexpectedErrorMiddleware(), new ResponseExceptionMiddleware()]), + 3 => new CacheControl(0, null), + 4 => new BannersRequest([ + new Position('homepage.top'), + ]), + 5 => new HttpRequest( + 'GET', + 'content/test', + [ + 'query' => [ + 'query' => '{"homepage.top":{}}', + 'locale' => 'en', + ], + 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + ], + [ + 'query' => [ + 'homepage.top' => new stdClass(), + ], + 'locale' => 'en', + ], + ), + ], + 'POST, [Request]: single position, no resources, [Config]: locale' => [ + 0 => ClientConfig::create('https://www.example.com', 'test') + ->withMethod('POST') + ->withLocale('en'), + 1 => 'https://www.example.com/api/v1', + 2 => new Middlewares([new UnexpectedErrorMiddleware(), new ResponseExceptionMiddleware()]), + 3 => new CacheControl(0, null), + 4 => new BannersRequest([ + new Position('homepage.top'), + ]), + 5 => new HttpRequest( + 'POST', + 'content/test', + [ + 'body' => '{"query":{"homepage.top":{}},"locale":"en"}', + 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + ], + [ + 'query' => [ + 'homepage.top' => new stdClass(), + ], + 'locale' => 'en', + ], + ), + ], + 'GET, [Request]: single position, no resources, locale, [Config]: locale' => [ + 0 => ClientConfig::create('https://www.example.com', 'test') + ->withLocale('en'), + 1 => 'https://www.example.com/api/v1', + 2 => new Middlewares([new UnexpectedErrorMiddleware(), new ResponseExceptionMiddleware()]), + 3 => new CacheControl(0, null), + 4 => new BannersRequest([ + new Position('homepage.top'), + ], 'cs'), + 5 => new HttpRequest( + 'GET', + 'content/test', + [ + 'query' => [ + 'query' => '{"homepage.top":{}}', + 'locale' => 'cs', + ], + 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + ], + [ + 'query' => [ + 'homepage.top' => new stdClass(), + ], + 'locale' => 'cs', + ], + ), + ], + 'POST, [Request]: single position, no resources, locale, [Config]: locale' => [ + 0 => ClientConfig::create('https://www.example.com', 'test') + ->withMethod('POST') + ->withLocale('en'), + 1 => 'https://www.example.com/api/v1', + 2 => new Middlewares([new UnexpectedErrorMiddleware(), new ResponseExceptionMiddleware()]), + 3 => new CacheControl(0, null), + 4 => new BannersRequest([ + new Position('homepage.top'), + ], 'cs'), + 5 => new HttpRequest( + 'POST', + 'content/test', + [ + 'body' => '{"query":{"homepage.top":{}},"locale":"cs"}', + 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + ], + [ + 'query' => [ + 'homepage.top' => new stdClass(), + ], + 'locale' => 'cs', + ], + ), + ], + 'GET, [Request]: single position, no resources, [Config]: cache control' => [ + 0 => ClientConfig::create('https://www.example.com', 'test') + ->withCacheExpiration(3600) + ->withCacheControlHeaderOverride('no-cache, max-age=200'), + 1 => 'https://www.example.com/api/v1', + 2 => new Middlewares([new UnexpectedErrorMiddleware(), new ResponseExceptionMiddleware()]), + 3 => new CacheControl(3600, 'no-cache, max-age=200'), + 4 => new BannersRequest([ + new Position('homepage.top'), + ]), + 5 => new HttpRequest( + 'GET', + 'content/test', + [ + 'query' => [ + 'query' => '{"homepage.top":{}}', + ], + 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + ], + [ + 'query' => [ + 'homepage.top' => new stdClass(), + ], + 'locale' => null, + ], + ), + ], + 'POST, [Request]: single position, no resources, [Config]: cache control' => [ + 0 => ClientConfig::create('https://www.example.com', 'test') + ->withMethod('POST') + ->withCacheExpiration(3600) + ->withCacheControlHeaderOverride('no-cache, max-age=200'), + 1 => 'https://www.example.com/api/v1', + 2 => new Middlewares([new UnexpectedErrorMiddleware(), new ResponseExceptionMiddleware()]), + 3 => new CacheControl(3600, 'no-cache, max-age=200'), + 4 => new BannersRequest([ + new Position('homepage.top'), + ]), + 5 => new HttpRequest( + 'POST', + 'content/test', + [ + 'body' => '{"query":{"homepage.top":{}}}', + 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + ], + [ + 'query' => [ + 'homepage.top' => new stdClass(), + ], + 'locale' => null, + ], + ), + ], + ]; + } + + protected function tearDown(): void + { + Mockery::close(); + } + + private function assertHttpClientIsNull(AmpClient $ampClient): void + { + call_user_func(Closure::bind(static function () use ($ampClient) { + Assert::null($ampClient->httpClient); + }, null, AmpClient::class)); + } +} + +(new AmpClientTest())->run(); diff --git a/tests/Bridge/Nette/DI/AmpClientExtensionTest.php b/tests/Bridge/Nette/DI/AmpClientExtensionTest.php new file mode 100644 index 0000000..880b014 --- /dev/null +++ b/tests/Bridge/Nette/DI/AmpClientExtensionTest.php @@ -0,0 +1,157 @@ +getClientFromContainer(__DIR__ . '/config.minimal.neon'); + $httpClientFactory = $this->unwrapHttpClientFactory($client); + + Assert::equal(ClientConfig::create('https://www.example.com', 'test'), $client->getConfig()); + Assert::type(NoCacheStorage::class, $client->getCacheStorage()); + + Assert::equal( + new HttpClientFactory( + new ResponseHydrator([ + new BannersResponseHydratorHandler(), + ]), + [], + ), + $httpClientFactory, + ); + } + + public function testContainerWithMethodOption(): void + { + $client = $this->getClientFromContainer(__DIR__ . '/config.withMethod.neon'); + + Assert::equal( + ClientConfig::create('https://www.example.com', 'test') + ->withMethod('POST'), + $client->getConfig(), + ); + } + + public function testContainerWithVersionOption(): void + { + $client = $this->getClientFromContainer(__DIR__ . '/config.withVersion.neon'); + + Assert::equal( + ClientConfig::create('https://www.example.com', 'test') + ->withVersion(1), + $client->getConfig(), + ); + } + + public function testContainerWithLocaleOption(): void + { + $client = $this->getClientFromContainer(__DIR__ . '/config.withLocale.neon'); + + Assert::equal( + ClientConfig::create('https://www.example.com', 'test') + ->withLocale('en'), + $client->getConfig(), + ); + } + + public function testContainerWithDefaultResourcesOption(): void + { + $client = $this->getClientFromContainer(__DIR__ . '/config.withDefaultResources.neon'); + + Assert::equal( + ClientConfig::create('https://www.example.com', 'test') + ->withDefaultResources([ + new BannerResource('first', ['a']), + new BannerResource('second', ['a', 'b']), + ]), + $client->getConfig(), + ); + } + + public function testContainerWithOriginOption(): void + { + $client = $this->getClientFromContainer(__DIR__ . '/config.withOrigin.neon'); + + Assert::equal( + ClientConfig::create('https://www.example.com', 'test') + ->withOrigin('https://www.example.io'), + $client->getConfig(), + ); + } + + public function testContainerWithCacheOptions(): void + { + $client = $this->getClientFromContainer(__DIR__ . '/config.withCache.neon'); + + Assert::equal( + ClientConfig::create('https://www.example.com', 'test') + ->withCacheExpiration('+1 hour') + ->withCacheControlHeaderOverride('no-store'), + $client->getConfig(), + ); + + Assert::equal( + new NetteCacheStorage( + new MemoryStorage(), + ), + $client->getCacheStorage(), + ); + } + + public function testContainerWithGuzzleConfig(): void + { + $client = $this->getClientFromContainer(__DIR__ . '/config.withGuzzleConfig.neon'); + $httpClientFactory = $this->unwrapHttpClientFactory($client); + + Assert::type(HttpClientFactory::class, $httpClientFactory); + + $guzzleClientConfig = call_user_func(Closure::bind(static function () use ($httpClientFactory): array { + return $httpClientFactory->guzzleClientConfig; + }, null, HttpClientFactory::class)); + + Assert::same([ + 'custom_option' => 'custom_value', + ], $guzzleClientConfig); + } + + private function getClientFromContainer(string $configFile): AmpClient + { + $container = ContainerFactory::create($configFile); + $client = $container->getByType(AmpClientInterface::class); + + Assert::type(AmpClient::class, $client); + + return $client; + } + + private function unwrapHttpClientFactory(AmpClient $ampClient): HttpClientFactoryInterface + { + return call_user_func(Closure::bind(static function () use ($ampClient): HttpClientFactoryInterface { + return $ampClient->httpClientFactory; + }, null, AmpClient::class)); + } +} + +(new AmpClientExtensionTest())->run(); diff --git a/tests/Bridge/Nette/DI/ContainerFactory.php b/tests/Bridge/Nette/DI/ContainerFactory.php new file mode 100644 index 0000000..25fba88 --- /dev/null +++ b/tests/Bridge/Nette/DI/ContainerFactory.php @@ -0,0 +1,36 @@ + $configFiles + */ + public static function create($configFiles, bool $debug = false): Container + { + $tempDir = sys_get_temp_dir() . '/' . uniqid('68publishers:AmpClientPhp', true); + + Helpers::purge($tempDir); + + $configurator = new Configurator(); + $configurator->setTempDirectory($tempDir); + $configurator->setDebugMode($debug); + + foreach ((array) $configFiles as $configFile) { + $configurator->addConfig($configFile); + } + + return $configurator->createContainer(); + } +} diff --git a/tests/Bridge/Nette/DI/config.minimal.neon b/tests/Bridge/Nette/DI/config.minimal.neon new file mode 100644 index 0000000..2e72287 --- /dev/null +++ b/tests/Bridge/Nette/DI/config.minimal.neon @@ -0,0 +1,6 @@ +extensions: + amp_client: SixtyEightPublishers\AmpClient\Bridge\Nette\DI\AmpClientExtension + +amp_client: + url: https://www.example.com + channel: test diff --git a/tests/Bridge/Nette/DI/config.withCache.neon b/tests/Bridge/Nette/DI/config.withCache.neon new file mode 100644 index 0000000..6313f52 --- /dev/null +++ b/tests/Bridge/Nette/DI/config.withCache.neon @@ -0,0 +1,10 @@ +extensions: + amp_client: SixtyEightPublishers\AmpClient\Bridge\Nette\DI\AmpClientExtension + +amp_client: + url: https://www.example.com + channel: test + cache: + storage: Nette\Caching\Storages\MemoryStorage + expiration: '+1 hour' + cache_control_header_override: 'no-store' diff --git a/tests/Bridge/Nette/DI/config.withDefaultResources.neon b/tests/Bridge/Nette/DI/config.withDefaultResources.neon new file mode 100644 index 0000000..317760d --- /dev/null +++ b/tests/Bridge/Nette/DI/config.withDefaultResources.neon @@ -0,0 +1,11 @@ +extensions: + amp_client: SixtyEightPublishers\AmpClient\Bridge\Nette\DI\AmpClientExtension + +amp_client: + url: https://www.example.com + channel: test + default_resources: + first: a + second: + - a + - b diff --git a/tests/Bridge/Nette/DI/config.withGuzzleConfig.neon b/tests/Bridge/Nette/DI/config.withGuzzleConfig.neon new file mode 100644 index 0000000..4a0cd8d --- /dev/null +++ b/tests/Bridge/Nette/DI/config.withGuzzleConfig.neon @@ -0,0 +1,9 @@ +extensions: + amp_client: SixtyEightPublishers\AmpClient\Bridge\Nette\DI\AmpClientExtension + +amp_client: + url: https://www.example.com + channel: test + http: + guzzle_config: + custom_option: custom_value diff --git a/tests/Bridge/Nette/DI/config.withLocale.neon b/tests/Bridge/Nette/DI/config.withLocale.neon new file mode 100644 index 0000000..463a37c --- /dev/null +++ b/tests/Bridge/Nette/DI/config.withLocale.neon @@ -0,0 +1,7 @@ +extensions: + amp_client: SixtyEightPublishers\AmpClient\Bridge\Nette\DI\AmpClientExtension + +amp_client: + url: https://www.example.com + channel: test + locale: en diff --git a/tests/Bridge/Nette/DI/config.withMethod.neon b/tests/Bridge/Nette/DI/config.withMethod.neon new file mode 100644 index 0000000..27b00b3 --- /dev/null +++ b/tests/Bridge/Nette/DI/config.withMethod.neon @@ -0,0 +1,7 @@ +extensions: + amp_client: SixtyEightPublishers\AmpClient\Bridge\Nette\DI\AmpClientExtension + +amp_client: + url: https://www.example.com + channel: test + method: POST diff --git a/tests/Bridge/Nette/DI/config.withOrigin.neon b/tests/Bridge/Nette/DI/config.withOrigin.neon new file mode 100644 index 0000000..31728c5 --- /dev/null +++ b/tests/Bridge/Nette/DI/config.withOrigin.neon @@ -0,0 +1,7 @@ +extensions: + amp_client: SixtyEightPublishers\AmpClient\Bridge\Nette\DI\AmpClientExtension + +amp_client: + url: https://www.example.com + channel: test + origin: https://www.example.io diff --git a/tests/Bridge/Nette/DI/config.withVersion.neon b/tests/Bridge/Nette/DI/config.withVersion.neon new file mode 100644 index 0000000..c39e763 --- /dev/null +++ b/tests/Bridge/Nette/DI/config.withVersion.neon @@ -0,0 +1,7 @@ +extensions: + amp_client: SixtyEightPublishers\AmpClient\Bridge\Nette\DI\AmpClientExtension + +amp_client: + url: https://www.example.com + channel: test + version: 1 diff --git a/tests/Http/Cache/CacheControlHeaderTest.php b/tests/Http/Cache/CacheControlHeaderTest.php index ee97535..019aa10 100644 --- a/tests/Http/Cache/CacheControlHeaderTest.php +++ b/tests/Http/Cache/CacheControlHeaderTest.php @@ -63,6 +63,8 @@ private function doAsserts(CacheControlHeader $header): void 'no-store' => '', 's-maxage' => '100', ], $header->all()); + + Assert::false($header->isEmpty()); } } diff --git a/tests/Http/Cache/EtagTest.php b/tests/Http/Cache/EtagTest.php index 727d055..c440d69 100644 --- a/tests/Http/Cache/EtagTest.php +++ b/tests/Http/Cache/EtagTest.php @@ -22,13 +22,17 @@ public function testCreatingEtagFromString(): void public function testCreatingEtagFromResponse(): void { - $response = new Response(200, [ + $responseWithoutEtag = new Response(200); + $responseWithEtag = new Response(200, [ 'ETag' => 'test', ]); - $etag = Etag::fromResponse($response); + $missingEtag = Etag::fromResponse($responseWithoutEtag); + $existingEtag = Etag::fromResponse($responseWithEtag); - Assert::same('test', $etag->getValue()); + Assert::null($missingEtag); + Assert::type(Etag::class, $existingEtag); + Assert::same('test', $existingEtag->getValue()); } } diff --git a/tests/Http/Cache/ExpirationTest.php b/tests/Http/Cache/ExpirationTest.php index a391514..8886f6a 100644 --- a/tests/Http/Cache/ExpirationTest.php +++ b/tests/Http/Cache/ExpirationTest.php @@ -4,7 +4,9 @@ namespace SixtyEightPublishers\AmpClient\Tests\Http\Cache; +use DateTimeImmutable; use SixtyEightPublishers\AmpClient\Http\Cache\Expiration; +use SlopeIt\ClockMock\ClockMock; use Tester\Assert; use Tester\TestCase; use function time; @@ -31,30 +33,66 @@ public function testZeroExpirationFromIntegerShouldBeDirectlyExpired(): void Assert::false($expiration->isFresh()); } - public function testFutureExpirationFromStringShouldBeFresh(): void + public function testExpirationFromString(): void { + $nowPlus50Seconds = new DateTimeImmutable('+50 seconds'); + $nowPlus60Seconds = new DateTimeImmutable('+60 seconds'); + $nowPlus61Seconds = new DateTimeImmutable('+61 seconds'); + $expiration = Expiration::create('+60 seconds'); Assert::same(time() + 60, $expiration->getValue()); + Assert::false($expiration->isExpired()); Assert::true($expiration->isFresh()); + + [$isExpiredAfter50Seconds, $isFreshAfter50Seconds] = ClockMock::executeAtFrozenDateTime($nowPlus50Seconds, static fn () => [$expiration->isExpired(), $expiration->isFresh()]); + + Assert::false($isExpiredAfter50Seconds); + Assert::true($isFreshAfter50Seconds); + + // still fresh + [$isExpiredAfter60Seconds, $isFreshAfter60Seconds] = ClockMock::executeAtFrozenDateTime($nowPlus60Seconds, static fn () => [$expiration->isExpired(), $expiration->isFresh()]); + + Assert::false($isExpiredAfter60Seconds); + Assert::true($isFreshAfter60Seconds); + + // not fresh + [$isExpiredAfter61Seconds, $isFreshAfter61Seconds] = ClockMock::executeAtFrozenDateTime($nowPlus61Seconds, static fn () => [$expiration->isExpired(), $expiration->isFresh()]); + + Assert::true($isExpiredAfter61Seconds); + Assert::false($isFreshAfter61Seconds); } - public function testFutureExpirationFromIntegerShouldBeFresh(): void + public function testExpirationFromInteger(): void { + $nowPlus50Seconds = new DateTimeImmutable('+50 seconds'); + $nowPlus60Seconds = new DateTimeImmutable('+60 seconds'); + $nowPlus61Seconds = new DateTimeImmutable('+61 seconds'); + $expiration = Expiration::create(60); Assert::same(time() + 60, $expiration->getValue()); + Assert::false($expiration->isExpired()); Assert::true($expiration->isFresh()); - } - public function testPastExpirationShouldBeExpired(): void - { - $expiration = new Expiration(time() - 1); + [$isExpiredAfter50Seconds, $isFreshAfter50Seconds] = ClockMock::executeAtFrozenDateTime($nowPlus50Seconds, static fn () => [$expiration->isExpired(), $expiration->isFresh()]); - Assert::true($expiration->isExpired()); - Assert::false($expiration->isFresh()); + Assert::false($isExpiredAfter50Seconds); + Assert::true($isFreshAfter50Seconds); + + // still fresh + [$isExpiredAfter60Seconds, $isFreshAfter60Seconds] = ClockMock::executeAtFrozenDateTime($nowPlus60Seconds, static fn () => [$expiration->isExpired(), $expiration->isFresh()]); + + Assert::false($isExpiredAfter60Seconds); + Assert::true($isFreshAfter60Seconds); + + // not fresh + [$isExpiredAfter61Seconds, $isFreshAfter61Seconds] = ClockMock::executeAtFrozenDateTime($nowPlus61Seconds, static fn () => [$expiration->isExpired(), $expiration->isFresh()]); + + Assert::true($isExpiredAfter61Seconds); + Assert::false($isFreshAfter61Seconds); } } diff --git a/tests/Http/Cache/InMemoryCacheStorageTest.php b/tests/Http/Cache/InMemoryCacheStorageTest.php new file mode 100644 index 0000000..26594e7 --- /dev/null +++ b/tests/Http/Cache/InMemoryCacheStorageTest.php @@ -0,0 +1,103 @@ + 'a', 'test2' => 'b']); + $response = new CachedResponse($cacheKey, (object) [], Expiration::create(60), null); + + $storage->save($response, Expiration::create(3600)); + + Assert::same($response, $storage->get($cacheKey)); + } + + public function testCacheShouldReturnNullWhenResponseIsMissing(): void + { + $storage = new InMemoryCacheStorage(); + $cacheKey = CacheKey::compute(['test' => 'a', 'test2' => 'b']); + + Assert::null($storage->get($cacheKey)); + } + + public function testCacheShouldReturnNullWhenResponseIsExpired(): void + { + $storage = new InMemoryCacheStorage(); + + $cacheKey = CacheKey::compute(['test' => 'a', 'test2' => 'b']); + $response = new CachedResponse($cacheKey, (object) [], Expiration::create(30), null); + + $nowPlus50Seconds = new DateTimeImmutable('+50 seconds'); + $nowPlus60Seconds = new DateTimeImmutable('+60 seconds'); + $nowPlus61Seconds = new DateTimeImmutable('+61 seconds'); + + $storage->save($response, Expiration::create(60)); + + Assert::same($response, $storage->get($cacheKey)); + + $responseAfter50Seconds = ClockMock::executeAtFrozenDateTime($nowPlus50Seconds, static fn () => $storage->get($cacheKey)); + $responseAfter60Seconds = ClockMock::executeAtFrozenDateTime($nowPlus60Seconds, static fn () => $storage->get($cacheKey)); + $responseAfter61Seconds = ClockMock::executeAtFrozenDateTime($nowPlus61Seconds, static fn () => $storage->get($cacheKey)); + + Assert::same($response, $responseAfter50Seconds); + Assert::same($response, $responseAfter60Seconds); + Assert::null($responseAfter61Seconds); + } + + public function testResponseShouldBeDeleted(): void + { + $storage = new InMemoryCacheStorage(); + + $cacheKey1 = CacheKey::compute(['test' => 'a', 'test2' => 'b']); + $response1 = new CachedResponse($cacheKey1, (object) [], Expiration::create(60), null); + + $cacheKey2 = CacheKey::compute(['test' => 'c', 'test2' => 'd']); + $response2 = new CachedResponse($cacheKey2, (object) [], Expiration::create(60), null); + + $storage->save($response1, Expiration::create(3600)); + $storage->save($response2, Expiration::create(3600)); + + $storage->delete($cacheKey1); + + Assert::null($storage->get($cacheKey1)); + Assert::same($response2, $storage->get($cacheKey2)); + } + + public function testStorageShouldBeCleared(): void + { + $storage = new InMemoryCacheStorage(); + + $cacheKey1 = CacheKey::compute(['test' => 'a', 'test2' => 'b']); + $response1 = new CachedResponse($cacheKey1, (object) [], Expiration::create(60), null); + + $cacheKey2 = CacheKey::compute(['test' => 'c', 'test2' => 'd']); + $response2 = new CachedResponse($cacheKey2, (object) [], Expiration::create(60), null); + + $storage->save($response1, Expiration::create(3600)); + $storage->save($response2, Expiration::create(3600)); + + $storage->clear(); + + Assert::null($storage->get($cacheKey1)); + Assert::null($storage->get($cacheKey2)); + } +} + +(new InMemoryCacheStorageTest())->run(); diff --git a/tests/Http/Cache/NoCacheStorageTest.php b/tests/Http/Cache/NoCacheStorageTest.php new file mode 100644 index 0000000..5f1bea5 --- /dev/null +++ b/tests/Http/Cache/NoCacheStorageTest.php @@ -0,0 +1,36 @@ + 'a', 'test2' => 'b']); + $response = new CachedResponse($cacheKey, (object) [], Expiration::create(3600), null); + + $storage->save($response, Expiration::create(3600)); + + Assert::null($storage->get($cacheKey)); + + Assert::noError(static function () use ($storage, $cacheKey) { + $storage->delete($cacheKey); + $storage->clear(); + }); + } +} + +(new NoCacheStorageTest())->run(); diff --git a/tests/Http/HttpClientFactoryTest.php b/tests/Http/HttpClientFactoryTest.php new file mode 100644 index 0000000..7a345a3 --- /dev/null +++ b/tests/Http/HttpClientFactoryTest.php @@ -0,0 +1,117 @@ + new HandlerStack(Utils::chooseHandler()), + 'custom_option' => 'custom_value', + ]; + + $factory = new HttpClientFactory($responseHydrator, $guzzleConfig); + $client = $factory->create($baseUrl, $middlewares, $cacheStorage, $cacheControl); + + Assert::type(HttpClient::class, $client); + + [ + $configuredBaseUrl, + $configuredResponseHydrator, + $configuredCacheStorage, + $configuredCacheControl, + $configuredGuzzleConfig, + $configuredHandlerStackMiddlewares, + ] = $this->expandHttpClientProperties($client); + + Assert::same('https://www.example.com', $configuredBaseUrl); + Assert::same($responseHydrator, $configuredResponseHydrator); + Assert::same($cacheStorage, $configuredCacheStorage); + Assert::same($cacheControl, $configuredCacheControl); + + Assert::equal('custom_value', $configuredGuzzleConfig['custom_option'] ?? ''); + + Assert::same([ + [$middleware2, 'b'], + [$middleware1, 'a'], + ], $configuredHandlerStackMiddlewares); + } + + protected function tearDown(): void + { + Mockery::close(); + } + + /** + * @return array{ + * 0: string, + * 1: ResponseHydratorInterface, + * 2: CacheStorageInterface, + * 3: CacheControl, + * 4: array, + * 5: array, + * } + */ + private function expandHttpClientProperties(HttpClient $httpClient): array + { + return call_user_func(Closure::bind(static function () use ($httpClient) { + $guzzleClient = $httpClient->guzzleClient; + + [$guzzleConfig, $handlerStackMiddlewares] = call_user_func(Closure::bind(static function () use ($guzzleClient) { + $config = $guzzleClient->config; + $handler = $config['handler']; + assert($handler instanceof HandlerStack); + + return [ + $config, + call_user_func(Closure::bind(static fn (): array => $handler->stack, null, HandlerStack::class)), + ]; + }, null, GuzzleClient::class)); + + return [ + $httpClient->baseUrl, + $httpClient->responseHydrator, + $httpClient->cacheStorage, + $httpClient->cacheControl, + $guzzleConfig, + $handlerStackMiddlewares, + ]; + }, null, HttpClient::class)); + } +} + +(new HttpClientFactoryTest())->run(); diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php new file mode 100644 index 0000000..eaba2d4 --- /dev/null +++ b/tests/Http/HttpClientTest.php @@ -0,0 +1,433 @@ +buildHttpClient([ + new Response(200, [], '{"version":1}'), + new Response(200, [], '{"version":2}'), + ]); + $client = $services['client']; + $history = $services['history']; + $cacheControl = $services['cacheControl']; + $hydrator = $services['hydrator']; + $expectedResult1 = new stdClass(); + $expectedResult2 = new stdClass(); + + $cacheControl + ->shouldReceive('getCacheControlHeaderOverride') + ->twice() + ->withNoArgs() + ->andReturnNull(); + + $hydrator + ->shouldReceive('hydrate') + ->once() + ->with('stdClass', ['version' => 1]) + ->andReturn($expectedResult1) + ->shouldReceive('hydrate') + ->once() + ->with('stdClass', ['version' => 2]) + ->andReturn($expectedResult2); + + $request = new HttpRequest( + 'GET', + '/test', + [ + 'query' => [ + 'test' => '1', + ], + ], + ); + + $result1 = $client->request($request, 'stdClass'); + + Assert::same($expectedResult1, $result1); + Assert::same('GET', $history[0]['request']->getMethod()); + Assert::same('https://www.example.com/test?test=1', (string) $history[0]['request']->getUri()); + + $result2 = $client->request($request, 'stdClass'); + + Assert::same($expectedResult2, $result2); + Assert::same('GET', $history[1]['request']->getMethod()); + Assert::same('https://www.example.com/test?test=1', (string) $history[1]['request']->getUri()); + } + + public function testExceptionShouldBeThrownOnInvalidResponseBody(): void + { + $services = $this->buildHttpClient([ + new Response(200, [], '{"version":1'), // invalid JSON + ]); + $client = $services['client']; + $cacheControl = $services['cacheControl']; + + $cacheControl + ->shouldReceive('getCacheControlHeaderOverride') + ->once() + ->withNoArgs() + ->andReturnNull(); + + $request = new HttpRequest( + 'GET', + '/test', + [ + 'query' => [ + 'test' => '1', + ], + ], + ); + + Assert::exception( + static fn () => $client->request($request, 'stdClass'), + ResponseHydrationException::class, + 'Response body is probably malformed.%A?%', + ); + } + + public function testResponseShouldBeReturnedFromCacheIfFresh(): void + { + $services = $this->buildHttpClient([]); + $client = $services['client']; + $cache = $services['cache']; + + $expectedResult = new stdClass(); + $cachedResponse = Mockery::mock(CachedResponse::class); + + $cache + ->shouldReceive('get') + ->once() + ->with(Mockery::type(CacheKey::class)) + ->andReturn($cachedResponse); + + $cachedResponse + ->shouldReceive('isFresh') + ->andReturn(true) + ->shouldReceive('getResponse') + ->andReturn($expectedResult); + + $request = new HttpRequest( + 'GET', + '/test', + [], + [ + 'cacheComponent1' => 'test', + ], + ); + + $result = $client->request($request, 'stdClass'); + + Assert::same($expectedResult, $result); + } + + /** + * @dataProvider cachingParametersDataProvider + */ + public function testResponseShouldOrShouldNotBeCachedWhenThereIsNotCacheHit( + array $responseHeaders, + bool $shouldBeStored, + int $maxAge, + ?string $etag, + ?string $cacheControlHeaderOverride + ): void { + ClockMock::executeAtFrozenDateTime(new DateTimeImmutable('now'), function () use ($responseHeaders, $shouldBeStored, $maxAge, $etag, $cacheControlHeaderOverride): void { + $response = new Response(200, $responseHeaders, '{"version":1}'); + + $services = $this->buildHttpClient([$response]); + $client = $services['client']; + $history = $services['history']; + $cache = $services['cache']; + $cacheControl = $services['cacheControl']; + $hydrator = $services['hydrator']; + + $expectedResult = new stdClass(); + + $hydrator + ->shouldReceive('hydrate') + ->once() + ->with('stdClass', ['version' => 1]) + ->andReturn($expectedResult); + + $cacheControl + ->shouldReceive('getCacheControlHeaderOverride') + ->once() + ->withNoArgs() + ->andReturn(null !== $cacheControlHeaderOverride ? new CacheControlHeader([$cacheControlHeaderOverride]) : null); + + $cache + ->shouldReceive('get') + ->once() + ->with(Mockery::type(CacheKey::class)) + ->andReturnNull(); + + if ($shouldBeStored) { + $cacheControl + ->shouldReceive('createExpiration') + ->withNoArgs() + ->andReturn(Expiration::create(3600)); + + $cache + ->shouldReceive('save') + ->with(Mockery::type(CachedResponse::class), Mockery::type(Expiration::class)) + ->andReturnUsing(function (CachedResponse $cachedResponse) use ($expectedResult, $maxAge, $etag) { + Assert::same($expectedResult, $cachedResponse->getResponse()); + Assert::equal(Expiration::create($maxAge), $cachedResponse->getMaxAge()); + Assert::equal(null !== $etag ? new Etag($etag) : null, $cachedResponse->getEtag()); + + return null; + }); + } + + $request = new HttpRequest( + 'GET', + '/test', + [ + 'query' => [ + 'test' => '1', + ], + ], + [ + 'cacheComponent1' => 'test', + ], + ); + + $result = $client->request($request, 'stdClass'); + + Assert::same($expectedResult, $result); + Assert::same('GET', $history[0]['request']->getMethod()); + Assert::same('https://www.example.com/test?test=1', (string) $history[0]['request']->getUri()); + }); + } + + /** + * @dataProvider cachingParametersDataProvider + */ + public function testResponseShouldBeCachedWhenResponseIsNotModified( + array $responseHeaders, + bool $shouldBeStored, + int $maxAge, + ?string $etag, + ?string $cacheControlHeaderOverride + ): void { + ClockMock::executeAtFrozenDateTime(new DateTimeImmutable('now'), function () use ($responseHeaders, $shouldBeStored, $maxAge, $etag, $cacheControlHeaderOverride): void { + $response = new Response(304, $responseHeaders); + + $services = $this->buildHttpClient([$response]); + $client = $services['client']; + $history = $services['history']; + $cache = $services['cache']; + $cacheControl = $services['cacheControl']; + + $expectedResult = new stdClass(); + $cachedResponse = Mockery::mock(CachedResponse::class); + + $cachedResponse + ->shouldReceive('isFresh') + ->andReturn(false) + ->shouldReceive('getResponse') + ->andReturn($expectedResult) + ->shouldReceive('getEtag') + ->andReturn(new Etag('1234')); + + $cacheControl + ->shouldReceive('getCacheControlHeaderOverride') + ->once() + ->withNoArgs() + ->andReturn(null !== $cacheControlHeaderOverride ? new CacheControlHeader([$cacheControlHeaderOverride]) : null); + + $cache + ->shouldReceive('get') + ->once() + ->with(Mockery::type(CacheKey::class)) + ->andReturn($cachedResponse); + + if ($shouldBeStored) { + $cacheControl + ->shouldReceive('createExpiration') + ->withNoArgs() + ->andReturn(Expiration::create(3600)); + + $cache + ->shouldReceive('save') + ->with(Mockery::type(CachedResponse::class), Mockery::type(Expiration::class)) + ->andReturnUsing(function (CachedResponse $cachedResponse) use ($expectedResult, $maxAge, $etag) { + Assert::same($expectedResult, $cachedResponse->getResponse()); + Assert::equal(Expiration::create($maxAge), $cachedResponse->getMaxAge()); + Assert::equal(null !== $etag ? new Etag($etag) : null, $cachedResponse->getEtag()); + + return null; + }); + } else { + $cache + ->shouldReceive('delete') + ->with(Mockery::type(CacheKey::class)) + ->andReturns(); + } + + $request = new HttpRequest( + 'GET', + '/test', + [ + 'query' => [ + 'test' => '1', + ], + ], + [ + 'cacheComponent1' => 'test', + ], + ); + + $result = $client->request($request, 'stdClass'); + + Assert::same($expectedResult, $result); + Assert::same('GET', $history[0]['request']->getMethod()); + Assert::same('https://www.example.com/test?test=1', (string) $history[0]['request']->getUri()); + Assert::same(['1234'], $history[0]['request']->getHeader('If-None-Match')); + }); + } + + public function cachingParametersDataProvider(): array + { + # 0 => array $responseHeaders + # 1 => bool $shouldBeStored + # 2 => int $maxAge + # 3 => ?string $etag + # 4 => ?string $cacheControlHeaderOverride + return [ + [ + 0 => [], + 1 => false, + 2 => 0, + 3 => null, + 4 => null, + ], + [ + 0 => ['ETag' => '123456'], + 1 => false, + 2 => 0, + 3 => '123456', + 4 => null, + ], + [ + 0 => ['Cache-Control' => 'max-age=100'], + 1 => true, + 2 => 100, + 3 => null, + 4 => null, + ], + [ + 0 => ['Cache-Control' => 'max-age=100', 'ETag' => '123456'], + 1 => true, + 2 => 100, + 3 => '123456', + 4 => null, + ], + [ + 0 => ['Cache-Control' => 'no-cache, max-age=100'], + 1 => true, + 2 => 0, + 3 => null, + 4 => null, + ], + [ + 0 => ['Cache-Control' => 'no-store, max-age=100'], + 1 => false, + 2 => 0, + 3 => null, + 4 => null, + ], + [ + 0 => ['Cache-Control' => 'max-age=100, s-maxage=50'], + 1 => true, + 2 => 50, + 3 => null, + 4 => null, + ], + [ + 0 => ['Cache-Control' => 'max-age=100', 'ETag' => '123456'], + 1 => false, + 2 => 0, + 3 => null, + 4 => 'no-store', + ], + ]; + } + + protected function tearDown(): void + { + Mockery::close(); + } + + /** + * @param array $responses + * + * @return array{ + * client: HttpClient, + * history: ArrayObject, + * hydrator: ResponseHydratorInterface|MockInterface, + * cache: CacheStorageInterface|MockInterface, + * cacheControl: CacheControl|MockInterface, + * } + */ + private function buildHttpClient(array $responses): array + { + $responseHydrator = Mockery::mock(ResponseHydratorInterface::class); + $cacheStorage = Mockery::mock(CacheStorageInterface::class); + $cacheControl = Mockery::mock(CacheControl::class); + + $guzzleClientHandler = HandlerStack::create( + new MockHandler($responses), + ); + $history = new ArrayObject([]); + + $guzzleClientHandler->push(Middleware::history($history), 'history'); + + $guzzleClient = new GuzzleClient([ + 'handler' => $guzzleClientHandler, + ]); + + $client = new HttpClient('https://www.example.com/', $guzzleClient, $responseHydrator, $cacheStorage, $cacheControl); + + return [ + 'client' => $client, + 'history' => $history, + 'hydrator' => $responseHydrator, + 'cache' => $cacheStorage, + 'cacheControl' => $cacheControl, + ]; + } +} + +(new HttpClientTest())->run(); diff --git a/tests/Http/Middleware/MiddlewareFixture.php b/tests/Http/Middleware/MiddlewareFixture.php new file mode 100644 index 0000000..9c2276a --- /dev/null +++ b/tests/Http/Middleware/MiddlewareFixture.php @@ -0,0 +1,47 @@ +name = $name; + $this->priority = $priority; + $this->result = $result; + } + + public function getName(): string + { + return $this->name; + } + + public function getPriority(): int + { + return $this->priority; + } + + public function __invoke(Closure $next): Closure + { + return static fn (): FulfilledPromise => new FulfilledPromise($this->result); + } +} diff --git a/tests/Http/MiddlewaresTest.php b/tests/Http/MiddlewaresTest.php new file mode 100644 index 0000000..cbfab04 --- /dev/null +++ b/tests/Http/MiddlewaresTest.php @@ -0,0 +1,50 @@ +getIterator())); + } + + public function testMiddlewaresImmutability(): void + { + $middleware1 = new MiddlewareFixture('1', 100); + $middleware2 = new MiddlewareFixture('2', 200); + $middleware3 = new MiddlewareFixture('3', 300); + + $middlewares = new Middlewares([ + $middleware1, + $middleware2, + ]); + + $modified = $middlewares->with($middleware3); + + Assert::same([$middleware2, $middleware1], iterator_to_array($middlewares->getIterator())); + Assert::same([$middleware3, $middleware2, $middleware1], iterator_to_array($modified->getIterator())); + } +} + +(new MiddlewaresTest())->run(); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index c489dda..c347ebe 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -12,5 +12,6 @@ } Environment::setup(); +Environment::bypassFinals(); return $loader;