diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml
index 768ecb0..bd6526e 100644
--- a/.github/workflows/code-quality.yaml
+++ b/.github/workflows/code-quality.yaml
@@ -40,6 +40,7 @@ jobs:
outputs:
lint: ${{ steps.filter.outputs.lint }}
analyse: ${{ steps.filter.outputs.analyse }}
+ tests: ${{ steps.filter.outputs.tests }}
steps:
- name: Checkout
@@ -51,8 +52,13 @@ jobs:
filters: |
lint:
- 'src/**'
+ - 'tests/**'
analyse:
- 'src/**'
+ - 'tests/**'
+ tests:
+ - 'src/**'
+ - 'tests/**'
lint:
runs-on: ubuntu-latest
@@ -120,3 +126,34 @@ jobs:
php_version: "8.2"
version: 2
command: audit
+
+ test:
+ runs-on: ubuntu-latest
+
+ needs: [composer,changes]
+
+ if: ${{ needs.changes.outputs.tests == 'true' }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP with PCOV
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: "8.2"
+ extensions: gd, imagick, pcov
+ coverage: pcov
+
+ - name: Cache composer dependencies
+ uses: actions/cache@v4
+ with:
+ key: composer-${{ hashFiles('composer.json') }}-${{ hashFiles('composer.lock') }}
+ restore-keys: composer-
+ path: vendor
+
+ - name: Run PHPUnit with coverage
+ run: make test
+
+ - name: Check coverage threshold
+ run: make coverage
diff --git a/Makefile b/Makefile
index 6cdae28..de589ab 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
-include docker/.env
-.SILENT: cache shell analyse
+.SILENT: cache shell analyse test coverage
.DEFAULT_GOAL := help
help:
@@ -44,7 +44,7 @@ cache:
## Code quality
##---------------------------------------------------------------------------
-check: lint analyse security
+check: lint analyse security test coverage
lint: ## Execute PHPCS
lint: cache
@@ -61,3 +61,11 @@ analyse: cache
security: ## Check CVE for vendor dependencies
security:
docker/exec composer audit
+
+test: ## Execute PHPUnit tests
+test: cache
+ docker/exec vendor/bin/phpunit --coverage-xml .cache/coverage/xml
+
+coverage: ## Check coverage threshold (95%)
+coverage:
+ docker/exec vendor/bin/coverage-checker .cache/coverage/xml/index.xml 75
diff --git a/composer.json b/composer.json
index 02f8749..88773a2 100644
--- a/composer.json
+++ b/composer.json
@@ -37,11 +37,19 @@
},
"require-dev": {
"babeuloula/phpcs": "^1.5",
+ "babeuloula/phpunit-coverage-checker": "^1.0",
"intervention/image": "^3.9",
"phpstan/phpstan": "^1.12",
"phpstan/phpstan-deprecation-rules": "^1.2",
+ "phpstan/phpstan-phpunit": "^1.0",
"phpstan/phpstan-strict-rules": "^1.6",
- "phpstan/phpstan-symfony": "^1.4"
+ "phpstan/phpstan-symfony": "^1.4",
+ "phpunit/phpunit": "^11.0"
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "BaBeuloula\\CdnPhpBundle\\Tests\\": "tests/"
+ }
},
"suggest": {
"intervention/image": "Needed to use InterventionImageFallback."
diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile
index 6833403..1ad5694 100644
--- a/docker/php/Dockerfile
+++ b/docker/php/Dockerfile
@@ -1,6 +1,6 @@
FROM php:8.2-cli
-ARG DOCKER_PHP_EXT=2.6.0
+ARG DOCKER_PHP_EXT=2.10.12
ADD https://github.com/mlocati/docker-php-extension-installer/releases/download/${DOCKER_PHP_EXT}/install-php-extensions /usr/local/bin/
RUN chmod +x /usr/local/bin/install-php-extensions
@@ -25,6 +25,9 @@ RUN \
rm -rf /var/lib/apt/lists/* && \
truncate -s 0 /var/log/*log
+# Install PHP extensions
+RUN install-php-extensions gd imagick pcov
+
# Install composer
RUN \
curl -sl https://getcomposer.org/composer-2.phar -o /usr/local/bin/composer && \
diff --git a/phpcs.xml b/phpcs.xml
index 6cf644d..998f6ce 100644
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -15,5 +15,6 @@
src/
+ tests/
diff --git a/phpstan.neon b/phpstan.neon
index 98d1448..dfe9eb2 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -2,6 +2,7 @@ includes:
- vendor/phpstan/phpstan-symfony/extension.neon
- vendor/phpstan/phpstan-symfony/rules.neon
- vendor/phpstan/phpstan-deprecation-rules/rules.neon
+ - vendor/phpstan/phpstan-phpunit/extension.neon
parameters:
tmpDir: .cache/phpstan
@@ -10,6 +11,7 @@ parameters:
paths:
- src
+ - tests
parallel:
jobSize: 20
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..492f44e
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,28 @@
+
+
+
+
+
+ tests/
+
+
+
+
+
+ src/
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AbstractHandler.php b/src/AbstractHandler.php
index c1e16e2..7a3a70b 100644
--- a/src/AbstractHandler.php
+++ b/src/AbstractHandler.php
@@ -31,7 +31,6 @@ public function parseHeaders(Request $request): array
{
$requestedHeaders = [
'accept-language',
- 'accept-encoding',
'accept',
'user-agent',
];
diff --git a/src/CdnPhpBundle.php b/src/CdnPhpBundle.php
index f2f957c..fac8d5a 100644
--- a/src/CdnPhpBundle.php
+++ b/src/CdnPhpBundle.php
@@ -80,6 +80,7 @@ public function loadExtension(array $config, ContainerConfigurator $container, C
->get(ProxyExtension::class)
->arg('$routeName', $config['twig']['route_name'])
->arg('$routeParameter', $config['twig']['route_parameter'])
+ ->arg('$encryptedParameters', $config['proxy']['encrypted_parameters'])
;
}
}
diff --git a/src/FallbackHandler/InterventionImageFallbackHandler.php b/src/FallbackHandler/InterventionImageFallbackHandler.php
index ef95f3a..5115ef1 100644
--- a/src/FallbackHandler/InterventionImageFallbackHandler.php
+++ b/src/FallbackHandler/InterventionImageFallbackHandler.php
@@ -43,24 +43,32 @@ public function response(string $file, ?Options $options = null, array $headers
{
$file = $this->normalizeFile($file);
- try {
- $image = $this->imageManager->read($this->assetsPath . $file);
- } catch (DecoderException $e) {
- throw new FileNotFoundException($file, previous: $e);
- }
+ $formatKey = match (true) {
+ $this->supportsAvif($headers) => 'avif',
+ $this->supportsWebp($headers) => 'webp',
+ default => 'original',
+ };
- $cacheKey = sha1($file . '?' . $options?->buildQuery());
+ $safeFile = (string) preg_replace('/[^a-zA-Z0-9_\-\.]/', '_', $file);
+ $optionsPart = null !== $options ? '_' . sha1($options->buildQuery(false)) : '';
+ $cacheKey = $safeFile . $optionsPart . '.' . $formatKey;
/** @var array{content: string, mimetype: string} $encodedImage */
$encodedImage = $this->cache->get(
$cacheKey,
- function (ItemInterface $item) use ($options, $image, $headers): array {
+ function (ItemInterface $item) use ($options, $file, $headers): array {
+ try {
+ $image = $this->imageManager->read($this->assetsPath . $file);
+ } catch (DecoderException $e) {
+ throw new FileNotFoundException($file, previous: $e);
+ }
+
if (null !== $options?->width && null !== $options->height) {
- $image->cover((int) $options->width, (int) $options->height);
+ $image->cover($options->width, $options->height);
} elseif (null !== $options?->width) {
- $image->scale(width: (int) $options->width);
+ $image->scale(width: $options->width);
} elseif (null !== $options?->height) {
- $image->scale(height: (int) $options->height);
+ $image->scale(height: $options->height);
}
$item->expiresAfter($this->cacheLifetime);
@@ -81,10 +89,7 @@ function (ItemInterface $item) use ($options, $image, $headers): array {
return new Response(
$encodedImage['content'],
Response::HTTP_OK,
- array_merge_recursive(
- $headers,
- ['Content-Type' => $encodedImage['mimetype']],
- ),
+ array_replace($headers, ['Content-Type' => $encodedImage['mimetype']]),
);
}
diff --git a/src/Options.php b/src/Options.php
index dae3df8..0ce5d14 100644
--- a/src/Options.php
+++ b/src/Options.php
@@ -26,8 +26,8 @@ final class Options
private const DEFAULT_SIGNATURE = null;
public function __construct(
- public readonly null|int|string $width = self::DEFAULT_WIDTH,
- public readonly null|int|string $height = self::DEFAULT_HEIGHT,
+ public readonly ?int $width = self::DEFAULT_WIDTH,
+ public readonly ?int $height = self::DEFAULT_HEIGHT,
public readonly ?string $watermarkUrl = self::DEFAULT_WATERMARK_URL,
public readonly string $watermarkGravity = self::DEFAULT_WATERMARK_POSITION,
public readonly int $watermarkScale = self::DEFAULT_WATERMARK_SCALE,
@@ -86,9 +86,12 @@ public function setSignature(string $signature): self
/** @param array $options */
public static function fromArray(array $options): self
{
+ $width = $options['width'] ?? $options['w'] ?? null;
+ $height = $options['height'] ?? $options['h'] ?? null;
+
return new self(
- $options['width'] ?? $options['w'] ?? self::DEFAULT_WIDTH,
- $options['height'] ?? $options['h'] ?? self::DEFAULT_HEIGHT,
+ null !== $width ? (int) $width : self::DEFAULT_WIDTH,
+ null !== $height ? (int) $height : self::DEFAULT_HEIGHT,
$options['watermarkUrl'] ?? $options['wu'] ?? $options['wat_url'] ?? self::DEFAULT_WATERMARK_URL,
$options['watermarkPosition'] ?? $options['wp'] ?? $options['wat_position'] ?? self::DEFAULT_WATERMARK_POSITION,
(int) ($options['watermarkScale'] ?? $options['ws'] ?? $options['wat_scale'] ?? self::DEFAULT_WATERMARK_SCALE),
diff --git a/src/Proxy.php b/src/Proxy.php
index e9d4232..e90a67c 100644
--- a/src/Proxy.php
+++ b/src/Proxy.php
@@ -107,6 +107,6 @@ public function response(string $file, ?Options $options = null, array $headers
private function exists(string $file): bool
{
- return $this->filesystem->exists($this->assetsPath . $this->normalizeFile($file));
+ return $this->filesystem->exists($this->assetsPath . $file);
}
}
diff --git a/src/Signer.php b/src/Signer.php
index 7eb3d9b..9e31bfd 100644
--- a/src/Signer.php
+++ b/src/Signer.php
@@ -13,8 +13,6 @@
namespace BaBeuloula\CdnPhpBundle;
-use Defuse\Crypto\Crypto;
-
final class Signer
{
public function __construct(
@@ -40,12 +38,12 @@ public function sign(Options $options): string
public function isValid(Options $options): bool
{
- return $this->calcSignature($options) === $options->signature;
+ return hash_equals($this->calcSignature($options), (string) $options->signature);
}
public function calcSignature(Options $options): string
{
- return sha1($options->buildQuery(false) . $this->getSecretKey());
+ return hash_hmac('sha256', $options->buildQuery(false), $this->getSecretKey());
}
public function isCdnSigningEnabled(): bool
diff --git a/src/Twig/Extension/ProxyExtension.php b/src/Twig/Extension/ProxyExtension.php
index bdf37b9..ff18ebb 100644
--- a/src/Twig/Extension/ProxyExtension.php
+++ b/src/Twig/Extension/ProxyExtension.php
@@ -26,7 +26,8 @@ public function __construct(
private readonly RouterInterface $router,
private readonly string $routeName,
private readonly string $routeParameter,
- private readonly Signer $encrypter,
+ private readonly Signer $signer,
+ private readonly bool $encryptedParameters = false,
) {
}
@@ -39,13 +40,12 @@ public function getFunctions(): array
}
/** @param array $options */
- public function cdnPhp(string $file, array $options = [], bool $enableEncrypter = true): string
+ public function cdnPhp(string $file, array $options = [], ?bool $enableEncrypter = null): string
{
$options = Options::fromArray($options);
- $queryParams = (true === $enableEncrypter)
- ? '?' . $this->encrypter->sign($options)
- : '?' . $options->buildQuery()
- ;
+ $useEncrypter = $enableEncrypter ?? $this->encryptedParameters;
+ $query = true === $useEncrypter ? $this->signer->sign($options) : $options->buildQuery();
+ $queryParams = '' !== $query ? '?' . $query : '';
return $this->router->generate(
$this->routeName,
diff --git a/tests/AbstractHandlerTest.php b/tests/AbstractHandlerTest.php
new file mode 100644
index 0000000..fb88e98
--- /dev/null
+++ b/tests/AbstractHandlerTest.php
@@ -0,0 +1,133 @@
+
+ * @copyright Copyright (c) BaBeuloula
+ * @license MIT
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace BaBeuloula\CdnPhpBundle\Tests;
+
+use BaBeuloula\CdnPhpBundle\AbstractHandler;
+use BaBeuloula\CdnPhpBundle\Options;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+final class AbstractHandlerTest extends TestCase
+{
+ private function makeHandler(string $assetsPath): AbstractHandler
+ {
+ // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
+ return new class ($assetsPath) extends AbstractHandler {
+ // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
+ public function response(string $file, ?Options $options = null, array $headers = []): Response
+ {
+ return new Response();
+ }
+ };
+ }
+
+ private function getAssetsPath(AbstractHandler $handler): string
+ {
+ /** @var string $value */
+ $value = (new \ReflectionProperty(AbstractHandler::class, 'assetsPath'))->getValue($handler);
+
+ return $value;
+ }
+
+ private function normalizeFile(AbstractHandler $handler, string $file): string
+ {
+ /** @var string $value */
+ $value = (new \ReflectionMethod(AbstractHandler::class, 'normalizeFile'))->invoke($handler, $file);
+
+ return $value;
+ }
+
+ public function testAssetsPathAddsTrailingSlash(): void
+ {
+ self::assertSame('/assets/', $this->getAssetsPath($this->makeHandler('/assets')));
+ }
+
+ public function testAssetsPathStripsExtraTrailingSlashes(): void
+ {
+ self::assertSame('/assets/', $this->getAssetsPath($this->makeHandler('/assets///')));
+ }
+
+ public function testAssetsPathAlreadyWithTrailingSlash(): void
+ {
+ self::assertSame('/assets/', $this->getAssetsPath($this->makeHandler('/assets/')));
+ }
+
+ public function testNormalizeFileStripsLeadingSlash(): void
+ {
+ self::assertSame('images/foo.jpg', $this->normalizeFile($this->makeHandler('/assets/'), '/images/foo.jpg'));
+ }
+
+ public function testNormalizeFileWithNoLeadingSlash(): void
+ {
+ self::assertSame('images/foo.jpg', $this->normalizeFile($this->makeHandler('/assets/'), 'images/foo.jpg'));
+ }
+
+ public function testParseHeadersExtractsKnownHeaders(): void
+ {
+ $request = Request::create(
+ '/',
+ 'GET',
+ [],
+ [],
+ [],
+ [
+ 'HTTP_ACCEPT' => 'image/webp,image/*',
+ 'HTTP_USER_AGENT' => 'Mozilla/5.0',
+ 'HTTP_ACCEPT_LANGUAGE' => 'fr-FR',
+ ]
+ );
+
+ $headers = $this->makeHandler('/assets/')->parseHeaders($request);
+
+ self::assertArrayHasKey('accept', $headers);
+ self::assertArrayHasKey('user-agent', $headers);
+ self::assertArrayHasKey('accept-language', $headers);
+ self::assertSame('image/webp,image/*', $headers['accept']);
+ self::assertSame('Mozilla/5.0', $headers['user-agent']);
+ self::assertSame('fr-FR', $headers['accept-language']);
+ }
+
+ public function testParseHeadersIgnoresUnknownHeaders(): void
+ {
+ $request = Request::create(
+ '/',
+ 'GET',
+ [],
+ [],
+ [],
+ [
+ 'HTTP_X_CUSTOM' => 'value',
+ 'HTTP_AUTHORIZATION' => 'Bearer token',
+ ]
+ );
+
+ $headers = $this->makeHandler('/assets/')->parseHeaders($request);
+
+ self::assertArrayNotHasKey('x-custom', $headers);
+ self::assertArrayNotHasKey('authorization', $headers);
+ }
+
+ public function testParseHeadersOmitsAbsentHeaders(): void
+ {
+ // Use new Request() directly to avoid Request::create() defaults (HTTP_ACCEPT, HTTP_ACCEPT_LANGUAGE)
+ $request = new Request([], [], [], [], [], ['HTTP_USER_AGENT' => 'Mozilla/5.0']);
+
+ $headers = $this->makeHandler('/assets/')->parseHeaders($request);
+
+ self::assertArrayHasKey('user-agent', $headers);
+ self::assertArrayNotHasKey('accept', $headers);
+ self::assertArrayNotHasKey('accept-language', $headers);
+ }
+}
diff --git a/tests/Exception/FetchAssetExceptionTest.php b/tests/Exception/FetchAssetExceptionTest.php
new file mode 100644
index 0000000..10efb5a
--- /dev/null
+++ b/tests/Exception/FetchAssetExceptionTest.php
@@ -0,0 +1,38 @@
+
+ * @copyright Copyright (c) BaBeuloula
+ * @license MIT
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace BaBeuloula\CdnPhpBundle\Tests\Exception;
+
+use BaBeuloula\CdnPhpBundle\Exception\FetchAssetException;
+use PHPUnit\Framework\TestCase;
+
+final class FetchAssetExceptionTest extends TestCase
+{
+ public function testIsInstanceOfException(): void
+ {
+ self::assertInstanceOf(\Exception::class, new FetchAssetException());
+ }
+
+ public function testAcceptsMessage(): void
+ {
+ $exception = new FetchAssetException('fetch failed');
+ self::assertSame('fetch failed', $exception->getMessage());
+ }
+
+ public function testAcceptsPreviousException(): void
+ {
+ $previous = new \RuntimeException('original');
+ $exception = new FetchAssetException('', 0, $previous);
+ self::assertSame($previous, $exception->getPrevious());
+ }
+}
diff --git a/tests/Exception/FileNotFoundExceptionTest.php b/tests/Exception/FileNotFoundExceptionTest.php
new file mode 100644
index 0000000..77c42f2
--- /dev/null
+++ b/tests/Exception/FileNotFoundExceptionTest.php
@@ -0,0 +1,38 @@
+
+ * @copyright Copyright (c) BaBeuloula
+ * @license MIT
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace BaBeuloula\CdnPhpBundle\Tests\Exception;
+
+use BaBeuloula\CdnPhpBundle\Exception\FileNotFoundException;
+use PHPUnit\Framework\TestCase;
+
+final class FileNotFoundExceptionTest extends TestCase
+{
+ public function testIsInstanceOfException(): void
+ {
+ self::assertInstanceOf(\Exception::class, new FileNotFoundException());
+ }
+
+ public function testAcceptsMessage(): void
+ {
+ $exception = new FileNotFoundException('file not found');
+ self::assertSame('file not found', $exception->getMessage());
+ }
+
+ public function testAcceptsPreviousException(): void
+ {
+ $previous = new \RuntimeException('original');
+ $exception = new FileNotFoundException('', 0, $previous);
+ self::assertSame($previous, $exception->getPrevious());
+ }
+}
diff --git a/tests/FallbackHandler/InterventionImageFallbackHandlerTest.php b/tests/FallbackHandler/InterventionImageFallbackHandlerTest.php
new file mode 100644
index 0000000..9633c2e
--- /dev/null
+++ b/tests/FallbackHandler/InterventionImageFallbackHandlerTest.php
@@ -0,0 +1,203 @@
+
+ * @copyright Copyright (c) BaBeuloula
+ * @license MIT
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace BaBeuloula\CdnPhpBundle\Tests\FallbackHandler;
+
+use BaBeuloula\CdnPhpBundle\Exception\FileNotFoundException;
+use BaBeuloula\CdnPhpBundle\FallbackHandler\InterventionImageFallbackHandler;
+use BaBeuloula\CdnPhpBundle\Options;
+use Intervention\Image\Interfaces\DriverInterface;
+use PHPUnit\Framework\Attributes\RequiresPhpExtension;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Cache\Adapter\ArrayAdapter;
+use Symfony\Contracts\Cache\CacheInterface;
+
+final class InterventionImageFallbackHandlerTest extends TestCase
+{
+ private const FIXTURES_PATH = __DIR__ . '/../Fixtures/';
+
+ /** @param array $headers */
+ private function makeCacheCapturingKey(array $headers, ?Options $options = null): string
+ {
+ /** @var DriverInterface&MockObject $driver */
+ $driver = $this->createMock(DriverInterface::class);
+ /** @var CacheInterface&MockObject $cache */
+ $cache = $this->createMock(CacheInterface::class);
+
+ $capturedKey = '';
+ $cache->method('get')
+ ->willReturnCallback(
+ static function (string $key) use (&$capturedKey): array {
+ $capturedKey = $key;
+
+ return ['content' => '', 'mimetype' => 'image/png'];
+ }
+ );
+
+ $handler = new InterventionImageFallbackHandler($driver, self::FIXTURES_PATH, $cache);
+ $handler->response('test.png', $options, $headers);
+
+ return $capturedKey;
+ }
+
+ public function testFormatKeyIsAvifWhenAcceptContainsAvif(): void
+ {
+ $key = $this->makeCacheCapturingKey(['accept' => 'image/avif,image/webp']);
+ self::assertStringEndsWith('.avif', $key);
+ }
+
+ public function testFormatKeyIsWebpWhenAcceptContainsWebp(): void
+ {
+ $key = $this->makeCacheCapturingKey(['accept' => 'image/webp']);
+ self::assertStringEndsWith('.webp', $key);
+ }
+
+ public function testFormatKeyIsOriginalWhenNoAcceptHeader(): void
+ {
+ $key = $this->makeCacheCapturingKey([]);
+ self::assertStringEndsWith('.original', $key);
+ }
+
+ public function testFormatKeyIsAvifOverWebpWhenBothAccepted(): void
+ {
+ $key = $this->makeCacheCapturingKey(['accept' => 'image/avif,image/webp,image/*']);
+ self::assertStringEndsWith('.avif', $key);
+ }
+
+ public function testCacheKeyIncludesFile(): void
+ {
+ $key = $this->makeCacheCapturingKey([]);
+ self::assertStringContainsString('test', $key);
+ }
+
+ public function testCacheKeyDiffersForDifferentOptions(): void
+ {
+ $keyA = $this->makeCacheCapturingKey([], new Options(200));
+ $keyB = $this->makeCacheCapturingKey([], new Options(300));
+
+ self::assertNotSame($keyA, $keyB);
+ }
+
+ public function testCacheKeyDiffersForDifferentFormats(): void
+ {
+ $keyAvif = $this->makeCacheCapturingKey(['accept' => 'image/avif']);
+ $keyWebp = $this->makeCacheCapturingKey(['accept' => 'image/webp']);
+ $keyOrig = $this->makeCacheCapturingKey([]);
+
+ self::assertNotSame($keyAvif, $keyWebp);
+ self::assertNotSame($keyAvif, $keyOrig);
+ self::assertNotSame($keyWebp, $keyOrig);
+ }
+
+ #[RequiresPhpExtension('gd')]
+ public function testCacheMissProcessesImageAndReturnsResponse(): void
+ {
+ $driver = new \Intervention\Image\Drivers\Gd\Driver();
+ $handler = new InterventionImageFallbackHandler($driver, self::FIXTURES_PATH, new ArrayAdapter());
+
+ $response = $handler->response('test.png');
+
+ self::assertSame(200, $response->getStatusCode());
+ self::assertNotEmpty($response->getContent());
+ }
+
+ #[RequiresPhpExtension('gd')]
+ public function testCacheHitSkipsProcessingAndReturnsResponse(): void
+ {
+ $driver = new \Intervention\Image\Drivers\Gd\Driver();
+ $cache = new ArrayAdapter();
+ $handler = new InterventionImageFallbackHandler($driver, self::FIXTURES_PATH, $cache);
+
+ // First call populates cache
+ $handler->response('test.png');
+
+ // Second call should hit the cache (same result, no re-processing)
+ $response = $handler->response('test.png');
+ self::assertSame(200, $response->getStatusCode());
+ }
+
+ #[RequiresPhpExtension('gd')]
+ public function testResponseSetsCorrectContentType(): void
+ {
+ $driver = new \Intervention\Image\Drivers\Gd\Driver();
+ $handler = new InterventionImageFallbackHandler($driver, self::FIXTURES_PATH, new ArrayAdapter());
+
+ $response = $handler->response('test.png');
+
+ self::assertNotEmpty($response->headers->get('Content-Type'));
+ }
+
+ #[RequiresPhpExtension('gd')]
+ public function testResponseContentTypeReplacesPassedHeaders(): void
+ {
+ $driver = new \Intervention\Image\Drivers\Gd\Driver();
+ $handler = new InterventionImageFallbackHandler($driver, self::FIXTURES_PATH, new ArrayAdapter());
+
+ $response = $handler->response('test.png', null, ['content-type' => 'wrong/type']);
+ $contentType = $response->headers->get('Content-Type');
+
+ self::assertNotNull($contentType);
+ self::assertNotSame('wrong/type', $contentType);
+ }
+
+ #[RequiresPhpExtension('gd')]
+ public function testDecoderExceptionThrowsFileNotFoundException(): void
+ {
+ $driver = new \Intervention\Image\Drivers\Gd\Driver();
+ $handler = new InterventionImageFallbackHandler($driver, self::FIXTURES_PATH, new ArrayAdapter());
+
+ $this->expectException(FileNotFoundException::class);
+ $handler->response('nonexistent-file-that-does-not-exist.jpg');
+ }
+
+ #[RequiresPhpExtension('gd')]
+ public function testBothDimensionsUsesCover(): void
+ {
+ $driver = new \Intervention\Image\Drivers\Gd\Driver();
+ $handler = new InterventionImageFallbackHandler($driver, self::FIXTURES_PATH, new ArrayAdapter());
+
+ $response = $handler->response('test.png', new Options(5, 5));
+ self::assertSame(200, $response->getStatusCode());
+ }
+
+ #[RequiresPhpExtension('gd')]
+ public function testWidthOnlyScalesImage(): void
+ {
+ $driver = new \Intervention\Image\Drivers\Gd\Driver();
+ $handler = new InterventionImageFallbackHandler($driver, self::FIXTURES_PATH, new ArrayAdapter());
+
+ $response = $handler->response('test.png', new Options(5));
+ self::assertSame(200, $response->getStatusCode());
+ }
+
+ #[RequiresPhpExtension('gd')]
+ public function testHeightOnlyScalesImage(): void
+ {
+ $driver = new \Intervention\Image\Drivers\Gd\Driver();
+ $handler = new InterventionImageFallbackHandler($driver, self::FIXTURES_PATH, new ArrayAdapter());
+
+ $response = $handler->response('test.png', new Options(null, 5));
+ self::assertSame(200, $response->getStatusCode());
+ }
+
+ #[RequiresPhpExtension('gd')]
+ public function testWebpEncodingWhenAccepted(): void
+ {
+ $driver = new \Intervention\Image\Drivers\Gd\Driver();
+ $handler = new InterventionImageFallbackHandler($driver, self::FIXTURES_PATH, new ArrayAdapter());
+
+ $response = $handler->response('test.png', null, ['accept' => 'image/webp']);
+ self::assertSame('image/webp', $response->headers->get('Content-Type'));
+ }
+}
diff --git a/tests/Fixtures/test.png b/tests/Fixtures/test.png
new file mode 100644
index 0000000..d0a5700
Binary files /dev/null and b/tests/Fixtures/test.png differ
diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php
new file mode 100644
index 0000000..2f4c6aa
--- /dev/null
+++ b/tests/OptionsTest.php
@@ -0,0 +1,164 @@
+
+ * @copyright Copyright (c) BaBeuloula
+ * @license MIT
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace BaBeuloula\CdnPhpBundle\Tests;
+
+use BaBeuloula\CdnPhpBundle\Options;
+use PHPUnit\Framework\TestCase;
+
+final class OptionsTest extends TestCase
+{
+ public function testToArrayEmpty(): void
+ {
+ self::assertSame([], (new Options())->toArray());
+ }
+
+ public function testToArrayWithDimensions(): void
+ {
+ $result = (new Options(200, 100))->toArray();
+ self::assertSame(['w' => 200, 'h' => 100], $result);
+ }
+
+ public function testToArrayExcludesNullValues(): void
+ {
+ $result = (new Options(width: 200))->toArray();
+ self::assertArrayHasKey('w', $result);
+ self::assertArrayNotHasKey('h', $result);
+ }
+
+ public function testToArrayWithWatermarkIncludesWatermarkKeys(): void
+ {
+ $result = (new Options(watermarkUrl: 'http://example.com/wm.png'))->toArray();
+ self::assertArrayHasKey('wu', $result);
+ self::assertArrayHasKey('wp', $result);
+ self::assertArrayHasKey('ws', $result);
+ self::assertArrayHasKey('wo', $result);
+ }
+
+ public function testToArrayWithoutWatermarkExcludesWatermarkKeys(): void
+ {
+ $result = (new Options(200))->toArray();
+ self::assertArrayNotHasKey('wu', $result);
+ self::assertArrayNotHasKey('wp', $result);
+ self::assertArrayNotHasKey('ws', $result);
+ self::assertArrayNotHasKey('wo', $result);
+ }
+
+ public function testToArrayIncludesSignatureWhenRequested(): void
+ {
+ $result = (new Options(signature: 'abc123'))->toArray(true);
+ self::assertArrayHasKey(Options::SIGNATURE_KEY, $result);
+ self::assertSame('abc123', $result[Options::SIGNATURE_KEY]);
+ }
+
+ public function testToArrayExcludesSignatureWhenFalse(): void
+ {
+ $result = (new Options(signature: 'abc123'))->toArray(false);
+ self::assertArrayNotHasKey(Options::SIGNATURE_KEY, $result);
+ }
+
+ public function testBuildQueryEmpty(): void
+ {
+ self::assertSame('', (new Options())->buildQuery());
+ }
+
+ public function testBuildQueryWithDimensions(): void
+ {
+ self::assertSame('w=200&h=100', (new Options(200, 100))->buildQuery());
+ }
+
+ public function testBuildQueryExcludesSignatureWhenFalse(): void
+ {
+ $query = (new Options(200, signature: 'sig'))->buildQuery(false);
+ self::assertStringNotContainsString('signature', $query);
+ }
+
+ public function testHasSignatureReturnsFalseByDefault(): void
+ {
+ self::assertFalse((new Options())->hasSignature());
+ }
+
+ public function testHasSignatureReturnsTrueWhenSet(): void
+ {
+ self::assertTrue((new Options(signature: 'abc'))->hasSignature());
+ }
+
+ public function testSetSignatureReturnsNewImmutableInstance(): void
+ {
+ $original = new Options(200);
+ $new = $original->setSignature('xyz');
+
+ self::assertNotSame($original, $new);
+ self::assertSame('xyz', $new->signature);
+ self::assertNull($original->signature);
+ }
+
+ public function testSetSignaturePreservesOtherValues(): void
+ {
+ $original = new Options(200, 100, watermarkUrl: 'http://example.com/wm.png');
+ $new = $original->setSignature('xyz');
+
+ self::assertSame(200, $new->width);
+ self::assertSame(100, $new->height);
+ self::assertSame('http://example.com/wm.png', $new->watermarkUrl);
+ }
+
+ public function testFromArrayWithShortAliases(): void
+ {
+ $options = Options::fromArray(['w' => 200, 'h' => 100]);
+ self::assertSame(200, $options->width);
+ self::assertSame(100, $options->height);
+ }
+
+ public function testFromArrayWithLongAliases(): void
+ {
+ $options = Options::fromArray(['width' => 300, 'height' => 150]);
+ self::assertSame(300, $options->width);
+ self::assertSame(150, $options->height);
+ }
+
+ public function testFromArrayWithWatLegacyAliases(): void
+ {
+ $options = Options::fromArray(
+ [
+ 'wat_url' => 'http://example.com/wm.png',
+ 'wat_position' => 'top',
+ 'wat_scale' => 80,
+ 'wat_opacity' => 40,
+ ]
+ );
+
+ self::assertSame('http://example.com/wm.png', $options->watermarkUrl);
+ self::assertSame('top', $options->watermarkGravity);
+ self::assertSame(80, $options->watermarkScale);
+ self::assertSame(40, $options->watermarkOpacity);
+ }
+
+ public function testFromArrayWithEmptyArrayUsesDefaults(): void
+ {
+ $options = Options::fromArray([]);
+ self::assertNull($options->width);
+ self::assertNull($options->height);
+ self::assertNull($options->watermarkUrl);
+ self::assertSame('center', $options->watermarkGravity);
+ self::assertSame(75, $options->watermarkScale);
+ self::assertSame(50, $options->watermarkOpacity);
+ }
+
+ public function testFromArrayNormalizesStringDimensionsToInt(): void
+ {
+ $options = Options::fromArray(['w' => '200', 'h' => '100']);
+ self::assertSame(200, $options->width);
+ self::assertSame(100, $options->height);
+ }
+}
diff --git a/tests/ProxyTest.php b/tests/ProxyTest.php
new file mode 100644
index 0000000..98b5c72
--- /dev/null
+++ b/tests/ProxyTest.php
@@ -0,0 +1,281 @@
+
+ * @copyright Copyright (c) BaBeuloula
+ * @license MIT
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace BaBeuloula\CdnPhpBundle\Tests;
+
+use BaBeuloula\CdnPhpBundle\Exception\FetchAssetException;
+use BaBeuloula\CdnPhpBundle\Exception\FileNotFoundException;
+use BaBeuloula\CdnPhpBundle\FallbackHandler\FallbackHandlerInterface;
+use BaBeuloula\CdnPhpBundle\Options;
+use BaBeuloula\CdnPhpBundle\Proxy;
+use BaBeuloula\CdnPhpBundle\Signer;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\Component\HttpClient\MockHttpClient;
+use Symfony\Component\HttpClient\Response\MockResponse;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\EventListener\AbstractSessionListener;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+
+final class ProxyTest extends TestCase
+{
+ private const CDN_URL = 'http://cdn.example.com/';
+ private const ASSETS_PATH = '/var/www/assets/';
+
+ /** @var Filesystem&MockObject */
+ private Filesystem $filesystem;
+
+ protected function setUp(): void
+ {
+ $this->filesystem = $this->createMock(Filesystem::class);
+ }
+
+ private function makeProxy(
+ MockHttpClient $client,
+ bool $checkAssets = false,
+ ?FallbackHandlerInterface $fallback = null,
+ ?Signer $signer = null,
+ ): Proxy {
+ return new Proxy(
+ self::ASSETS_PATH,
+ $checkAssets,
+ $this->filesystem,
+ $client,
+ self::CDN_URL,
+ $signer ?? new Signer(),
+ $fallback,
+ );
+ }
+
+ public function testResponseFetchesCdnAndCopiesWhitelistedHeaders(): void
+ {
+ $client = new MockHttpClient(
+ new MockResponse(
+ 'img-content',
+ [
+ 'response_headers' => [
+ 'content-type' => ['image/jpeg'],
+ 'cache-control' => ['max-age=3600'],
+ 'etag' => ['"abc123"'],
+ 'last-modified' => ['Mon, 01 Jan 2024 00:00:00 GMT'],
+ 'expires' => ['Mon, 01 Jan 2025 00:00:00 GMT'],
+ 'content-encoding' => ['identity'],
+ 'content-length' => ['11'],
+ 'vary' => ['Accept'],
+ 'x-content-type-options' => ['nosniff'],
+ 'x-dominant-color' => ['#ff0000'],
+ ],
+ ]
+ )
+ );
+
+ $response = $this->makeProxy($client)->response('image.jpg');
+
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('img-content', $response->getContent());
+ self::assertSame('image/jpeg', $response->headers->get('content-type'));
+ self::assertStringContainsString('max-age=3600', (string) $response->headers->get('cache-control'));
+ self::assertSame('"abc123"', $response->headers->get('etag'));
+ self::assertSame('nosniff', $response->headers->get('x-content-type-options'));
+ self::assertSame('#ff0000', $response->headers->get('x-dominant-color'));
+ }
+
+ public function testResponseSetsNoCacheControlHeader(): void
+ {
+ $client = new MockHttpClient(new MockResponse('content'));
+ $response = $this->makeProxy($client)->response('image.jpg');
+
+ self::assertSame('true', $response->headers->get(AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER));
+ }
+
+ public function testResponseIgnoresNonWhitelistedHeaders(): void
+ {
+ $client = new MockHttpClient(
+ new MockResponse(
+ 'content',
+ [
+ 'response_headers' => ['x-secret' => ['value']],
+ ]
+ )
+ );
+
+ $response = $this->makeProxy($client)->response('image.jpg');
+
+ self::assertFalse($response->headers->has('x-secret'));
+ }
+
+ public function testResponseNormalizesFileStrippingLeadingSlash(): void
+ {
+ $requestedUrl = '';
+ $client = new MockHttpClient(
+ // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
+ static function (string $method, string $url) use (&$requestedUrl): MockResponse {
+ $requestedUrl = $url;
+
+ return new MockResponse('content');
+ }
+ );
+
+ $this->makeProxy($client)->response('/image.jpg');
+
+ self::assertStringContainsString(self::CDN_URL . 'image.jpg', $requestedUrl);
+ self::assertStringNotContainsString('//image.jpg', $requestedUrl);
+ }
+
+ public function testCheckAssetsEnabledFileExists(): void
+ {
+ $this->filesystem->expects(self::once())
+ ->method('exists')
+ ->willReturn(true);
+
+ $client = new MockHttpClient(new MockResponse('content'));
+ $this->makeProxy($client, checkAssets: true)->response('image.jpg');
+ }
+
+ public function testCheckAssetsEnabledFileMissingThrowsFileNotFoundException(): void
+ {
+ $this->filesystem->expects(self::once())
+ ->method('exists')
+ ->willReturn(false);
+
+ $client = new MockHttpClient(new MockResponse('content'));
+
+ $this->expectException(FileNotFoundException::class);
+ $this->makeProxy($client, checkAssets: true)->response('image.jpg');
+ }
+
+ public function testCheckAssetsDisabledNeverCallsFilesystem(): void
+ {
+ $this->filesystem->expects(self::never())->method('exists');
+
+ $client = new MockHttpClient(new MockResponse('content'));
+ $this->makeProxy($client, checkAssets: false)->response('image.jpg');
+ }
+
+ public function testResponseWithOptionsBuildsQueryString(): void
+ {
+ $requestedUrl = '';
+ $client = new MockHttpClient(
+ // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
+ static function (string $method, string $url) use (&$requestedUrl): MockResponse {
+ $requestedUrl = $url;
+
+ return new MockResponse('content');
+ }
+ );
+
+ $this->makeProxy($client)->response('image.jpg', new Options(200, 100));
+
+ self::assertStringContainsString('w=200&h=100', $requestedUrl);
+ }
+
+ public function testResponseWithCdnSigningAppendsExpiresAndSig(): void
+ {
+ $requestedUrl = '';
+ $client = new MockHttpClient(
+ // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
+ static function (string $method, string $url) use (&$requestedUrl): MockResponse {
+ $requestedUrl = $url;
+
+ return new MockResponse('content');
+ }
+ );
+
+ $signer = new Signer(null, 'test-cdn-secret');
+ $this->makeProxy($client, signer: $signer)->response('image.jpg');
+
+ self::assertStringContainsString('expires=', $requestedUrl);
+ self::assertStringContainsString('sig=', $requestedUrl);
+ }
+
+ public function testResponseWithCdnSigningAndOptionsAppendsToExistingQuery(): void
+ {
+ $requestedUrl = '';
+ $client = new MockHttpClient(
+ // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
+ static function (string $method, string $url) use (&$requestedUrl): MockResponse {
+ $requestedUrl = $url;
+
+ return new MockResponse('content');
+ }
+ );
+
+ $signer = new Signer(null, 'test-cdn-secret');
+ $this->makeProxy($client, signer: $signer)->response('image.jpg', new Options(200));
+
+ self::assertStringContainsString('w=200', $requestedUrl);
+ self::assertStringContainsString('expires=', $requestedUrl);
+ self::assertStringContainsString('sig=', $requestedUrl);
+ }
+
+ public function testHttpTimeoutIs25(): void
+ {
+ $capturedOptions = [];
+ $client = new MockHttpClient(
+ // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
+ static function (string $method, string $url, array $options) use (&$capturedOptions): MockResponse {
+ $capturedOptions = $options;
+
+ return new MockResponse('content');
+ }
+ );
+
+ $this->makeProxy($client)->response('image.jpg');
+
+ self::assertSame(25.0, $capturedOptions['timeout']);
+ }
+
+ public function testExceptionWithFallbackCallsFallback(): void
+ {
+ $client = new MockHttpClient(
+ static function (): never {
+ throw new \RuntimeException('CDN unavailable', 503);
+ }
+ );
+
+ $fallbackResponse = new Response('fallback-content', 200, ['content-type' => 'image/jpeg']);
+ $fallback = $this->createMock(FallbackHandlerInterface::class);
+ $fallback->expects(self::once())
+ ->method('response')
+ ->willReturn($fallbackResponse);
+
+ $response = $this->makeProxy($client, fallback: $fallback)->response('image.jpg');
+
+ self::assertSame('fallback-content', $response->getContent());
+ }
+
+ public function testExceptionWithout404ThrowsFetchAssetException(): void
+ {
+ $client = new MockHttpClient(
+ static function (): never {
+ throw new \RuntimeException('Server error', 500);
+ }
+ );
+
+ $this->expectException(FetchAssetException::class);
+ $this->makeProxy($client)->response('image.jpg');
+ }
+
+ public function testExceptionWith404ThrowsNotFoundHttpException(): void
+ {
+ $client = new MockHttpClient(
+ static function (): never {
+ throw new \RuntimeException('Not found', Response::HTTP_NOT_FOUND);
+ }
+ );
+
+ $this->expectException(NotFoundHttpException::class);
+ $this->makeProxy($client)->response('image.jpg');
+ }
+}
diff --git a/tests/SignerTest.php b/tests/SignerTest.php
new file mode 100644
index 0000000..2900ad5
--- /dev/null
+++ b/tests/SignerTest.php
@@ -0,0 +1,122 @@
+
+ * @copyright Copyright (c) BaBeuloula
+ * @license MIT
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace BaBeuloula\CdnPhpBundle\Tests;
+
+use BaBeuloula\CdnPhpBundle\Options;
+use BaBeuloula\CdnPhpBundle\Signer;
+use PHPUnit\Framework\TestCase;
+
+final class SignerTest extends TestCase
+{
+ public function testIsEnabledWithNull(): void
+ {
+ self::assertFalse((new Signer(null))->isEnabled());
+ }
+
+ public function testIsEnabledWithEmptyString(): void
+ {
+ self::assertFalse((new Signer(''))->isEnabled());
+ }
+
+ public function testIsEnabledWithKey(): void
+ {
+ self::assertTrue((new Signer('secret'))->isEnabled());
+ }
+
+ public function testCalcSignatureIsDeterministic(): void
+ {
+ $signer = new Signer('my-secret');
+ $options = new Options(200, 100);
+
+ self::assertSame($signer->calcSignature($options), $signer->calcSignature($options));
+ }
+
+ public function testCalcSignatureMatchesExpectedHmac(): void
+ {
+ $signer = new Signer('my-secret');
+ $options = new Options(200, 100);
+ $expected = hash_hmac('sha256', $options->buildQuery(false), 'my-secret');
+
+ self::assertSame($expected, $signer->calcSignature($options));
+ }
+
+ public function testSignWithDisabledSignerReturnsRawQuery(): void
+ {
+ $query = (new Signer(null))->sign(new Options(200));
+ self::assertStringNotContainsString('signature=', $query);
+ self::assertSame('w=200', $query);
+ }
+
+ public function testSignWithEnabledSignerAddsSignature(): void
+ {
+ $query = (new Signer('key'))->sign(new Options(200));
+ self::assertStringContainsString('signature=', $query);
+ }
+
+ public function testIsValidReturnsTrueForCorrectSignature(): void
+ {
+ $signer = new Signer('key');
+ $options = new Options(200, 100);
+ $signature = $signer->calcSignature($options);
+ $signed = new Options(200, 100, signature: $signature);
+
+ self::assertTrue($signer->isValid($signed));
+ }
+
+ public function testIsValidReturnsFalseForWrongSignature(): void
+ {
+ $signer = new Signer('key');
+ $options = new Options(200, 100, signature: 'wrong-signature');
+
+ self::assertFalse($signer->isValid($options));
+ }
+
+ public function testIsCdnSigningEnabledWithNull(): void
+ {
+ self::assertFalse((new Signer(null, null))->isCdnSigningEnabled());
+ }
+
+ public function testIsCdnSigningEnabledWithKey(): void
+ {
+ self::assertTrue((new Signer(null, 'cdn-key'))->isCdnSigningEnabled());
+ }
+
+ public function testSignCdnRequestReturnsExpiresAndSig(): void
+ {
+ $result = (new Signer(null, 'cdn-key'))->signCdnRequest('images/foo.jpg');
+
+ self::assertArrayHasKey('expires', $result);
+ self::assertArrayHasKey('sig', $result);
+ self::assertIsInt($result['expires']);
+ self::assertIsString($result['sig']);
+ }
+
+ public function testSignCdnRequestExpiresApproximatesNowPlusTtl(): void
+ {
+ $ttl = 3600;
+ $result = (new Signer(null, 'cdn-key', $ttl))->signCdnRequest('img.jpg');
+ $expected = time() + $ttl;
+
+ self::assertEqualsWithDelta($expected, $result['expires'], 5.0);
+ }
+
+ public function testSignCdnRequestNormalizesLeadingSlash(): void
+ {
+ $signer = new Signer(null, 'cdn-key', 3600);
+ $withSlash = $signer->signCdnRequest('/images/foo.jpg');
+ $withoutSlash = $signer->signCdnRequest('images/foo.jpg');
+
+ self::assertSame($withSlash['sig'], $withoutSlash['sig']);
+ }
+}
diff --git a/tests/Twig/Extension/ProxyExtensionTest.php b/tests/Twig/Extension/ProxyExtensionTest.php
new file mode 100644
index 0000000..7f2da9d
--- /dev/null
+++ b/tests/Twig/Extension/ProxyExtensionTest.php
@@ -0,0 +1,159 @@
+
+ * @copyright Copyright (c) BaBeuloula
+ * @license MIT
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace BaBeuloula\CdnPhpBundle\Tests\Twig\Extension;
+
+use BaBeuloula\CdnPhpBundle\Signer;
+use BaBeuloula\CdnPhpBundle\Twig\Extension\ProxyExtension;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+use Symfony\Component\Routing\RouterInterface;
+use Twig\TwigFunction;
+
+final class ProxyExtensionTest extends TestCase
+{
+ private const ROUTE_NAME = 'app_cdn';
+ private const ROUTE_PARAMETER = 'path';
+ private const GENERATED_URL = 'http://example.com/cdn/image.jpg';
+
+ /** @var RouterInterface&MockObject */
+ private RouterInterface $router;
+
+ protected function setUp(): void
+ {
+ $this->router = $this->createMock(RouterInterface::class);
+ $this->router->method('generate')->willReturn(self::GENERATED_URL);
+ }
+
+ private function makeExtension(bool $encryptedParameters = false, ?Signer $signer = null): ProxyExtension
+ {
+ return new ProxyExtension(
+ $this->router,
+ self::ROUTE_NAME,
+ self::ROUTE_PARAMETER,
+ $signer ?? new Signer(),
+ $encryptedParameters,
+ );
+ }
+
+ public function testGetFunctionsReturnsTwoFunctions(): void
+ {
+ $functions = $this->makeExtension()->getFunctions();
+
+ self::assertCount(2, $functions);
+ self::assertContainsOnlyInstancesOf(TwigFunction::class, $functions);
+
+ $names = array_map(static fn (TwigFunction $f) => $f->getName(), $functions);
+ self::assertContains('cdn_php', $names);
+ self::assertContains('cdn', $names);
+ }
+
+ public function testCdnPhpWithNoOptionsAndEncryptionDisabledReturnsUrl(): void
+ {
+ $url = $this->makeExtension()->cdnPhp('image.jpg');
+
+ self::assertSame(self::GENERATED_URL, $url);
+ }
+
+ public function testCdnPhpWithOptionsAppendsQueryString(): void
+ {
+ $url = $this->makeExtension()->cdnPhp('image.jpg', ['w' => 200]);
+
+ self::assertStringContainsString('?', $url);
+ self::assertStringContainsString('w=200', $url);
+ }
+
+ public function testCdnPhpWithEmptyQueryNoTrailingQuestionMark(): void
+ {
+ $url = $this->makeExtension()->cdnPhp('image.jpg', []);
+
+ self::assertStringEndsNotWith('?', $url);
+ }
+
+ public function testCdnPhpStripsLeadingSlashFromFile(): void
+ {
+ $this->router->expects(self::once())
+ ->method('generate')
+ ->with(
+ self::ROUTE_NAME,
+ [self::ROUTE_PARAMETER => 'image.jpg'],
+ UrlGeneratorInterface::ABSOLUTE_URL,
+ )
+ ->willReturn(self::GENERATED_URL);
+
+ $this->makeExtension()->cdnPhp('/image.jpg');
+ }
+
+ public function testCdnPhpCallsRouterWithAbsoluteUrl(): void
+ {
+ $this->router->expects(self::once())
+ ->method('generate')
+ ->with(
+ self::anything(),
+ self::anything(),
+ UrlGeneratorInterface::ABSOLUTE_URL,
+ )
+ ->willReturn(self::GENERATED_URL);
+
+ $this->makeExtension()->cdnPhp('image.jpg');
+ }
+
+ public function testCdnPhpWithEncryptionEnabledAddsSignature(): void
+ {
+ $url = $this->makeExtension(
+ encryptedParameters: true,
+ signer: new Signer('test-secret'),
+ )->cdnPhp('image.jpg', ['w' => 200]);
+
+ self::assertStringContainsString('signature=', $url);
+ }
+
+ public function testCdnPhpWithEncryptionDisabledNoSignature(): void
+ {
+ $url = $this->makeExtension(
+ encryptedParameters: false,
+ signer: new Signer('test-secret'),
+ )->cdnPhp('image.jpg', ['w' => 200]);
+
+ self::assertStringNotContainsString('signature=', $url);
+ }
+
+ public function testExplicitEnableEncrypterTrueOverridesDefaultFalse(): void
+ {
+ $url = $this->makeExtension(
+ encryptedParameters: false,
+ signer: new Signer('test-secret'),
+ )->cdnPhp('image.jpg', ['w' => 200], true);
+
+ self::assertStringContainsString('signature=', $url);
+ }
+
+ public function testExplicitEnableEncrypterFalseOverridesDefaultTrue(): void
+ {
+ $url = $this->makeExtension(
+ encryptedParameters: true,
+ signer: new Signer('test-secret'),
+ )->cdnPhp('image.jpg', ['w' => 200], false);
+
+ self::assertStringNotContainsString('signature=', $url);
+ }
+
+ public function testCdnPhpWithEncryptionAndEmptyQueryAppendsNothing(): void
+ {
+ // Disabled signer (null key) returns empty query for options with no values
+ $url = $this->makeExtension(encryptedParameters: true)->cdnPhp('image.jpg');
+
+ self::assertStringEndsNotWith('?', $url);
+ }
+}