diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index bc5e177..c8596fb 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -29,8 +29,8 @@ We're sorry to hear you have a problem. Can you help us solve it by providing th attributes: label: PHP Version description: What version of PHP are you running? Please be as specific as possible - placeholder: "8.4.0" - value: "8.4.0" + placeholder: "8.5.0" + value: "8.5.0" validations: required: true - type: input @@ -38,8 +38,8 @@ We're sorry to hear you have a problem. Can you help us solve it by providing th attributes: label: Laravel Version description: What version of Laravel are you running? Please be as specific as possible - placeholder: "12.0.0" - value: "12.0.0" + placeholder: "13.0.0" + value: "13.0.0" validations: required: true - type: dropdown diff --git a/.github/workflows/composer-audit.yml b/.github/workflows/composer-audit.yml index ed5c547..72698cf 100644 --- a/.github/workflows/composer-audit.yml +++ b/.github/workflows/composer-audit.yml @@ -28,7 +28,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@2.37.0 with: - php-version: '8.4' + php-version: '8.5' coverage: none - name: Resolve dependencies and audit diff --git a/.github/workflows/fix-php-code-style-issues.yml b/.github/workflows/fix-php-code-style-issues.yml index 41e6193..1416546 100644 --- a/.github/workflows/fix-php-code-style-issues.yml +++ b/.github/workflows/fix-php-code-style-issues.yml @@ -13,8 +13,17 @@ jobs: - name: Checkout code uses: actions/checkout@v6.0.2 - - name: Fix PHP code style issues - uses: aglipanci/laravel-pint-action@2.6 + - name: Setup PHP + uses: shivammathur/setup-php@2.37.0 + with: + php-version: '8.5' + coverage: none + + - name: Install composer dependencies + uses: ramsey/composer-install@4.0.0 + + - name: Run Laravel Pint + run: vendor/bin/pint - name: Commit changes uses: stefanzweifel/git-auto-commit-action@v7.1.0 diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 1802312..5bbb64c 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -32,7 +32,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@2.37.0 with: - php-version: '8.4' + php-version: '8.5' coverage: none - name: Install composer dependencies diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index aaa34ea..85cdcad 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -14,18 +14,18 @@ jobs: max-parallel: 1 matrix: os: [ ubuntu-latest ] - php: [ 8.2, 8.3, 8.4 ] - laravel: [ 12.* ] + php: [ 8.3, 8.4, 8.5 ] + laravel: [ 13.* ] stability: [ prefer-lowest, prefer-stable ] name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@2.37.0 with: php-version: ${{ matrix.php }} extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo @@ -45,7 +45,7 @@ jobs: run: cp phpunit.xml.dist phpunit.xml - name: Execute tests - run: vendor/bin/pest + run: vendor/bin/pest --no-coverage env: CLOUDINARY_CLOUD_NAME: ${{ secrets.CLOUDINARY_CLOUD_NAME }} CLOUDINARY_API_KEY: ${{ secrets.CLOUDINARY_API_KEY }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c4f415..90ca800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to `laravel-cloudinary` will be documented in this file. +## 13.0.0 - 2026-04-03 + +- Laravel 13 support +- PHP 8.3, 8.4, and 8.5 support (PHP 8.2 dropped) +- Development tooling: Orchestra Testbench 11, Pest 4, Larastan 3.9 +- Packagist `dev-main` branch alias `13.x-dev` for Composer until `v13.0.0` is tagged +- **Flysystem 3:** `listContents()` now returns `FileAttributes` / `DirectoryAttributes` so `Storage::files()`, `Storage::directories()`, and related APIs work (fixes [#64](https://github.com/codebar-ag/laravel-flysystem-cloudinary/issues/64), [#80](https://github.com/codebar-ag/laravel-flysystem-cloudinary/issues/80)) +- **Flysystem 3:** `read()` / `readStream()` throw `UnableToReadFile` on failure; `readStream()` returns a `resource`; `copy()` throws `UnableToCopyFile`; `delete()` throws `UnableToDeleteFile` when destroy fails; `createDirectory()` / `deleteDirectory()` throw the corresponding Flysystem exceptions +- Apply configured folder prefix to `move()`, `createDirectory()`, `deleteDirectory()`, and fix `directoryExists()` for root-level paths +- Fix string uploads: rewind temp stream after `fwrite()` before Cloudinary `upload()` +- `createDir()` catches `ApiError` as well as `RateLimited` +- Composer scripts: `analyse` (PHPStan), `test` / `test-coverage` via Pest with `--exclude-group=integration`; `format` uses Pint +- Integration tests: `integration` group, skip when placeholder Cloudinary env is used +- **Adapter internals:** Extracted `CloudinaryPathNormalizer`, `CloudinaryDiskOptions`, `CloudinaryResponseMapper`, `CloudinaryResourceOperations`, and `CloudinaryResponseLogger`; disk options (folder, upload preset, merge options, secure URL preference) are resolved from the Laravel disk config in `FlysystemCloudinaryServiceProvider` with fallback to `config('flysystem-cloudinary.*')` when the adapter is constructed without disk options +- **API:** Added `lastUploadMetadata()`, `lastCopySucceeded()`, and `lastDeleteSucceeded()`; public `$meta`, `$copied`, and `$deleted` remain for backward compatibility +- **Adapter slimming:** `CloudinaryStringUploadSource`, `CloudinaryAdminFolderLocator`, `CloudinaryUrlBuilder`, `CloudinaryListResponseAssembler`, `CloudinaryDiskOptions::uploadOptionsFor()`, and `Concerns\InteractsWithCloudinaryMetadata` further shrink `FlysystemCloudinaryAdapter` and move branching out of the adapter file +- **Security:** `phpunit.xml` no longer ships real Cloudinary API secrets; default env is placeholder-only. Live API integration tests run only when real `CLOUDINARY_*` values are exported (see `tests/cloudinary_integration.php`) +- **Tests:** Dedicated Pest files per collaborator (`CloudinaryResourceOperations`, `CloudinaryResponseMapper`, `CloudinaryResponseLogger`, `CloudinaryUrlBuilder`, `CloudinaryListResponseAssembler`, `CloudinaryAdminFolderLocator`, `CloudinaryDiskOptions`, `FlysystemCloudinaryResponseLog`, `FlysystemCloudinaryServiceProvider`); extra integration cases for missing files, directories, and rename + ## 2.0.0 - 2022-11-20 laravel-flysystem v3 upgrade: diff --git a/README.md b/README.md index 6fb394d..5a733f1 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ additional parameters to your url 😉 | Package | PHP | Laravel | Flysystem | |-----------|-------------|-----------|-------------| -| v12.0 | ^8.2 - ^8.4 | 12.x | 3.25.1 | +| v13.0 | 8.3.*–8.5.* | 13.x | 3.x | +| v12.0 | ^8.2 - ^8.4 | 12.x | 3.x | | v11.0 | ^8.2 - ^8.3 | 11.x | 3.0 | | v4.0 | ^8.2 - ^8.3 | 11.x | 3.0 | | v3.0 | 8.2 | 10.x | 3.0 | @@ -59,13 +60,23 @@ configuration: Add the following environment variables to your `.env` file: ```shell -FILESYSTEM_DRIVER=cloudinary +FILESYSTEM_DISK=cloudinary CLOUDINARY_CLOUD_NAME=my-cloud-name CLOUDINARY_API_KEY=my-api-key CLOUDINARY_API_SECRET=my-api-secret ``` +Older Laravel apps may still use `FILESYSTEM_DRIVER`; Laravel 9+ prefers `FILESYSTEM_DISK`. + +### Cloudinary folder modes + +This adapter lists assets with the Admin API using **public ID `prefix`** and manages folders with **`subFolders` / `create_folder` / `delete_folder`**, which matches **legacy fixed folder mode** and typical public-ID paths. If your Cloudinary product environment uses **dynamic folder mode** only, some behaviours may differ; see [Folder modes](https://cloudinary.com/documentation/folder_modes) and the [Admin API](https://cloudinary.com/documentation/admin_api#folders). + +### Continuous integration and integration tests + +The test suite includes optional **integration** tests that call the live Cloudinary API. They run only when `CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_API_KEY`, and `CLOUDINARY_API_SECRET` are set to real values (for example via GitHub Actions secrets). The default `composer test` command excludes the `integration` group; run `vendor/bin/pest` without `--exclude-group` to include them locally. + ## 🏗 File extension problem Let's look at the following example: diff --git a/composer.json b/composer.json index e0e5f9d..3bb77d9 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "codebar-ag/laravel-flysystem-cloudinary", - "description": "Cloudinary Flysystem v1 integration with Laravel", + "description": "Cloudinary Flysystem 3 integration with Laravel", "keywords": [ "laravel", "codebar-ag", @@ -19,18 +19,18 @@ } ], "require": { - "php": "8.2.*|8.3.*|8.4.*", + "php": "8.3.*|8.4.*|8.5.*", "guzzlehttp/guzzle": "^7.8", - "illuminate/contracts": "^12.0", + "illuminate/contracts": "^13.0", "cloudinary/cloudinary_php": "^3.1", "nesbot/carbon": "^3.8", "spatie/laravel-package-tools": "^1.19" }, "require-dev": { "laravel/pint": "^1.21", - "larastan/larastan": "^v3.1", - "orchestra/testbench": "^10.0", - "pestphp/pest": "^3.7", + "larastan/larastan": "^3.9", + "orchestra/testbench": "^11.0", + "pestphp/pest": "^4.0", "phpstan/extension-installer": "^1.4", "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-phpunit": "^2.0", @@ -47,25 +47,30 @@ } }, "scripts": { - "psalm": "vendor/bin/psalm", - "test": "./vendor/bin/testbench package:test --parallel --no-coverage --exclude-group=Integration", - "test-coverage": "vendor/bin/phpunit --coverage-html coverage --exclude-group=Integration", - "format": "vendor/bin/php-cs-fixer fix --allow-risky=yes" + "analyse": "vendor/bin/phpstan analyse", + "test": "vendor/bin/pest --no-coverage --exclude-group=integration", + "test-coverage": "vendor/bin/pest --coverage --exclude-group=integration", + "format": "vendor/bin/pint" }, "config": { "sort-packages": true, "allow-plugins": { + "composer/package-versions-deprecated": false, "pestphp/pest-plugin": true, - "phpstan/extension-installer": true + "phpstan/extension-installer": true, + "dealerdirect/phpcodesniffer-composer-installer": true } }, "extra": { + "branch-alias": { + "dev-main": "13.x-dev" + }, "laravel": { "providers": [ "CodebarAg\\FlysystemCloudinary\\FlysystemCloudinaryServiceProvider" ] } }, - "minimum-stability": "stable", + "minimum-stability": "dev", "prefer-stable": true } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e36c338..9730646 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -21,7 +21,7 @@ - + diff --git a/src/CloudinaryAdminFolderLocator.php b/src/CloudinaryAdminFolderLocator.php new file mode 100644 index 0000000..2b3410b --- /dev/null +++ b/src/CloudinaryAdminFolderLocator.php @@ -0,0 +1,38 @@ +adminApi()->subFolders($needle, [ + 'max_results' => 500, + 'next_cursor' => $response['next_cursor'] ?? null, + ]); + + $folders = array_merge($folders, $response['folders'] ?? []); + } while (! empty($response['next_cursor'])); + + foreach ($folders as $folder) { + if (($folder['path'] ?? '') === $prefixedPath) { + return true; + } + } + } catch (Throwable) { + return false; + } + + return false; + } +} diff --git a/src/CloudinaryDiskOptions.php b/src/CloudinaryDiskOptions.php new file mode 100644 index 0000000..cba0a0a --- /dev/null +++ b/src/CloudinaryDiskOptions.php @@ -0,0 +1,78 @@ + $globalUploadOptions + */ + public function __construct( + public ?string $folder, + public ?string $uploadPreset, + public array $globalUploadOptions, + public bool $preferSecureUrl, + ) {} + + /** + * @param array $diskConfig + */ + public static function fromDiskAndConfig(array $diskConfig): self + { + $publishedOptions = config('flysystem-cloudinary.options', []); + + $options = $diskConfig['options'] ?? null; + if (! is_array($options)) { + $options = is_array($publishedOptions) ? $publishedOptions : []; + } + + return new self( + folder: self::nullableString($diskConfig['folder'] ?? config('flysystem-cloudinary.folder')), + uploadPreset: self::nullableString($diskConfig['upload_preset'] ?? config('flysystem-cloudinary.upload_preset')), + globalUploadOptions: $options, + preferSecureUrl: (bool) ($diskConfig['secure_url'] ?? config('flysystem-cloudinary.secure_url', true)), + ); + } + + /** + * Merge base upload API options with disk + Flysystem {@see Config} overrides. + * + * @return array + */ + public function uploadOptionsFor(string $logicalPublicId, Config $config): array + { + $base = [ + 'type' => 'upload', + 'public_id' => $logicalPublicId, + 'invalidate' => true, + 'use_filename' => true, + 'resource_type' => 'auto', + 'unique_filename' => false, + ]; + + $fromDisk = array_filter([ + 'folder' => $this->folder, + 'upload_preset' => $this->uploadPreset, + ], static fn ($v) => $v !== null && $v !== ''); + + $merged = array_merge($base, $fromDisk, $this->globalUploadOptions); + + $flyOptions = $config->get('options'); + if (is_array($flyOptions) && $flyOptions !== []) { + $merged = array_merge($merged, $flyOptions); + } + + return $merged; + } + + private static function nullableString(mixed $value): ?string + { + if ($value === null || $value === '') { + return null; + } + + return (string) $value; + } +} diff --git a/src/CloudinaryListResponseAssembler.php b/src/CloudinaryListResponseAssembler.php new file mode 100644 index 0000000..1c7d6d1 --- /dev/null +++ b/src/CloudinaryListResponseAssembler.php @@ -0,0 +1,70 @@ + + */ + public function shallowList(string $directoryPrefixed): array + { + $baseOptions = [ + 'type' => 'upload', + 'prefix' => $directoryPrefixed, + 'max_results' => 500, + ]; + + try { + $assetResponses = []; + foreach (['raw', 'image', 'video'] as $resourceType) { + $assetResponses[] = $this->cloudinary->adminApi()->assets( + $baseOptions + ['resource_type' => $resourceType] + ); + } + + $responseDirectories = $this->cloudinary->adminApi()->subFolders($directoryPrefixed); + } catch (RateLimited|ApiError) { + return []; + } + + foreach ($assetResponses as $response) { + $this->logger->log($response); + } + $this->logger->log($responseDirectories); + + $out = []; + + foreach ($assetResponses as $response) { + foreach ($response->getArrayCopy()['resources'] ?? [] as $resource) { + $out[] = $this->mapper->normalizedToFileAttributes( + $this->mapper->normalizeUploadOrExplicit($resource, $resource['public_id']) + ); + } + } + + foreach ($responseDirectories->getArrayCopy()['folders'] ?? [] as $resource) { + $out[] = new DirectoryAttributes( + $this->paths->logical($resource['path']), + null, + null, + isset($resource['name']) ? ['name' => $resource['name']] : [] + ); + } + + return $out; + } +} diff --git a/src/CloudinaryPathNormalizer.php b/src/CloudinaryPathNormalizer.php new file mode 100644 index 0000000..226a03f --- /dev/null +++ b/src/CloudinaryPathNormalizer.php @@ -0,0 +1,36 @@ +folder === null || $this->folder === '') { + return $path; + } + + $folder = trim(trim($this->folder), '/'); + + return "{$folder}/{$path}"; + } + + public function logical(string $path): string + { + if ($this->folder === null || $this->folder === '') { + return $path; + } + + $folder = trim(trim($this->folder), '/'); + $prefix = $folder.'/'; + + return Str::of($path)->after($prefix)->__toString(); + } +} diff --git a/src/CloudinaryResourceOperations.php b/src/CloudinaryResourceOperations.php new file mode 100644 index 0000000..7d6d191 --- /dev/null +++ b/src/CloudinaryResourceOperations.php @@ -0,0 +1,62 @@ + 'upload']; + $lastNotFound = null; + + foreach (self::RESOURCE_TYPES as $resourceType) { + $options['resource_type'] = $resourceType; + + try { + $response = $cloudinary->uploadApi()->explicit($prefixedPublicId, $options); + $this->logger->log($response); + + return $response; + } catch (NotFound $exception) { + $lastNotFound = $exception; + } + } + + throw $lastNotFound; + } + + public function destroy(Cloudinary $cloudinary, string $prefixedPublicId): bool + { + $baseOptions = ['invalidate' => true]; + + try { + foreach (self::RESOURCE_TYPES as $resourceType) { + $options = $baseOptions + ['resource_type' => $resourceType]; + $response = $cloudinary->uploadApi()->destroy($prefixedPublicId, $options); + $this->logger->log($response); + + if (($response->getArrayCopy()['result'] ?? '') === 'ok') { + return true; + } + } + + return false; + } catch (ApiError) { + return false; + } + } +} diff --git a/src/CloudinaryResponseLogger.php b/src/CloudinaryResponseLogger.php new file mode 100644 index 0000000..3ab0ee6 --- /dev/null +++ b/src/CloudinaryResponseLogger.php @@ -0,0 +1,14 @@ +mimeDetector = new FinfoMimeTypeDetector; + } + + /** + * @param string|resource|null $body + * @return array + */ + public function normalizeUploadOrExplicit( + ApiResponse|array $response, + string $path, + $body = null, + ): array { + $logicalPath = $this->paths->logical($path); + + return [ + 'contents' => $body, + 'etag' => Arr::get($response, 'etag'), + 'mimetype' => $this->mimeDetector->detectMimeType($logicalPath, $body) ?? 'text/plain', + 'path' => $logicalPath, + 'size' => Arr::get($response, 'bytes'), + 'timestamp' => strtotime((string) Arr::get($response, 'created_at')), + 'type' => 'file', + 'version' => Arr::get($response, 'version'), + 'versionid' => Arr::get($response, 'version_id'), + 'visibility' => Arr::get($response, 'access_mode') === 'public' ? 'public' : 'private', + ]; + } + + /** + * @param array $normalized + */ + public function normalizedToFileAttributes(array $normalized): FileAttributes + { + $timestamp = $normalized['timestamp']; + $lastModified = ($timestamp !== false && $timestamp !== null) ? (int) $timestamp : null; + + $extra = array_filter([ + 'etag' => $normalized['etag'] ?? null, + 'version' => $normalized['version'] ?? null, + 'versionid' => $normalized['versionid'] ?? null, + ], fn ($v) => $v !== null && $v !== ''); + + $size = $normalized['size'] ?? null; + + return new FileAttributes( + $normalized['path'], + is_numeric($size) ? (int) $size : null, + $normalized['visibility'] ?? 'public', + $lastModified, + $normalized['mimetype'] ?? null, + $extra + ); + } + + /** + * @param array $resource + */ + public function adminAssetToFileAttributes(array $resource): FileAttributes + { + $publicId = $resource['public_id']; + $logicalPath = $this->paths->logical($publicId); + + return new FileAttributes( + $logicalPath, + (int) $resource['bytes'], + 'public', + (int) strtotime($resource['created_at']), + (string) sprintf('%s/%s', $resource['resource_type'], $resource['format']), + $this->extractExtraMetadata($resource) + ); + } + + /** + * @param array $metadata + * @return array + */ + public function extractExtraMetadata(array $metadata): array + { + $extracted = []; + + foreach (self::EXTRA_METADATA_FIELDS as $field) { + if (isset($metadata[$field]) && $metadata[$field] !== '') { + $extracted[$field] = $metadata[$field]; + } + } + + return $extracted; + } +} diff --git a/src/CloudinaryStringUploadSource.php b/src/CloudinaryStringUploadSource.php new file mode 100644 index 0000000..ffae274 --- /dev/null +++ b/src/CloudinaryStringUploadSource.php @@ -0,0 +1,41 @@ + $uri, 'handle' => $handle]; + } +} diff --git a/src/CloudinaryUrlBuilder.php b/src/CloudinaryUrlBuilder.php new file mode 100644 index 0000000..622712d --- /dev/null +++ b/src/CloudinaryUrlBuilder.php @@ -0,0 +1,54 @@ +paths->prefixed(trim((string) $path, '/')); + + try { + return (string) $this->cloudinary->image($prefixed)->toUrl(implode(',', $options)); + } catch (NotFound) { + return false; + } + } + + public function urlViaExplicit(string $path): string|false + { + $prefixed = $this->paths->prefixed(trim($path, '/')); + + try { + $response = $this->resourceOps->explicit($this->cloudinary, $prefixed); + } catch (NotFound) { + return false; + } + + $this->logger->log($response); + + [ + 'url' => $url, + 'secure_url' => $secureUrl, + ] = $response->getArrayCopy(); + + return $this->diskOptions->preferSecureUrl ? $secureUrl : $url; + } +} diff --git a/src/Concerns/InteractsWithCloudinaryMetadata.php b/src/Concerns/InteractsWithCloudinaryMetadata.php new file mode 100644 index 0000000..2b1aca7 --- /dev/null +++ b/src/Concerns/InteractsWithCloudinaryMetadata.php @@ -0,0 +1,32 @@ +pathsForMetadata()->prefixed(trim($path, '/')); + + try { + $result = (array) $this->cloudinaryForMetadata()->adminApi()->asset($prefixed); + } catch (Throwable $exception) { + throw UnableToRetrieveMetadata::create($path, $type, '', $exception); + } + + return $this->mapperForMetadata()->adminAssetToFileAttributes($result); + } +} diff --git a/src/FlysystemCloudinaryAdapter.php b/src/FlysystemCloudinaryAdapter.php index f7bb77c..7375fff 100644 --- a/src/FlysystemCloudinaryAdapter.php +++ b/src/FlysystemCloudinaryAdapter.php @@ -7,45 +7,121 @@ use Cloudinary\Api\Exception\BadRequest; use Cloudinary\Api\Exception\NotFound; use Cloudinary\Api\Exception\RateLimited; -use Cloudinary\Asset\Media; use Cloudinary\Cloudinary; use Cloudinary\Configuration\Configuration; -use CodebarAg\FlysystemCloudinary\Events\FlysystemCloudinaryResponseLog; -use Exception; +use CodebarAg\FlysystemCloudinary\Concerns\InteractsWithCloudinaryMetadata; use Illuminate\Http\Client\RequestException; -use Illuminate\Support\Arr; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Str; use League\Flysystem\Config; +use League\Flysystem\DirectoryAttributes; use League\Flysystem\FileAttributes; use League\Flysystem\FilesystemAdapter; +use League\Flysystem\UnableToCopyFile; +use League\Flysystem\UnableToCreateDirectory; +use League\Flysystem\UnableToDeleteDirectory; +use League\Flysystem\UnableToDeleteFile; use League\Flysystem\UnableToMoveFile; -use League\Flysystem\UnableToRetrieveMetadata; +use League\Flysystem\UnableToReadFile; use League\Flysystem\UnableToSetVisibility; -use League\MimeTypeDetection\FinfoMimeTypeDetector; +use League\Flysystem\UnableToWriteFile; +use ReflectionProperty; use Throwable; class FlysystemCloudinaryAdapter implements FilesystemAdapter { + use InteractsWithCloudinaryMetadata; + + /** + * Populated after {@see write()} / {@see writeStream()}. Prefer {@see lastUploadMetadata()} for new code. + * + * @var array|false + */ public array|false $meta; - public bool $copied; + /** Set after {@see copy()}. Prefer {@see lastCopySucceeded()} for new code. */ + public bool $copied = false; + + /** Set after {@see delete()}. Prefer {@see lastDeleteSucceeded()} for new code. */ + public bool $deleted = false; + + private readonly CloudinaryDiskOptions $diskOptions; + + private readonly CloudinaryPathNormalizer $paths; - public bool $deleted; + private readonly CloudinaryResponseMapper $mapper; - private const EXTRA_METADATA_FIELDS = [ - 'version', - 'width', - 'height', - 'url', - 'secure_url', - 'next_cursor', - ]; + private readonly CloudinaryResponseLogger $logger; + + private readonly CloudinaryResourceOperations $resourceOps; + + private readonly CloudinaryAdminFolderLocator $folderLocator; + + private readonly CloudinaryUrlBuilder $urlBuilder; + + private readonly CloudinaryListResponseAssembler $listAssembler; public function __construct( public Cloudinary $cloudinary, + ?CloudinaryDiskOptions $diskOptions = null, ) { - Configuration::instance($cloudinary->configuration); + $this->diskOptions = $diskOptions ?? CloudinaryDiskOptions::fromDiskAndConfig([]); + + $this->paths = new CloudinaryPathNormalizer($this->diskOptions->folder); + $this->mapper = new CloudinaryResponseMapper($this->paths); + $this->logger = new CloudinaryResponseLogger; + $this->resourceOps = new CloudinaryResourceOperations($this->logger); + $this->folderLocator = new CloudinaryAdminFolderLocator; + $this->urlBuilder = new CloudinaryUrlBuilder( + $this->cloudinary, + $this->paths, + $this->diskOptions, + $this->resourceOps, + $this->logger + ); + $this->listAssembler = new CloudinaryListResponseAssembler( + $this->cloudinary, + $this->paths, + $this->mapper, + $this->logger + ); + + $configurationProperty = new ReflectionProperty(Cloudinary::class, 'configuration'); + if ($configurationProperty->isInitialized($cloudinary)) { + Configuration::instance($cloudinary->configuration); + } + } + + protected function cloudinaryForMetadata(): Cloudinary + { + return $this->cloudinary; + } + + protected function pathsForMetadata(): CloudinaryPathNormalizer + { + return $this->paths; + } + + protected function mapperForMetadata(): CloudinaryResponseMapper + { + return $this->mapper; + } + + /** + * @return array|false + */ + public function lastUploadMetadata(): array|false + { + return $this->meta; + } + + public function lastCopySucceeded(): bool + { + return $this->copied; + } + + public function lastDeleteSucceeded(): bool + { + return $this->deleted; } /** @@ -53,7 +129,7 @@ public function __construct( */ public function write(string $path, string $contents, Config $config): void { - $this->meta = $this->upload($path, $contents, $config); + $this->writeAndSetMeta($path, $contents, $config); } /** @@ -61,7 +137,7 @@ public function write(string $path, string $contents, Config $config): void */ public function writeStream(string $path, $resource, Config $config): void { - $this->meta = $this->upload($path, $resource, $config); + $this->writeAndSetMeta($path, $resource, $config); } /** @@ -86,56 +162,39 @@ public function updateStream($path, $resource, Config $config): array|false * https://cloudinary.com/documentation/image_upload_api_reference#upload_method * * @param string|resource $body + * @return array|false */ protected function upload(string $path, $body, Config $config): array|false { - if (is_string($body)) { - $tempFile = tmpfile(); + $temporaryFileHandle = null; + $fileForUpload = $body; - if (fwrite($tempFile, $body) === false) { + if (is_string($body)) { + $source = CloudinaryStringUploadSource::create($body); + if ($source === false) { return false; } + $fileForUpload = $source['path']; + $temporaryFileHandle = $source['handle']; } - $path = trim($path, '/'); - - $options = [ - 'type' => 'upload', - 'public_id' => $path, - 'invalidate' => true, - 'use_filename' => true, - 'resource_type' => 'auto', - 'unique_filename' => false, - ]; - - if (config('flysystem-cloudinary.folder')) { - $options['folder'] = config('flysystem-cloudinary.folder'); - } - - if (config('flysystem-cloudinary.upload_preset')) { - $options['upload_preset'] = config('flysystem-cloudinary.upload_preset'); - } - - if (config('flysystem-cloudinary.options')) { - $options = array_merge($options, config('flysystem-cloudinary.options')); - } - - if ($config->get('options')) { - $options = array_merge($options, $config->get('options')); - } + $logicalPath = trim($path, '/'); try { - $response = $this - ->cloudinary + $response = $this->cloudinary ->uploadApi() - ->upload($tempFile ?? $body, $options); + ->upload($fileForUpload, $this->diskOptions->uploadOptionsFor($logicalPath, $config)); } catch (ApiError) { return false; + } finally { + if ($temporaryFileHandle !== null) { + fclose($temporaryFileHandle); + } } - event(new FlysystemCloudinaryResponseLog($response)); + $this->logger->log($response); - return $this->normalizeResponse($response, $path, $body); + return $this->mapper->normalizeUploadOrExplicit($response, $logicalPath, $body); } /** @@ -145,24 +204,18 @@ protected function upload(string $path, $body, Config $config): array|false */ public function rename($path, $newpath): bool { - $path = $this->ensureFolderIsPrefixed(trim($path, '/')); - - $newpath = $this->ensureFolderIsPrefixed(trim($newpath, '/')); - - $options = [ - 'invalidate' => true, - ]; + $from = $this->paths->prefixed(trim($path, '/')); + $to = $this->paths->prefixed(trim($newpath, '/')); try { - $response = $this - ->cloudinary + $response = $this->cloudinary ->uploadApi() - ->rename($path, $newpath, $options); + ->rename($from, $to, ['invalidate' => true]); } catch (NotFound|BadRequest) { return false; } - event(new FlysystemCloudinaryResponseLog($response)); + $this->logger->log($response); return true; } @@ -172,24 +225,20 @@ public function rename($path, $newpath): bool */ public function copy(string $path, string $newpath, Config $config): void { - $path = $this->ensureFolderIsPrefixed(trim($path, '/')); - - $newpath = $this->ensureFolderIsPrefixed(trim($newpath, '/')); - - $metaRead = $this->readObject($path); + $sourceLogical = trim($path, '/'); + $destLogical = trim($newpath, '/'); + $prefixedSource = $this->paths->prefixed($sourceLogical); + $metaRead = $this->readObject($prefixedSource); if ($metaRead === false) { $this->copied = false; - - return; + throw UnableToCopyFile::fromLocationTo($sourceLogical, $destLogical); } - $metaUpload = $this->upload($newpath, $metaRead['contents'], $config); - + $metaUpload = $this->upload($destLogical, $metaRead['contents'], $config); if ($metaUpload === false) { $this->copied = false; - - return; + throw UnableToCopyFile::fromLocationTo($sourceLogical, $destLogical); } $this->copied = true; @@ -202,53 +251,21 @@ public function copy(string $path, string $newpath, Config $config): void */ public function delete(string $path): void { - $path = $this->ensureFolderIsPrefixed(trim($path, '/')); + $logical = trim($path, '/'); + $prefixed = $this->paths->prefixed($logical); - $this->deleted = $this->destroy($path); + $this->deleted = $this->resourceOps->destroy($this->cloudinary, $prefixed); + if (! $this->deleted) { + throw UnableToDeleteFile::atLocation( + $logical, + 'Cloudinary destroy did not succeed for this resource type or path.' + ); + } } protected function destroy(string $path): bool { - $options = [ - 'invalidate' => true, - ]; - - $options['resource_type'] = 'image'; - $response = $this - ->cloudinary - ->uploadApi() - ->destroy($path, $options); - event(new FlysystemCloudinaryResponseLog($response)); - - if ($response->getArrayCopy()['result'] === 'ok') { - return true; - } - - $options['resource_type'] = 'raw'; - $response = $this - ->cloudinary - ->uploadApi() - ->destroy($path, $options); - - event(new FlysystemCloudinaryResponseLog($response)); - - if ($response->getArrayCopy()['result'] === 'ok') { - return true; - } - - $options['resource_type'] = 'video'; - $response = $this - ->cloudinary - ->uploadApi() - ->destroy($path, $options); - - event(new FlysystemCloudinaryResponseLog($response)); - - if ($response->getArrayCopy()['result'] === 'ok') { - return true; - } - - return false; + return $this->resourceOps->destroy($this->cloudinary, $path); } /** @@ -256,26 +273,23 @@ protected function destroy(string $path): bool */ public function deleteDir($dirname): bool { - $dirname = $this->ensureFolderIsPrefixed(trim($dirname, '/')); - - $files = $this->listContents($dirname); + $logicalDir = trim($dirname, '/'); + $prefixedDir = $this->paths->prefixed($logicalDir); - foreach ($files as ['path' => $path]) { - $path = $this->ensureFolderIsPrefixed(trim($path, '/')); - - $this->destroy($path); + foreach ($this->listContents($logicalDir, false) as $item) { + if (! $item->isFile()) { + continue; + } + $this->resourceOps->destroy($this->cloudinary, $this->paths->prefixed($item->path())); } try { - $response = $this - ->cloudinary - ->adminApi() - ->deleteFolder($dirname); + $response = $this->cloudinary->adminApi()->deleteFolder($prefixedDir); } catch (ApiError|RateLimited) { return false; } - event(new FlysystemCloudinaryResponseLog($response)); + $this->logger->log($response); return true; } @@ -285,33 +299,34 @@ public function deleteDir($dirname): bool */ public function createDir($dirname, Config $config): array|false { - $dirname = $this->ensureFolderIsPrefixed(trim($dirname, '/')); + $logical = trim($dirname, '/'); + $prefixed = $this->paths->prefixed($logical); try { - $response = $this - ->cloudinary - ->adminApi() - ->createFolder($dirname); - } catch (RateLimited) { + $response = $this->cloudinary->adminApi()->createFolder($prefixed); + } catch (ApiError|RateLimited) { return false; } - event(new FlysystemCloudinaryResponseLog($response)); + $this->logger->log($response); return [ - 'path' => $dirname, + 'path' => $logical, 'type' => 'dir', ]; } public function move(string $source, string $destination, Config $config): void { + $sourceLogical = trim($source, '/'); + $destLogical = trim($destination, '/'); + $prefixedSource = $this->paths->prefixed($sourceLogical); + $prefixedDest = $this->paths->prefixed($destLogical); + try { - $this->cloudinary - ->uploadApi() - ->rename($source, $destination); + $this->cloudinary->uploadApi()->rename($prefixedSource, $prefixedDest); } catch (NotFound $exception) { - throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); + throw UnableToMoveFile::fromLocationTo($sourceLogical, $destLogical, $exception); } } @@ -322,10 +337,10 @@ public function move(string $source, string $destination, Config $config): void */ public function has($path): array|bool|null { - $path = $this->ensureFolderIsPrefixed(trim($path, '/')); + $prefixed = $this->paths->prefixed(trim($path, '/')); try { - $this->explicit($path); + $this->resourceOps->explicit($this->cloudinary, $prefixed); } catch (NotFound) { return false; } @@ -338,56 +353,33 @@ public function has($path): array|bool|null */ public function read(string $path): string { - $path = $this->ensureFolderIsPrefixed(trim($path, '/')); + $logical = trim($path, '/'); - try { - $contents = file_get_contents(Media::fromParams($path)); - } catch (Exception) { - $contents = ''; - } - - return (string) $contents; + return (string) $this->readPrefixedOrFail($this->paths->prefixed($logical), $path)['contents']; } /** * {@inheritDoc} */ - public function readStream($path): array|false /** @phpstan-ignore-line */ + public function readStream(string $path) { - $path = $this->ensureFolderIsPrefixed(trim($path, '/')); - - $meta = $this->readObject($path); + $logical = trim($path, '/'); + $meta = $this->readPrefixedOrFail($this->paths->prefixed($logical), $path); - if ($meta === false) { - return false; - } - - $tempFile = tmpfile(); - - if (fwrite($tempFile, $meta['contents']) === false) { - return false; - } - - if (rewind($tempFile) === false) { - return false; - } - - unset($meta['contents']); - - $meta['stream'] = $tempFile; - - return $meta; + return $this->contentsToTempStream((string) $meta['contents'], $path); } /** * Read an object. * * https://cloudinary.com/documentation/image_upload_api_reference#explicit_method + * + * @return array|false */ - protected function readObject(string $path): array|bool + protected function readObject(string $path): array|false { try { - $response = $this->explicit($path); + $response = $this->resourceOps->explicit($this->cloudinary, $path); } catch (NotFound) { return false; } @@ -400,243 +392,36 @@ protected function readObject(string $path): array|bool return false; } - return $this->normalizeResponse($response, $path, $contents); + return $this->mapper->normalizeUploadOrExplicit($response, $path, $contents); } /** * {@inheritDoc} + * + * Shallow listing only ($deep is ignored). Cloudinary Admin API does not map cleanly to recursive Flysystem trees. + * + * @return iterable */ - public function listContents($directory = '', $recursive = false): array + public function listContents(string $path, bool $deep): iterable { - $directory = $this->ensureFolderIsPrefixed(trim($directory, '/')); - - $options = [ - 'type' => 'upload', - 'prefix' => $directory, - 'max_results' => 500, - ]; - - try { - $options['resource_type'] = 'raw'; - $responseRawFiles = $this - ->cloudinary - ->adminApi() - ->assets($options); - - $options['resource_type'] = 'image'; - $responseImageFiles = $this - ->cloudinary - ->adminApi() - ->assets($options); - - $options['resource_type'] = 'video'; - $responseVideoFiles = $this - ->cloudinary - ->adminApi() - ->assets($options); - - $responseDirectories = $this - ->cloudinary - ->adminApi() - ->subFolders($directory); - } catch (RateLimited|ApiError) { - return []; - } - - event(new FlysystemCloudinaryResponseLog($responseRawFiles)); - event(new FlysystemCloudinaryResponseLog($responseImageFiles)); - event(new FlysystemCloudinaryResponseLog($responseVideoFiles)); - event(new FlysystemCloudinaryResponseLog($responseDirectories)); - - $rawFiles = array_map(function (array $resource) { - return $this->normalizeResponse($resource, $resource['public_id']); - }, $responseRawFiles->getArrayCopy()['resources']); - - $imageFiles = array_map(function (array $resource) { - return $this->normalizeResponse($resource, $resource['public_id']); - }, $responseImageFiles->getArrayCopy()['resources']); - - $videoFiles = array_map(function (array $resource) { - return $this->normalizeResponse($resource, $resource['public_id']); - }, $responseVideoFiles->getArrayCopy()['resources']); - - $folders = array_map(function (array $resource) { - $path = $this->ensurePrefixedFolderIsRemoved($resource['path']); - - return [ - 'type' => 'dir', - 'path' => $path, - 'name' => $resource['name'], - ]; - }, $responseDirectories->getArrayCopy()['folders']); - - return [ - ...$rawFiles, - ...$imageFiles, - ...$videoFiles, - ...$folders, - ]; - } - - private function getMetadata(string $path, string $type): FileAttributes - { - try { - $result = (array) $this->cloudinary->adminApi()->asset($path); - } catch (Throwable $exception) { - throw UnableToRetrieveMetadata::create($path, $type, '', $exception); - } - - $attributes = $this->mapToFileAttributes($result); - - // @phpstan-ignore-next-line - if (! $attributes instanceof FileAttributes) { - throw UnableToRetrieveMetadata::create($path, $type); - } - - return $attributes; - } - - private function mapToFileAttributes($resource): FileAttributes - { - return new FileAttributes( - $resource['public_id'], - (int) $resource['bytes'], - 'public', - (int) strtotime($resource['created_at']), - (string) sprintf('%s/%s', $resource['resource_type'], $resource['format']), - $this->extractExtraMetadata((array) $resource) + return $this->listAssembler->shallowList( + $this->paths->prefixed(trim($path, '/')) ); } - private function extractExtraMetadata(array $metadata): array - { - $extracted = []; - - foreach (self::EXTRA_METADATA_FIELDS as $field) { - if (isset($metadata[$field]) && $metadata[$field] !== '') { - $extracted[$field] = $metadata[$field]; - } - } - - return $extracted; - } - - /** - * Get the URL of an image with optional transformation parameters - * - * @return string - */ public function getUrl(string|array $path): string|false { - $options = []; - - if (is_array($path)) { - $options = $path['options'] ?? []; - $path = $path['path']; - } - - $path = $this->ensureFolderIsPrefixed(trim($path, '/')); - - try { - return (string) $this->cloudinary->image($path)->toUrl(implode(',', $options)); - } catch (NotFound) { - return false; - } + return $this->urlBuilder->deliveryUrl($path); } public function getUrlViaRequest(string $path): string|false { - $path = $this->ensureFolderIsPrefixed(trim($path, '/')); - - try { - $response = $this->explicit($path); - } catch (NotFound) { - return false; - } - - event(new FlysystemCloudinaryResponseLog($response)); - - [ - 'url' => $url, - 'secure_url' => $secure_url, - ] = $response->getArrayCopy(); - - if (config('flysystem-cloudinary.secure_url')) { - return $secure_url; - } - - return $url; + return $this->urlBuilder->urlViaExplicit($path); } protected function explicit(string $path): ApiResponse { - $options = [ - 'type' => 'upload', - ]; - - try { - $options['resource_type'] = 'image'; - $response = $this - ->cloudinary - ->uploadApi() - ->explicit($path, $options); - - event(new FlysystemCloudinaryResponseLog($response)); - - return $response; - } catch (NotFound) { - } - - try { - $options['resource_type'] = 'raw'; - $response = $this - ->cloudinary - ->uploadApi() - ->explicit($path, $options); - - event(new FlysystemCloudinaryResponseLog($response)); - - return $response; - } catch (NotFound) { - } - - try { - $options['resource_type'] = 'video'; - $response = $this - ->cloudinary - ->uploadApi() - ->explicit($path, $options); - - event(new FlysystemCloudinaryResponseLog($response)); - - return $response; - } catch (NotFound $e) { - throw $e; - } - } - - protected function ensureFolderIsPrefixed(string $path): string - { - if (config('flysystem-cloudinary.folder')) { - $folder = trim(config('flysystem-cloudinary.folder'), '/'); - - return "{$folder}/$path"; - } - - return $path; - } - - protected function ensurePrefixedFolderIsRemoved(string $path): string - { - if (config('flysystem-cloudinary.folder')) { - $prefix = config('flysystem-cloudinary.folder').'/'; - - return Str::of($path) - ->after($prefix) - ->__toString(); - } - - return $path; + return $this->resourceOps->explicit($this->cloudinary, $path); } /** @@ -645,35 +430,23 @@ protected function ensurePrefixedFolderIsRemoved(string $path): string * https://flysystem.thephpleague.com/v1/docs/architecture/ * * @param string|resource|null $body + * @return array */ protected function normalizeResponse( ApiResponse|array $response, string $path, $body = null, ): array { - $path = $this->ensurePrefixedFolderIsRemoved($path); - - return [ - 'contents' => $body, - 'etag' => Arr::get($response, 'etag'), - 'mimetype' => (new FinfoMimeTypeDetector)->detectMimeType($path, $body) ?? 'text/plain', - 'path' => $path, - 'size' => Arr::get($response, 'bytes'), - 'timestamp' => strtotime(Arr::get($response, 'created_at')), - 'type' => 'file', - 'version' => Arr::get($response, 'version'), - 'versionid' => Arr::get($response, 'version_id'), - 'visibility' => Arr::get($response, 'access_mode') === 'public' ? 'public' : 'private', - ]; + return $this->mapper->normalizeUploadOrExplicit($response, $path, $body); } public function fileExists(string $path): bool { - $path = $this->ensureFolderIsPrefixed(trim($path, '/')); + $prefixed = $this->paths->prefixed(trim($path, '/')); try { - $this->cloudinary->adminApi()->asset($path); - } catch (Exception $e) { + $this->cloudinary->adminApi()->asset($prefixed); + } catch (Throwable) { return false; } @@ -682,36 +455,32 @@ public function fileExists(string $path): bool public function directoryExists(string $path): bool { - $folders = []; - $needle = substr($path, 0, strripos($path, '/')); - - $response = null; - do { - $response = (array) $this->cloudinary->adminApi()->subFolders($needle, [ - 'max_results' => 4, - 'next_cursor' => $response['next_cursor'] ?? null, - ]); - - $folders = array_merge($folders, $response['folders']); - } while (array_key_exists('next_cursor', $response) && ! is_null($response['next_cursor'])); - $folders_found = array_filter( - $folders, - function ($e) use ($path) { - return $e['path'] == $path; - } + return $this->folderLocator->folderExists( + $this->cloudinary, + $this->paths->prefixed(trim($path, '/')) ); - - return count($folders_found) > 0; } public function deleteDirectory(string $path): void { - $this->cloudinary->adminApi()->deleteFolder($path); + $prefixed = $this->paths->prefixed(trim($path, '/')); + + try { + $this->cloudinary->adminApi()->deleteFolder($prefixed); + } catch (Throwable $e) { + throw UnableToDeleteDirectory::atLocation($path, $e->getMessage(), $e); + } } public function createDirectory(string $path, Config $config): void { - $this->cloudinary->adminApi()->createFolder($path); + $prefixed = $this->paths->prefixed(trim($path, '/')); + + try { + $this->cloudinary->adminApi()->createFolder($prefixed); + } catch (Throwable $e) { + throw UnableToCreateDirectory::atLocation($path, $e->getMessage(), $e); + } } public function setVisibility(string $path, string $visibility): void @@ -758,4 +527,41 @@ public function fileSize(string $path): FileAttributes { return $this->getMetadata($path, FileAttributes::ATTRIBUTE_FILE_SIZE); } + + private function writeAndSetMeta(string $path, mixed $body, Config $config): void + { + $this->meta = $this->upload($path, $body, $config); + if ($this->meta === false) { + throw UnableToWriteFile::atLocation($path, 'Upload failed.'); + } + } + + /** + * @return array + */ + private function readPrefixedOrFail(string $prefixedPath, string $errorPath): array + { + $meta = $this->readObject($prefixedPath); + if ($meta === false) { + throw UnableToReadFile::fromLocation($errorPath); + } + + return $meta; + } + + private function contentsToTempStream(string $contents, string $errorPath) + { + $tempFile = tmpfile(); + if ($tempFile === false) { + throw UnableToReadFile::fromLocation($errorPath, 'Could not create temporary stream.'); + } + if (fwrite($tempFile, $contents) === false) { + throw UnableToReadFile::fromLocation($errorPath); + } + if (rewind($tempFile) === false) { + throw UnableToReadFile::fromLocation($errorPath); + } + + return $tempFile; + } } diff --git a/src/FlysystemCloudinaryServiceProvider.php b/src/FlysystemCloudinaryServiceProvider.php index 8e04514..62b018a 100644 --- a/src/FlysystemCloudinaryServiceProvider.php +++ b/src/FlysystemCloudinaryServiceProvider.php @@ -22,7 +22,8 @@ public function configurePackage(Package $package): void public function bootingPackage(): void { Storage::extend('cloudinary', function (Application $app, array $config) { - $adapter = new FlysystemCloudinaryAdapter(new Cloudinary($config)); + $diskOptions = CloudinaryDiskOptions::fromDiskAndConfig($config); + $adapter = new FlysystemCloudinaryAdapter(new Cloudinary($config), $diskOptions); return new FilesystemAdapter(new Filesystem($adapter, $config), $adapter, $config); }); diff --git a/tests/Feature/Adapter/DeleteDirectoryTest.php b/tests/Feature/Adapter/DeleteDirectoryTest.php new file mode 100644 index 0000000..4f2424c --- /dev/null +++ b/tests/Feature/Adapter/DeleteDirectoryTest.php @@ -0,0 +1,92 @@ +mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('uploadApi->destroy')->once()->andReturn(new ApiResponse([ + 'result' => 'ok', + ], [])); + }); + $adapter = new FlysystemCloudinaryAdapter($mock); + + $adapter->delete('::path::'); + + $this->assertTrue($adapter->lastDeleteSucceeded()); + Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); +}); + +it('can delete a directory', function () { + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('adminApi->assets')->times(3)->andReturn(new ApiResponse([ + 'resources' => [], + ], [])); + $mock->shouldReceive('adminApi->subFolders')->once()->andReturn(new ApiResponse([ + 'folders' => [], + ], [])); + $mock->shouldReceive('adminApi->deleteFolder')->once()->andReturn(new ApiResponse([], [])); + }); + $adapter = new FlysystemCloudinaryAdapter($mock); + + $bool = $adapter->deleteDir('::path::'); + + $this->assertTrue($bool); + Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 5); +}); + +it('deleteDir lists a single-prefixed path when folder config is set', function () { + config(['flysystem-cloudinary.folder' => 'app_uploads']); + + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('adminApi->assets') + ->times(3) + ->withArgs(function (array $options) { + expect($options['prefix'] ?? null)->toBe('app_uploads/::path::'); + + return true; + }) + ->andReturn(new ApiResponse([ + 'resources' => [], + ], [])); + + $mock->shouldReceive('adminApi->subFolders') + ->once() + ->withArgs(function (string $folder, array $options = []) { + expect($folder)->toBe('app_uploads/::path::'); + + return true; + }) + ->andReturn(new ApiResponse([ + 'folders' => [], + ], [])); + + $mock->shouldReceive('adminApi->deleteFolder') + ->once() + ->with('app_uploads/::path::') + ->andReturn(new ApiResponse([], [])); + }); + $adapter = new FlysystemCloudinaryAdapter($mock); + + expect($adapter->deleteDir('::path::'))->toBeTrue(); +}); + +it('can create a directory', function () { + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('adminApi->createFolder')->once()->andReturn(new ApiResponse([], [])); + }); + $adapter = new FlysystemCloudinaryAdapter($mock); + + $meta = $adapter->createDir('::path::', new Config); + + $this->assertSame([ + 'path' => '::path::', + 'type' => 'dir', + ], $meta); + Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); +}); diff --git a/tests/Feature/Adapter/ReadListHasTest.php b/tests/Feature/Adapter/ReadListHasTest.php new file mode 100644 index 0000000..61a4071 --- /dev/null +++ b/tests/Feature/Adapter/ReadListHasTest.php @@ -0,0 +1,54 @@ +mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('uploadApi->explicit')->once()->andReturn(new ApiResponse([], [])); + }); + $adapter = new FlysystemCloudinaryAdapter($mock); + + $bool = $adapter->has('::path::'); + + $this->assertTrue($bool); + Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); +}); + +it('can read stream', function () { + Http::fake(['*' => Http::response('body')]); + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('uploadApi->explicit')->once()->andReturn(new ApiResponse([ + 'secure_url' => 'https://example.test/file', + ], [])); + }); + $adapter = new FlysystemCloudinaryAdapter($mock); + + $stream = $adapter->readStream('::path::'); + + $this->assertIsResource($stream); + Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); +}); + +it('can list directory contents', function () { + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('adminApi->assets')->times(3)->andReturn(new ApiResponse([ + 'resources' => [], + ], [])); + + $mock->shouldReceive('adminApi->subFolders')->once()->andReturn(new ApiResponse([ + 'folders' => [], + ], [])); + }); + $adapter = new FlysystemCloudinaryAdapter($mock); + + $files = iterator_to_array($adapter->listContents('::path::', false)); + + $this->assertSame([], $files); + Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 4); +}); diff --git a/tests/Feature/Adapter/RenameCopyTest.php b/tests/Feature/Adapter/RenameCopyTest.php new file mode 100644 index 0000000..c3c7531 --- /dev/null +++ b/tests/Feature/Adapter/RenameCopyTest.php @@ -0,0 +1,116 @@ +mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('uploadApi->rename')->once()->andReturn(new ApiResponse([], [])); + }); + $adapter = new FlysystemCloudinaryAdapter($mock); + + $bool = $adapter->rename('::old-path::', '::new-path::'); + + $this->assertTrue($bool); + Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); +}); + +it('can copy', function () { + Http::fake(); + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('uploadApi->explicit')->once()->andReturn(new ApiResponse([ + 'secure_url' => '::url::', + ], [])); + $mock->shouldReceive('uploadApi->upload')->once()->andReturn(new ApiResponse([ + 'public_id' => '::to-path::', + 'version' => 1, + 'version_id' => 'v1', + 'created_at' => '2021-10-10T10:10:10Z', + 'bytes' => 3, + 'etag' => 'e', + 'access_mode' => 'public', + ], [])); + }); + $adapter = new FlysystemCloudinaryAdapter($mock); + + $adapter->copy('::from-path::', '::to-path::', new Config); + + $this->assertTrue($adapter->lastCopySucceeded()); + Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 2); +}); + +it('copy uses logical destination public_id when folder config is set', function () { + Http::fake(); + config(['flysystem-cloudinary.folder' => 'app_uploads']); + + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('uploadApi->explicit') + ->once() + ->withArgs(function (string $publicId, array $options) { + expect($publicId)->toBe('app_uploads/::from-path::'); + + return true; + }) + ->andReturn(new ApiResponse([ + 'secure_url' => 'https://example.test/file', + ], [])); + + $mock->shouldReceive('uploadApi->upload') + ->once() + ->withArgs(function (mixed $file, array $options) { + expect($file)->toBeString()->not->toBeEmpty(); + expect($options['public_id'] ?? null)->toBe('::to-path::'); + expect($options['folder'] ?? null)->toBe('app_uploads'); + + return true; + }) + ->andReturn(new ApiResponse([ + 'public_id' => 'app_uploads/::to-path::', + 'version' => 1, + 'version_id' => 'v1', + 'created_at' => '2021-10-10T10:10:10Z', + 'bytes' => 3, + 'etag' => 'e', + 'access_mode' => 'public', + ], [])); + }); + $adapter = new FlysystemCloudinaryAdapter($mock); + + $adapter->copy('::from-path::', '::to-path::', new Config); + + expect($adapter->lastCopySucceeded())->toBeTrue(); +}); + +it('does not copy if upload fails after read', function () { + Http::fake(); + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('uploadApi->explicit')->once()->andReturn(new ApiResponse([ + 'secure_url' => 'https://example.test/file', + ], [])); + $mock->shouldReceive('uploadApi->upload')->once()->andThrow(new ApiError('upload failed')); + }); + $adapter = new FlysystemCloudinaryAdapter($mock); + + $this->expectException(UnableToCopyFile::class); + $adapter->copy('::from-path::', '::to-path::', new Config); +}); + +it('does not copy if file is not found', function () { + Http::fake(); + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('uploadApi->explicit')->times(3)->andThrow(new NotFound('not found')); + }); + $adapter = new FlysystemCloudinaryAdapter($mock); + + $this->expectException(UnableToCopyFile::class); + $adapter->copy('::missing::', '::to-path::', new Config); +}); diff --git a/tests/Feature/Adapter/UploadWriteTest.php b/tests/Feature/Adapter/UploadWriteTest.php new file mode 100644 index 0000000..9a6a51b --- /dev/null +++ b/tests/Feature/Adapter/UploadWriteTest.php @@ -0,0 +1,164 @@ +mock(Cloudinary::class, function (MockInterface $mock) use ($publicId) { + $mock->shouldReceive('uploadApi->upload')->once()->andReturn(new ApiResponse([ + 'public_id' => $publicId, + 'version' => 123456, + 'version_id' => '::version-id::', + 'created_at' => '2021-10-10T10:10:10Z', + 'bytes' => 789, + 'etag' => '::etag::', + 'access_mode' => 'public', + ], [])); + }); + $adapter = new FlysystemCloudinaryAdapter($mock); + + $adapter->write($publicId, $contents, new Config); + + $meta = $adapter->lastUploadMetadata(); + $this->assertSame($contents, $meta['contents']); + $this->assertSame('::etag::', $meta['etag']); + $this->assertSame('text/plain', $meta['mimetype']); + $this->assertSame($publicId, $meta['path']); + $this->assertSame(789, $meta['size']); + $this->assertSame(1633860610, $meta['timestamp']); + $this->assertSame('file', $meta['type']); + $this->assertSame(123456, $meta['version']); + $this->assertSame('::version-id::', $meta['versionid']); + $this->assertSame('public', $meta['visibility']); + Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); +}); + +it('can write stream', function () { + $publicId = '::file-path::'; + $contents = '::file-contents::'; + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) use ($publicId) { + $mock->shouldReceive('uploadApi->upload')->once()->andReturn(new ApiResponse([ + 'public_id' => $publicId, + 'version' => 123456, + 'version_id' => '::version-id::', + 'created_at' => '2021-10-10T10:10:10Z', + 'bytes' => 789, + 'etag' => '::etag::', + 'access_mode' => 'public', + ], [])); + }); + $adapter = new FlysystemCloudinaryAdapter($mock); + + $adapter->writeStream($publicId, $contents, new Config); + + $meta = $adapter->lastUploadMetadata(); + $this->assertSame($contents, $meta['contents']); + $this->assertSame('::etag::', $meta['etag']); + $this->assertSame('text/plain', $meta['mimetype']); + $this->assertSame($publicId, $meta['path']); + $this->assertSame(789, $meta['size']); + $this->assertSame(1633860610, $meta['timestamp']); + $this->assertSame('file', $meta['type']); + $this->assertSame(123456, $meta['version']); + $this->assertSame('::version-id::', $meta['versionid']); + $this->assertSame('public', $meta['visibility']); + Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); +}); + +it('can update', function () { + $publicId = '::file-path::'; + $contents = '::file-contents::'; + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) use ($publicId) { + $mock->shouldReceive('uploadApi->upload')->once()->andReturn(new ApiResponse([ + 'public_id' => $publicId, + 'version' => 123456, + 'version_id' => '::version-id::', + 'created_at' => '2021-10-10T10:10:10Z', + 'bytes' => 789, + 'etag' => '::etag::', + 'access_mode' => 'public', + ], [])); + }); + $adapter = new FlysystemCloudinaryAdapter($mock); + + $meta = $adapter->update($publicId, $contents, new Config); + + $this->assertSame($contents, $meta['contents']); + $this->assertSame('::etag::', $meta['etag']); + $this->assertSame('text/plain', $meta['mimetype']); + $this->assertSame($publicId, $meta['path']); + $this->assertSame(789, $meta['size']); + $this->assertSame(1633860610, $meta['timestamp']); + $this->assertSame('file', $meta['type']); + $this->assertSame(123456, $meta['version']); + $this->assertSame('::version-id::', $meta['versionid']); + $this->assertSame('public', $meta['visibility']); + Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); +}); + +it('can update stream', function () { + $publicId = '::file-path::'; + $contents = '::file-contents::'; + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) use ($publicId) { + $mock->shouldReceive('uploadApi->upload')->once()->andReturn(new ApiResponse([ + 'public_id' => $publicId, + 'version' => 123456, + 'version_id' => '::version-id::', + 'created_at' => '2021-10-10T10:10:10Z', + 'bytes' => 789, + 'etag' => '::etag::', + 'access_mode' => 'public', + ], [])); + }); + $adapter = new FlysystemCloudinaryAdapter($mock); + + $meta = $adapter->updateStream($publicId, $contents, new Config); + + $this->assertSame($contents, $meta['contents']); + $this->assertSame('::etag::', $meta['etag']); + $this->assertSame('text/plain', $meta['mimetype']); + $this->assertSame($publicId, $meta['path']); + $this->assertSame(789, $meta['size']); + $this->assertSame(1633860610, $meta['timestamp']); + $this->assertSame('file', $meta['type']); + $this->assertSame(123456, $meta['version']); + $this->assertSame('::version-id::', $meta['versionid']); + $this->assertSame('public', $meta['visibility']); + Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); +}); + +it('write passes a local file path string to upload for string contents', function () { + $publicId = '::file-path::'; + $contents = '::file-contents::'; + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) use ($publicId) { + $mock->shouldReceive('uploadApi->upload') + ->once() + ->withArgs(function (mixed $file, array $options) use ($publicId) { + expect($file)->toBeString(); + expect(is_readable($file))->toBeTrue(); + + return ($options['public_id'] ?? null) === $publicId; + }) + ->andReturn(new ApiResponse([ + 'public_id' => $publicId, + 'version' => 123456, + 'version_id' => '::version-id::', + 'created_at' => '2021-10-10T10:10:10Z', + 'bytes' => 789, + 'etag' => '::etag::', + 'access_mode' => 'public', + ], [])); + }); + $adapter = new FlysystemCloudinaryAdapter($mock); + + $adapter->write($publicId, $contents, new Config); + + expect($adapter->lastUploadMetadata()['path'])->toBe($publicId); +}); diff --git a/tests/Feature/Adapter/UrlTest.php b/tests/Feature/Adapter/UrlTest.php new file mode 100644 index 0000000..a059c1c --- /dev/null +++ b/tests/Feature/Adapter/UrlTest.php @@ -0,0 +1,88 @@ +mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('uploadApi->explicit')->once()->andReturn(new ApiResponse([ + 'url' => '::url::', + 'secure_url' => '::secure-url::', + ], [])); + }); + $adapter = new FlysystemCloudinaryAdapter($mock); + + $url = $adapter->getUrlViaRequest('::path::'); + + $this->assertSame('::secure-url::', $url); + Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 2); +}); + +it('can get url', function () { + $cloudinary = new Cloudinary([ + 'cloud_name' => env('CLOUDINARY_CLOUD_NAME'), + 'api_key' => env('CLOUDINARY_API_KEY'), + 'api_secret' => env('CLOUDINARY_API_SECRET'), + 'url' => [ + 'secure' => true, + ], + ]); + + $adapter = new FlysystemCloudinaryAdapter($cloudinary); + + $url = $adapter->getUrl('::path::'); + + expect($url) + ->toContain('https://', '::path::') + ->not->toContain('http://'); + + $cloudinary = new Cloudinary([ + 'cloud_name' => env('CLOUDINARY_CLOUD_NAME'), + 'api_key' => env('CLOUDINARY_API_KEY'), + 'api_secret' => env('CLOUDINARY_API_SECRET'), + 'url' => [ + 'secure' => false, + ], + ]); + + $adapter = new FlysystemCloudinaryAdapter($cloudinary); + + $url = $adapter->getUrl('::path::'); + + expect($url)->toContain('http://', '::path::') + ->not->toContain('https://'); +}); + +it('can get url with option', function () { + $cloudinary = new Cloudinary([ + 'cloud_name' => env('CLOUDINARY_CLOUD_NAME'), + 'api_key' => env('CLOUDINARY_API_KEY'), + 'api_secret' => env('CLOUDINARY_API_SECRET'), + 'url' => [ + 'secure' => true, + ], + ]); + + $adapter = new FlysystemCloudinaryAdapter($cloudinary); + + $url = $adapter->getUrl([ + 'path' => '::path::', + 'options' => [ + 'w_64', + 'h_64', + 'c_fill', + 'auto', + ], + ]); + + expect($url) + ->toContain('https://', '::path::') + ->toContain('w_64', 'h_64', 'c_fill', 'auto') + ->not->toContain('http://'); +}); diff --git a/tests/Feature/AdapterTest.php b/tests/Feature/AdapterTest.php deleted file mode 100644 index e645eab..0000000 --- a/tests/Feature/AdapterTest.php +++ /dev/null @@ -1,355 +0,0 @@ - env('CLOUDINARY_CLOUD_NAME'), - 'api_key' => env('CLOUDINARY_API_KEY'), - 'api_secret' => env('CLOUDINARY_API_SECRET'), - 'url' => [ - 'secure' => true, - ], - ]); - - $this->adapter = new FlysystemCloudinaryAdapter($cloudinary); -}); - -it('can write', function () { - $publicId = '::file-path::'; - $contents = '::file-contents::'; - $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) use ($publicId) { - $mock->shouldReceive('uploadApi->upload')->once()->andReturn(new ApiResponse([ - 'public_id' => $publicId, - 'version' => 123456, - 'version_id' => '::version-id::', - 'created_at' => '2021-10-10T10:10:10Z', - 'bytes' => 789, - 'etag' => '::etag::', - 'access_mode' => 'public', - ], [])); - }); - $adapter = new FlysystemCloudinaryAdapter($mock); - - $adapter->write($publicId, $contents, new Config); - - $this->assertSame($contents, $adapter->meta['contents']); - $this->assertSame('::etag::', $adapter->meta['etag']); - $this->assertSame('text/plain', $adapter->meta['mimetype']); - $this->assertSame($publicId, $adapter->meta['path']); - $this->assertSame(789, $adapter->meta['size']); - $this->assertSame(1633860610, $adapter->meta['timestamp']); - $this->assertSame('file', $adapter->meta['type']); - $this->assertSame(123456, $adapter->meta['version']); - $this->assertSame('::version-id::', $adapter->meta['versionid']); - $this->assertSame('public', $adapter->meta['visibility']); - Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); -}); - -it('can write stream', function () { - $publicId = '::file-path::'; - $contents = '::file-contents::'; - $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) use ($publicId) { - $mock->shouldReceive('uploadApi->upload')->once()->andReturn(new ApiResponse([ - 'public_id' => $publicId, - 'version' => 123456, - 'version_id' => '::version-id::', - 'created_at' => '2021-10-10T10:10:10Z', - 'bytes' => 789, - 'etag' => '::etag::', - 'access_mode' => 'public', - ], [])); - }); - $adapter = new FlysystemCloudinaryAdapter($mock); - - $adapter->writeStream($publicId, $contents, new Config); - - $this->assertSame($contents, $adapter->meta['contents']); - $this->assertSame('::etag::', $adapter->meta['etag']); - $this->assertSame('text/plain', $adapter->meta['mimetype']); - $this->assertSame($publicId, $adapter->meta['path']); - $this->assertSame(789, $adapter->meta['size']); - $this->assertSame(1633860610, $adapter->meta['timestamp']); - $this->assertSame('file', $adapter->meta['type']); - $this->assertSame(123456, $adapter->meta['version']); - $this->assertSame('::version-id::', $adapter->meta['versionid']); - $this->assertSame('public', $adapter->meta['visibility']); - Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); -}); - -it('can update', function () { - $publicId = '::file-path::'; - $contents = '::file-contents::'; - $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) use ($publicId) { - $mock->shouldReceive('uploadApi->upload')->once()->andReturn(new ApiResponse([ - 'public_id' => $publicId, - 'version' => 123456, - 'version_id' => '::version-id::', - 'created_at' => '2021-10-10T10:10:10Z', - 'bytes' => 789, - 'etag' => '::etag::', - 'access_mode' => 'public', - ], [])); - }); - $adapter = new FlysystemCloudinaryAdapter($mock); - - $meta = $adapter->update($publicId, $contents, new Config); - - $this->assertSame($contents, $meta['contents']); - $this->assertSame('::etag::', $meta['etag']); - $this->assertSame('text/plain', $meta['mimetype']); - $this->assertSame($publicId, $meta['path']); - $this->assertSame(789, $meta['size']); - $this->assertSame(1633860610, $meta['timestamp']); - $this->assertSame('file', $meta['type']); - $this->assertSame(123456, $meta['version']); - $this->assertSame('::version-id::', $meta['versionid']); - $this->assertSame('public', $meta['visibility']); - Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); -}); - -it('can update stream', function () { - $publicId = '::file-path::'; - $contents = '::file-contents::'; - $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) use ($publicId) { - $mock->shouldReceive('uploadApi->upload')->once()->andReturn(new ApiResponse([ - 'public_id' => $publicId, - 'version' => 123456, - 'version_id' => '::version-id::', - 'created_at' => '2021-10-10T10:10:10Z', - 'bytes' => 789, - 'etag' => '::etag::', - 'access_mode' => 'public', - ], [])); - }); - $adapter = new FlysystemCloudinaryAdapter($mock); - - $meta = $adapter->updateStream($publicId, $contents, new Config); - - $this->assertSame($contents, $meta['contents']); - $this->assertSame('::etag::', $meta['etag']); - $this->assertSame('text/plain', $meta['mimetype']); - $this->assertSame($publicId, $meta['path']); - $this->assertSame(789, $meta['size']); - $this->assertSame(1633860610, $meta['timestamp']); - $this->assertSame('file', $meta['type']); - $this->assertSame(123456, $meta['version']); - $this->assertSame('::version-id::', $meta['versionid']); - $this->assertSame('public', $meta['visibility']); - Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); -}); - -it('can rename', function () { - $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { - $mock->shouldReceive('uploadApi->rename')->once()->andReturn(new ApiResponse([], [])); - }); - $adapter = new FlysystemCloudinaryAdapter($mock); - - $bool = $adapter->rename('::old-path::', '::new-path::'); - - $this->assertTrue($bool); - Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); -}); - -it('can copy', function () { - Http::fake(); - $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { - $mock->shouldReceive('uploadApi->explicit')->once()->andReturn(new ApiResponse([ - 'secure_url' => '::url::', - ], [])); - $mock->shouldReceive('uploadApi->upload')->once()->andReturn(new ApiResponse([], [])); - }); - $adapter = new FlysystemCloudinaryAdapter($mock); - - $adapter->copy('::from-path::', '::to-path::', new Config); - - $this->assertTrue($adapter->copied); - Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 2); -}); - -it('can delete', function () { - $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { - $mock->shouldReceive('uploadApi->destroy')->once()->andReturn(new ApiResponse([ - 'result' => 'ok', - ], [])); - }); - $adapter = new FlysystemCloudinaryAdapter($mock); - - $adapter->delete('::path::'); - - $this->assertTrue($adapter->deleted); - Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); -}); - -it('can delete a directory', function () { - $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { - $mock->shouldReceive('adminApi->assets')->times(3)->andReturn(new ApiResponse([ - 'resources' => [], - ], [])); - $mock->shouldReceive('adminApi->subFolders')->once()->andReturn(new ApiResponse([ - 'folders' => [], - ], [])); - $mock->shouldReceive('adminApi->deleteFolder')->once()->andReturn(new ApiResponse([], [])); - }); - $adapter = new FlysystemCloudinaryAdapter($mock); - - $bool = $adapter->deleteDir('::path::'); - - $this->assertTrue($bool); - Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 5); -}); - -it('can create a directory', function () { - $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { - $mock->shouldReceive('adminApi->createFolder')->once()->andReturn(new ApiResponse([], [])); - }); - $adapter = new FlysystemCloudinaryAdapter($mock); - - $meta = $adapter->createDir('::path::', new Config); - - $this->assertSame([ - 'path' => '::path::', - 'type' => 'dir', - ], $meta); - Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); -}); - -it('can check if file exists', function () { - $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { - $mock->shouldReceive('uploadApi->explicit')->once()->andReturn(new ApiResponse([], [])); - }); - $adapter = new FlysystemCloudinaryAdapter($mock); - - $bool = $adapter->has('::path::'); - - $this->assertTrue($bool); - Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); -}); - -it('can read stream', function () { - Http::fake(); - $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { - $mock->shouldReceive('uploadApi->explicit')->once()->andReturn(new ApiResponse([ - 'secure_url' => '::url::', - ], [])); - }); - $adapter = new FlysystemCloudinaryAdapter($mock); - - $meta = $adapter->readStream('::path::'); - - $this->assertIsResource($meta['stream']); - $this->assertArrayNotHasKey('contents', $meta); - Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); -}); - -it('can list directory contents', function () { - $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { - $mock->shouldReceive('adminApi->assets')->times(3)->andReturn(new ApiResponse([ - 'resources' => [], - ], [])); - - $mock->shouldReceive('adminApi->subFolders')->once()->andReturn(new ApiResponse([ - 'folders' => [], - ], [])); - }); - $adapter = new FlysystemCloudinaryAdapter($mock); - - $files = $adapter->listContents('::path::'); - - $this->assertSame([], $files); - Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 4); -}); - -it('can get url via request', function () { - Http::fake(); - $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { - $mock->shouldReceive('uploadApi->explicit')->once()->andReturn(new ApiResponse([ - 'url' => '::url::', - 'secure_url' => '::secure-url::', - ], [])); - }); - $adapter = new FlysystemCloudinaryAdapter($mock); - - $url = $adapter->getUrlViaRequest('::path::'); - - $this->assertSame('::secure-url::', $url); - Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 2); -}); - -it('can get url', function () { - // Secure URL - - $cloudinary = new Cloudinary([ - 'cloud_name' => env('CLOUDINARY_CLOUD_NAME'), - 'api_key' => env('CLOUDINARY_API_KEY'), - 'api_secret' => env('CLOUDINARY_API_SECRET'), - 'url' => [ - 'secure' => true, - ], - ]); - - $adapter = new FlysystemCloudinaryAdapter($cloudinary); - - $url = $adapter->getUrl('::path::'); - - expect($url) - ->toContain('https://', '::path::') - ->not->toContain('http://'); - - // Unsecure URL - - $cloudinary = new Cloudinary([ - 'cloud_name' => env('CLOUDINARY_CLOUD_NAME'), - 'api_key' => env('CLOUDINARY_API_KEY'), - 'api_secret' => env('CLOUDINARY_API_SECRET'), - 'url' => [ - 'secure' => false, - ], - ]); - - $adapter = new FlysystemCloudinaryAdapter($cloudinary); - - $url = $adapter->getUrl('::path::'); - - expect($url)->toContain('http://', '::path::') - ->not->toContain('https://'); -}); - -it('can get url with option', function () { - // Secure URL - - $cloudinary = new Cloudinary([ - 'cloud_name' => env('CLOUDINARY_CLOUD_NAME'), - 'api_key' => env('CLOUDINARY_API_KEY'), - 'api_secret' => env('CLOUDINARY_API_SECRET'), - 'url' => [ - 'secure' => true, - ], - ]); - - $adapter = new FlysystemCloudinaryAdapter($cloudinary); - - $url = $adapter->getUrl([ - 'path' => '::path::', - 'options' => [ - 'w_64', - 'h_64', - 'c_fill', - 'auto', - ], - ]); - - expect($url) - ->toContain('https://', '::path::') - ->toContain('w_64', 'h_64', 'c_fill', 'auto') - ->not->toContain('http://'); -}); diff --git a/tests/Feature/FlysystemCloudinaryServiceProviderTest.php b/tests/Feature/FlysystemCloudinaryServiceProviderTest.php new file mode 100644 index 0000000..d09a95d --- /dev/null +++ b/tests/Feature/FlysystemCloudinaryServiceProviderTest.php @@ -0,0 +1,20 @@ + 'cloudinary', + 'filesystems.disks.cloudinary' => [ + 'driver' => 'cloudinary', + 'cloud_name' => env('CLOUDINARY_CLOUD_NAME', 'demo'), + 'api_key' => env('CLOUDINARY_API_KEY', '1'), + 'api_secret' => env('CLOUDINARY_API_SECRET', '1'), + ], + ]); + + $adapter = Storage::disk('cloudinary')->getAdapter(); + + expect($adapter)->toBeInstanceOf(FlysystemCloudinaryAdapter::class); +}); diff --git a/tests/Integration/CloudinaryTest.php b/tests/Integration/CloudinaryTest.php index 73fdc6c..cfc9869 100644 --- a/tests/Integration/CloudinaryTest.php +++ b/tests/Integration/CloudinaryTest.php @@ -5,10 +5,14 @@ use Illuminate\Http\Testing\File; use Illuminate\Support\Facades\Event; use League\Flysystem\Config; +use League\Flysystem\UnableToCopyFile; use League\Flysystem\UnableToMoveFile; +use League\Flysystem\UnableToReadFile; use League\Flysystem\UnableToRetrieveMetadata; beforeEach(function () { + assertCloudinaryLiveCredentialsOrSkip($this); + Event::fake(); $cloudinary = new Cloudinary([ @@ -30,6 +34,7 @@ $this->adapter->write($publicId, $fakeImage, new Config); assertUploadResponse($this, $this->adapter->meta, $publicId); + $this->assertSame($this->adapter->meta, $this->adapter->lastUploadMetadata()); $this->adapter->delete($publicId); // cleanup }); @@ -40,6 +45,7 @@ $this->adapter->writeStream($publicId, $fakeImage, new Config); assertUploadResponse($this, $this->adapter->meta, $publicId); + $this->assertSame($this->adapter->meta, $this->adapter->lastUploadMetadata()); $this->adapter->delete($publicId); // cleanup }); @@ -67,9 +73,9 @@ function assertUploadResponse(mixed $test, array $meta, string $publicId): void { $test->assertIsString($meta['contents']); $test->assertIsString($meta['etag']); - $test->assertSame('image/jpeg', $meta['mimetype']); + $test->assertContains($meta['mimetype'], ['image/jpeg', 'image/jpg']); $test->assertSame($publicId, $meta['path']); - $test->assertSame(695, $meta['size']); + $test->assertGreaterThan(0, $meta['size']); $test->assertIsInt($meta['timestamp']); $test->assertSame('file', $meta['type']); $test->assertIsInt($meta['version']); @@ -129,9 +135,8 @@ function assertUploadResponse(mixed $test, array $meta, string $publicId): void $path = 'file-does-not-exist'; $newPath = 'file-copied'; + $this->expectException(UnableToCopyFile::class); $this->adapter->copy($path, $newPath, new Config); - - $this->assertFalse($this->adapter->copied); }); it('can delete', function () { @@ -201,31 +206,28 @@ function assertUploadResponse(mixed $test, array $meta, string $publicId): void $fakeImage = File::image('black.jpg')->getContent(); $this->adapter->write($publicId, $fakeImage, new Config); - $meta = $this->adapter->readStream($publicId); + $stream = $this->adapter->readStream($publicId); - $this->assertIsResource($meta['stream']); - $this->assertArrayNotHasKey('contents', $meta); + $this->assertIsResource($stream); $this->adapter->delete($publicId); // cleanup }); it('does not read if file is not found', function () { $publicId = 'file-does-not-exist'; - $response = $this->adapter->read($publicId); - - $this->assertEmpty($response); + $this->expectException(UnableToReadFile::class); + $this->adapter->read($publicId); }); it('does not read stream if file is not found', function () { $publicId = 'file-does-not-exist'; - $bool = $this->adapter->readStream($publicId); - - $this->assertFalse($bool); + $this->expectException(UnableToReadFile::class); + $this->adapter->readStream($publicId); }); it('can list directory contents', function () { - $files = $this->adapter->listContents('sandbox'); + $files = iterator_to_array($this->adapter->listContents('sandbox', false)); $this->assertIsArray($files); }); @@ -237,7 +239,7 @@ function assertUploadResponse(mixed $test, array $meta, string $publicId): void $size = $this->adapter->getSize($publicId); - $this->assertEquals(695, $size); + $this->assertGreaterThan(0, $size); $this->adapter->delete($publicId); // cleanup }); @@ -255,7 +257,7 @@ function assertUploadResponse(mixed $test, array $meta, string $publicId): void $mimeType = $this->adapter->getMimetype($publicId); - $this->assertEquals('image/jpg', $mimeType); + $this->assertContains($mimeType, ['image/jpeg', 'image/jpg']); $this->adapter->delete($publicId); // cleanup }); @@ -346,3 +348,19 @@ function assertUploadResponse(mixed $test, array $meta, string $publicId): void $this->adapter->deleteDirectory($directory); $this->assertFalse($this->adapter->directoryExists($directory)); }); + +it('fileExists returns false for a missing path', function () { + expect($this->adapter->fileExists('no-such-file-'.rand().'/x'))->toBeFalse(); +}); + +it('directoryExists returns false for a path that was never created', function () { + expect($this->adapter->directoryExists('no-such-dir-'.rand()))->toBeFalse(); +}); + +it('has returns false for a missing file', function () { + expect($this->adapter->has('missing-'.rand()))->toBeFalse(); +}); + +it('rename returns false when source is missing', function () { + expect($this->adapter->rename('missing-'.rand(), 'dest'))->toBeFalse(); +}); diff --git a/tests/Pest.php b/tests/Pest.php index f02f3f5..2ef8c27 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,29 @@ in(__DIR__); + +uses()->beforeEach(function () { + Event::fake(); + + config(['flysystem-cloudinary.folder' => env('CLOUDINARY_FOLDER')]); + + $this->cloudinary = new Cloudinary([ + 'cloud_name' => env('CLOUDINARY_CLOUD_NAME'), + 'api_key' => env('CLOUDINARY_API_KEY'), + 'api_secret' => env('CLOUDINARY_API_SECRET'), + 'url' => [ + 'secure' => true, + ], + ]); + + $this->adapter = new FlysystemCloudinaryAdapter($this->cloudinary); +})->in(__DIR__.'/Feature/Adapter'); + +uses()->group('integration')->in(__DIR__.'/Integration'); diff --git a/tests/Unit/CloudinaryAdminFolderLocatorTest.php b/tests/Unit/CloudinaryAdminFolderLocatorTest.php new file mode 100644 index 0000000..397d3a2 --- /dev/null +++ b/tests/Unit/CloudinaryAdminFolderLocatorTest.php @@ -0,0 +1,66 @@ +mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('adminApi->subFolders') + ->once() + ->andReturn(new ApiResponse([ + 'folders' => [['path' => 'other']], + 'next_cursor' => 'c1', + ], [])); + $mock->shouldReceive('adminApi->subFolders') + ->once() + ->andReturn(new ApiResponse([ + 'folders' => [['path' => 'parent/target']], + 'next_cursor' => null, + ], [])); + }); + + $locator = new CloudinaryAdminFolderLocator; + + expect($locator->folderExists($mock, 'parent/target'))->toBeTrue(); +}); + +it('returns false when folder is not listed', function () { + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('adminApi->subFolders') + ->once() + ->andReturn(new ApiResponse(['folders' => [], 'next_cursor' => null], [])); + }); + + $locator = new CloudinaryAdminFolderLocator; + + expect($locator->folderExists($mock, 'a/b'))->toBeFalse(); +}); + +it('returns false when subFolders throws', function () { + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('adminApi->subFolders') + ->once() + ->andThrow(new Exception('api')); + }); + + $locator = new CloudinaryAdminFolderLocator; + + expect($locator->folderExists($mock, 'a/b'))->toBeFalse(); +}); + +it('uses empty needle for root-level prefixed path', function () { + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('adminApi->subFolders') + ->once() + ->andReturn(new ApiResponse([ + 'folders' => [['path' => 'only']], + 'next_cursor' => null, + ], [])); + }); + + $locator = new CloudinaryAdminFolderLocator; + + expect($locator->folderExists($mock, 'only'))->toBeTrue(); +}); diff --git a/tests/Unit/CloudinaryDiskOptionsTest.php b/tests/Unit/CloudinaryDiskOptionsTest.php new file mode 100644 index 0000000..40a141a --- /dev/null +++ b/tests/Unit/CloudinaryDiskOptionsTest.php @@ -0,0 +1,62 @@ + true], true); + $config = new Config(['options' => ['foo' => 'bar']]); + + $opts = $disk->uploadOptionsFor('my-id', $config); + + expect($opts['public_id'])->toBe('my-id') + ->and($opts['folder'])->toBe('app') + ->and($opts['upload_preset'])->toBe('my_preset') + ->and($opts['async'])->toBeTrue() + ->and($opts['foo'])->toBe('bar'); +}); + +it('omits null folder and preset keys from upload options', function () { + $disk = new CloudinaryDiskOptions(null, null, [], false); + $opts = $disk->uploadOptionsFor('id', new Config); + + expect($opts)->not->toHaveKey('folder') + ->and($opts)->not->toHaveKey('upload_preset'); +}); + +it('prefers disk array over published config for folder and preset', function () { + config([ + 'flysystem-cloudinary.folder' => 'from_published', + 'flysystem-cloudinary.upload_preset' => 'pub_preset', + 'flysystem-cloudinary.secure_url' => false, + 'flysystem-cloudinary.options' => ['x' => 1], + ]); + + $disk = CloudinaryDiskOptions::fromDiskAndConfig([ + 'folder' => 'from_disk', + 'upload_preset' => 'disk_preset', + 'secure_url' => true, + 'options' => ['y' => 2], + ]); + + expect($disk->folder)->toBe('from_disk') + ->and($disk->uploadPreset)->toBe('disk_preset') + ->and($disk->preferSecureUrl)->toBeTrue() + ->and($disk->globalUploadOptions)->toBe(['y' => 2]); +}); + +it('falls back to published config when disk omits keys', function () { + config([ + 'flysystem-cloudinary.folder' => 'pub_folder', + 'flysystem-cloudinary.upload_preset' => null, + 'flysystem-cloudinary.secure_url' => false, + 'flysystem-cloudinary.options' => ['merge' => true], + ]); + + $disk = CloudinaryDiskOptions::fromDiskAndConfig([]); + + expect($disk->folder)->toBe('pub_folder') + ->and($disk->uploadPreset)->toBeNull() + ->and($disk->preferSecureUrl)->toBeFalse() + ->and($disk->globalUploadOptions)->toBe(['merge' => true]); +}); diff --git a/tests/Unit/CloudinaryListResponseAssemblerTest.php b/tests/Unit/CloudinaryListResponseAssemblerTest.php new file mode 100644 index 0000000..7116629 --- /dev/null +++ b/tests/Unit/CloudinaryListResponseAssemblerTest.php @@ -0,0 +1,78 @@ +mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('adminApi->assets')->once()->andThrow(new ApiError('rate')); + }); + + $paths = new CloudinaryPathNormalizer(null); + $assembler = new CloudinaryListResponseAssembler( + $mock, + $paths, + new CloudinaryResponseMapper($paths), + new CloudinaryResponseLogger + ); + + expect($assembler->shallowList('prefix'))->toBe([]); +}); + +it('maps resources and folders to attributes', function () { + Event::fake(); + + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('adminApi->assets') + ->times(3) + ->andReturn(new ApiResponse([ + 'resources' => [ + [ + 'public_id' => 'prefix/a', + 'bytes' => 1, + 'created_at' => '2021-01-01T00:00:00Z', + 'etag' => 'e', + 'access_mode' => 'public', + ], + ], + ], [])); + $mock->shouldReceive('adminApi->subFolders') + ->once() + ->andReturn(new ApiResponse([ + 'folders' => [ + ['path' => 'prefix/sub', 'name' => 'sub'], + ], + ], [])); + }); + + $paths = new CloudinaryPathNormalizer(null); + $assembler = new CloudinaryListResponseAssembler( + $mock, + $paths, + new CloudinaryResponseMapper($paths), + new CloudinaryResponseLogger + ); + + $items = $assembler->shallowList('prefix'); + + expect($items)->toHaveCount(4) + ->and($items[0])->toBeInstanceOf(FileAttributes::class) + ->and($items[1])->toBeInstanceOf(FileAttributes::class) + ->and($items[2])->toBeInstanceOf(FileAttributes::class) + ->and($items[3])->toBeInstanceOf(DirectoryAttributes::class) + ->and($items[3]->path())->toBe('prefix/sub'); + + Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 4); +}); diff --git a/tests/Unit/CloudinaryPathNormalizerTest.php b/tests/Unit/CloudinaryPathNormalizerTest.php new file mode 100644 index 0000000..930459c --- /dev/null +++ b/tests/Unit/CloudinaryPathNormalizerTest.php @@ -0,0 +1,36 @@ +prefixed(' foo/bar '))->toBe('foo/bar'); + expect($normalizer->logical('foo/bar'))->toBe('foo/bar'); +}); + +it('returns trimmed path when folder is empty string', function () { + $normalizer = new CloudinaryPathNormalizer(''); + + expect($normalizer->prefixed('foo'))->toBe('foo'); +}); + +it('prefixes logical path with folder', function () { + $normalizer = new CloudinaryPathNormalizer('app_uploads'); + + expect($normalizer->prefixed('file.jpg'))->toBe('app_uploads/file.jpg'); + expect($normalizer->prefixed('/nested/path'))->toBe('app_uploads/nested/path'); +}); + +it('strips folder prefix for logical path', function () { + $normalizer = new CloudinaryPathNormalizer('app_uploads'); + + expect($normalizer->logical('app_uploads/file.jpg'))->toBe('file.jpg'); + expect($normalizer->logical('app_uploads/nested/a'))->toBe('nested/a'); +}); + +it('trims folder slashes when prefixing', function () { + $normalizer = new CloudinaryPathNormalizer(' /my/folder/ '); + + expect($normalizer->prefixed('x'))->toBe('my/folder/x'); +}); diff --git a/tests/Unit/CloudinaryResourceOperationsTest.php b/tests/Unit/CloudinaryResourceOperationsTest.php new file mode 100644 index 0000000..af2dca5 --- /dev/null +++ b/tests/Unit/CloudinaryResourceOperationsTest.php @@ -0,0 +1,103 @@ +mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('uploadApi->explicit') + ->once() + ->andReturn(new ApiResponse(['public_id' => 'x'], [])); + }); + + $ops = new CloudinaryResourceOperations(new CloudinaryResponseLogger); + $result = $ops->explicit($mock, 'prefixed/id'); + + expect($result)->toBeInstanceOf(ApiResponse::class); + Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); +}); + +it('explicit tries resource types until not found then throws last NotFound', function () { + Event::fake(); + + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('uploadApi->explicit') + ->times(3) + ->andThrow(new NotFound('missing')); + }); + + $ops = new CloudinaryResourceOperations(new CloudinaryResponseLogger); + + $this->expectException(NotFound::class); + $ops->explicit($mock, 'missing/asset'); +}); + +it('destroy returns true when first destroy returns ok', function () { + Event::fake(); + + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('uploadApi->destroy') + ->once() + ->andReturn(new ApiResponse(['result' => 'ok'], [])); + }); + + $ops = new CloudinaryResourceOperations(new CloudinaryResponseLogger); + + expect($ops->destroy($mock, 'path'))->toBeTrue(); + Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); +}); + +it('destroy tries types until ok', function () { + Event::fake(); + + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('uploadApi->destroy') + ->twice() + ->andReturn( + new ApiResponse(['result' => 'noop'], []), + new ApiResponse(['result' => 'ok'], []), + ); + }); + + $ops = new CloudinaryResourceOperations(new CloudinaryResponseLogger); + + expect($ops->destroy($mock, 'path'))->toBeTrue(); + Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 2); +}); + +it('destroy returns false when ApiError is thrown', function () { + Event::fake(); + + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('uploadApi->destroy') + ->once() + ->andThrow(new ApiError('fail')); + }); + + $ops = new CloudinaryResourceOperations(new CloudinaryResponseLogger); + + expect($ops->destroy($mock, 'path'))->toBeFalse(); +}); + +it('destroy returns false when no type returns ok', function () { + Event::fake(); + + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('uploadApi->destroy') + ->times(3) + ->andReturn(new ApiResponse(['result' => 'not_found'], [])); + }); + + $ops = new CloudinaryResourceOperations(new CloudinaryResponseLogger); + + expect($ops->destroy($mock, 'path'))->toBeFalse(); +}); diff --git a/tests/Unit/CloudinaryResponseLoggerTest.php b/tests/Unit/CloudinaryResponseLoggerTest.php new file mode 100644 index 0000000..f44ab32 --- /dev/null +++ b/tests/Unit/CloudinaryResponseLoggerTest.php @@ -0,0 +1,17 @@ + 'ok'], []); + (new CloudinaryResponseLogger)->log($response); + + Event::assertDispatched(FlysystemCloudinaryResponseLog::class, function (FlysystemCloudinaryResponseLog $event) use ($response) { + return $event->response === $response; + }); +}); diff --git a/tests/Unit/CloudinaryResponseMapperTest.php b/tests/Unit/CloudinaryResponseMapperTest.php new file mode 100644 index 0000000..cfce642 --- /dev/null +++ b/tests/Unit/CloudinaryResponseMapperTest.php @@ -0,0 +1,82 @@ + 'e', + 'bytes' => 10, + 'created_at' => '2021-01-01T00:00:00Z', + 'version' => 1, + 'version_id' => 'v1', + 'access_mode' => 'public', + ], []); + + $normalized = $mapper->normalizeUploadOrExplicit($row, 'app/file.txt', 'body'); + + expect($normalized['path'])->toBe('file.txt') + ->and($normalized['contents'])->toBe('body') + ->and($normalized['etag'])->toBe('e') + ->and($normalized['size'])->toBe(10) + ->and($normalized['visibility'])->toBe('public') + ->and($normalized['version'])->toBe(1) + ->and($normalized['versionid'])->toBe('v1'); +}); + +it('normalizeUploadOrExplicit marks private access_mode', function () { + $mapper = new CloudinaryResponseMapper(new CloudinaryPathNormalizer(null)); + $row = new ApiResponse(['access_mode' => 'authenticated'], []); + + expect($mapper->normalizeUploadOrExplicit($row, 'a', null)['visibility'])->toBe('private'); +}); + +it('normalizedToFileAttributes maps extra keys and null timestamp when strtotime false', function () { + $mapper = new CloudinaryResponseMapper(new CloudinaryPathNormalizer(null)); + $normalized = [ + 'path' => 'p', + 'size' => '5', + 'visibility' => 'public', + 'timestamp' => false, + 'mimetype' => 'text/plain', + 'etag' => '', + 'version' => null, + ]; + + $fa = $mapper->normalizedToFileAttributes($normalized); + + expect($fa->path())->toBe('p') + ->and($fa->fileSize())->toBe(5) + ->and($fa->lastModified())->toBeNull(); +}); + +it('adminAssetToFileAttributes builds mime type string and logical path', function () { + $mapper = new CloudinaryResponseMapper(new CloudinaryPathNormalizer('root')); + $fa = $mapper->adminAssetToFileAttributes([ + 'public_id' => 'root/sub/file', + 'bytes' => 100, + 'created_at' => '2020-06-15T12:00:00Z', + 'resource_type' => 'image', + 'format' => 'jpg', + ]); + + expect($fa->path())->toBe('sub/file') + ->and($fa->mimeType())->toBe('image/jpg') + ->and($fa->fileSize())->toBe(100); +}); + +it('extractExtraMetadata skips empty strings', function () { + $mapper = new CloudinaryResponseMapper(new CloudinaryPathNormalizer(null)); + + $extra = $mapper->extractExtraMetadata([ + 'version' => 2, + 'url' => '', + 'secure_url' => 'https://x', + ]); + + expect($extra)->toHaveKey('version') + ->and($extra)->toHaveKey('secure_url') + ->and($extra)->not->toHaveKey('url'); +}); diff --git a/tests/Unit/CloudinaryStringUploadSourceTest.php b/tests/Unit/CloudinaryStringUploadSourceTest.php new file mode 100644 index 0000000..f393663 --- /dev/null +++ b/tests/Unit/CloudinaryStringUploadSourceTest.php @@ -0,0 +1,18 @@ +toBeArray() + ->and($source['path'])->toBeString()->not->toBeEmpty() + ->and(is_readable($source['path']))->toBeTrue() + ->and($source['handle'])->toBeResource(); + + expect(file_get_contents($source['path']))->toBe($contents); + + fclose($source['handle']); +}); diff --git a/tests/Unit/CloudinaryUrlBuilderTest.php b/tests/Unit/CloudinaryUrlBuilderTest.php new file mode 100644 index 0000000..7a19312 --- /dev/null +++ b/tests/Unit/CloudinaryUrlBuilderTest.php @@ -0,0 +1,78 @@ +mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('image')->once()->andThrow(new NotFound('nope')); + }); + + $builder = new CloudinaryUrlBuilder( + $mock, + new CloudinaryPathNormalizer(null), + new CloudinaryDiskOptions(null, null, [], true), + new CloudinaryResourceOperations(new CloudinaryResponseLogger), + new CloudinaryResponseLogger + ); + + expect($builder->deliveryUrl('any'))->toBeFalse(); +}); + +it('urlViaExplicit prefers secure url when disk option is true', function () { + Event::fake(); + + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('uploadApi->explicit') + ->once() + ->andReturn(new ApiResponse([ + 'url' => 'http://insecure', + 'secure_url' => 'https://secure', + ], [])); + }); + + $logger = new CloudinaryResponseLogger; + $builder = new CloudinaryUrlBuilder( + $mock, + new CloudinaryPathNormalizer(null), + new CloudinaryDiskOptions(null, null, [], true), + new CloudinaryResourceOperations($logger), + $logger + ); + + expect($builder->urlViaExplicit('file'))->toBe('https://secure'); + Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 2); +}); + +it('urlViaExplicit returns plain url when preferSecureUrl is false', function () { + Event::fake(); + + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('uploadApi->explicit') + ->once() + ->andReturn(new ApiResponse([ + 'url' => 'http://insecure', + 'secure_url' => 'https://secure', + ], [])); + }); + + $logger = new CloudinaryResponseLogger; + $builder = new CloudinaryUrlBuilder( + $mock, + new CloudinaryPathNormalizer(null), + new CloudinaryDiskOptions(null, null, [], false), + new CloudinaryResourceOperations($logger), + $logger + ); + + expect($builder->urlViaExplicit('file'))->toBe('http://insecure'); +}); diff --git a/tests/Unit/FlysystemCloudinaryResponseLogTest.php b/tests/Unit/FlysystemCloudinaryResponseLogTest.php new file mode 100644 index 0000000..33a5708 --- /dev/null +++ b/tests/Unit/FlysystemCloudinaryResponseLogTest.php @@ -0,0 +1,11 @@ + 1], []); + $event = new FlysystemCloudinaryResponseLog($response); + + expect($event->response)->toBe($response); +}); diff --git a/tests/Unit/InteractsWithCloudinaryMetadataTest.php b/tests/Unit/InteractsWithCloudinaryMetadataTest.php new file mode 100644 index 0000000..c1b8d81 --- /dev/null +++ b/tests/Unit/InteractsWithCloudinaryMetadataTest.php @@ -0,0 +1,77 @@ +cloudinary; + } + + protected function pathsForMetadata(): CloudinaryPathNormalizer + { + return $this->paths; + } + + protected function mapperForMetadata(): CloudinaryResponseMapper + { + return $this->mapper; + } + + public function fetchMetadata(string $path, string $type): FileAttributes + { + return $this->getMetadata($path, $type); + } +} + +it('maps admin asset to file attributes', function () { + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('adminApi->asset') + ->once() + ->andReturn(new ApiResponse([ + 'public_id' => 'file', + 'bytes' => 50, + 'created_at' => '2022-02-02T02:02:02Z', + 'resource_type' => 'raw', + 'format' => 'txt', + ], [])); + }); + + $paths = new CloudinaryPathNormalizer(null); + $double = new MetadataTestDouble($mock, $paths, new CloudinaryResponseMapper($paths)); + + $fa = $double->fetchMetadata('file', FileAttributes::ATTRIBUTE_FILE_SIZE); + + expect($fa->path())->toBe('file') + ->and($fa->fileSize())->toBe(50); +}); + +it('wraps admin asset failures in UnableToRetrieveMetadata', function () { + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('adminApi->asset') + ->once() + ->andThrow(new Exception('not found')); + }); + + $paths = new CloudinaryPathNormalizer(null); + $double = new MetadataTestDouble($mock, $paths, new CloudinaryResponseMapper($paths)); + + $this->expectException(UnableToRetrieveMetadata::class); + $double->fetchMetadata('missing', FileAttributes::ATTRIBUTE_FILE_SIZE); +}); diff --git a/tests/cloudinary_integration.php b/tests/cloudinary_integration.php new file mode 100644 index 0000000..8d204f0 --- /dev/null +++ b/tests/cloudinary_integration.php @@ -0,0 +1,33 @@ +markTestSkipped( + 'Set CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, and CLOUDINARY_API_SECRET in the environment to run integration tests against the live API.' + ); + } + + if (strlen($apiSecret) < 20) { + $case->markTestSkipped( + 'Integration requires a real CLOUDINARY_API_SECRET (typical length 27+). Short values from phpunit.xml placeholders are not used for live API calls.' + ); + } +}