feat(storage): S3 相容物件儲存支援 + 一次性遷移指令

實作 cloud-ready-s3-storage 的程式碼部分(tasks 1-3):

- config/filesystems.php:public disk 的 driver 改吃 FILESYSTEM_DISK env,
  切換為 s3 時不需改動既有 6 個 Storage::disk('public') 呼叫點
- .env.example:FILESYSTEM_DISK 維持 local 預設(本機無 S3 服務),補齊
  AWS_ENDPOINT/AWS_URL/AWS_USE_PATH_STYLE_ENDPOINT 供 R2 等服務使用
- 新增 storage:migrate-to-s3 指令:一次性把 storage/app/public 既有檔案
  上傳到 S3,來源固定讀本機磁碟(不受 FILESYSTEM_DISK 影響),支援
  --dry-run,失敗不中斷且不刪除本機檔案
- 新增測試:遷移指令(上傳/dry-run/部分失敗)+ public disk 在 local
  driver 下零 AWS credentials 也能正常運作

239 tests passed / 578 assertions,容器內驗證無回歸。

R2 環境準備、VPS 部署與實際切換(tasks 4-6)待 Hank 自行處理。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LpcY9b7y9x4fusHBTXciQ
This commit is contained in:
2026-08-03 03:35:24 +08:00
parent e794fddfb1
commit 54178f6d70
8 changed files with 579 additions and 16 deletions
+4 -2
View File
@@ -34,7 +34,7 @@ SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local # 雲端應改為 s3(local 在容器重啟後會遺失上傳檔案
FILESYSTEM_DISK=local # 本機開發維持 local;雲端部署改為 s3 時需一併設定下方 AWS_* 變數(含 AWS_ENDPOINTAWS_URL
QUEUE_CONNECTION=redis # 雲端建議值;database 效能較差且不利擴容
CACHE_STORE=redis
@@ -60,7 +60,9 @@ AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
AWS_ENDPOINT= # S3 相容服務(如 Cloudflare R2)的 endpoint URLAWS S3 本身留空即可
AWS_URL= # 公開存取用的網址前綴(如 R2 公開網域),留空時 fallback 為 AWS SDK 預設組法
AWS_USE_PATH_STYLE_ENDPOINT=false # Cloudflare R2 需設為 true
VITE_APP_NAME="${APP_NAME}"
@@ -0,0 +1,83 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Throwable;
class MigrateStorageToS3 extends Command
{
protected $signature = 'storage:migrate-to-s3
{--dry-run : 只列出將上傳的檔案與總數,不實際執行}';
protected $description = '將本機 storage/app/public 下既有上傳檔案搬遷至 S3 相容物件儲存(public disk 切換前的一次性遷移,不刪除本機檔案)';
public function handle(): int
{
// 直接指定 local 磁碟為來源,不透過會依 FILESYSTEM_DISK 變動的 'public' disk
// 避免這支指令在 FILESYSTEM_DISK 已切成 s3 後誤把來源當成目的地。
$source = Storage::build([
'driver' => 'local',
'root' => storage_path('app/public'),
]);
$files = $source->allFiles();
if (empty($files)) {
$this->info('storage/app/public 底下沒有檔案,無需遷移。');
return self::SUCCESS;
}
if ($this->option('dry-run')) {
$this->info(sprintf('[dry-run] 將上傳 %d 個檔案:', count($files)));
foreach ($files as $file) {
$this->line(" - {$file}");
}
return self::SUCCESS;
}
$destination = Storage::disk('s3');
$failed = [];
$this->info(sprintf('開始上傳 %d 個檔案至 S3...', count($files)));
$bar = $this->output->createProgressBar(count($files));
foreach ($files as $file) {
try {
$destination->put($file, $source->get($file));
if (! $destination->exists($file)) {
$failed[] = $file;
}
} catch (Throwable $e) {
$failed[] = $file;
$this->newLine();
$this->warn("上傳失敗:{$file}{$e->getMessage()}");
}
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$succeeded = count($files) - count($failed);
$this->info(sprintf('完成:%d/%d 個檔案上傳成功。', $succeeded, count($files)));
if (! empty($failed)) {
$this->error('以下檔案上傳失敗,本機檔案未刪除,可重新執行本指令補上傳:');
foreach ($failed as $file) {
$this->line(" - {$file}");
}
return self::FAILURE;
}
return self::SUCCESS;
}
}
+1
View File
@@ -13,6 +13,7 @@
"laravel/sanctum": "^4.1",
"laravel/socialite": "^5.20",
"laravel/tinker": "^2.9",
"league/flysystem-aws-s3-v3": "^3.35",
"predis/predis": "^3.4",
"sentry/sentry-laravel": "^4.25"
},
Generated
+344 -2
View File
@@ -4,8 +4,159 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d2ea97103ddb23bb4bc532f34d4a2297",
"content-hash": "2ecac4c7d6ab5ffb62eeb2a4c6c69cb1",
"packages": [
{
"name": "aws/aws-crt-php",
"version": "v1.2.7",
"source": {
"type": "git",
"url": "https://github.com/awslabs/aws-crt-php.git",
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
"shasum": ""
},
"require": {
"php": ">=5.5"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
"yoast/phpunit-polyfills": "^1.0"
},
"suggest": {
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
},
"type": "library",
"autoload": {
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "AWS SDK Common Runtime Team",
"email": "aws-sdk-common-runtime@amazon.com"
}
],
"description": "AWS Common Runtime for PHP",
"homepage": "https://github.com/awslabs/aws-crt-php",
"keywords": [
"amazon",
"aws",
"crt",
"sdk"
],
"support": {
"issues": "https://github.com/awslabs/aws-crt-php/issues",
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
},
"time": "2024-10-18T22:15:13+00:00"
},
{
"name": "aws/aws-sdk-php",
"version": "3.390.2",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
"reference": "a09e95bf062e15b7343f9c1362fd45c7a0dec39b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/a09e95bf062e15b7343f9c1362fd45c7a0dec39b",
"reference": "a09e95bf062e15b7343f9c1362fd45c7a0dec39b",
"shasum": ""
},
"require": {
"aws/aws-crt-php": "^1.2.3",
"ext-json": "*",
"ext-pcre": "*",
"ext-simplexml": "*",
"guzzlehttp/guzzle": "^7.8.2 || ^8.0",
"guzzlehttp/promises": "^2.0.3 || ^3.0",
"guzzlehttp/psr7": "^2.6.3 || ^3.0",
"mtdowling/jmespath.php": "^2.9.1",
"php": ">=8.1",
"psr/http-message": "^1.0 || ^2.0",
"symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
},
"require-dev": {
"andrewsville/php-token-reflection": "^1.4",
"aws/aws-php-sns-message-validator": "~1.0",
"behat/behat": "~3.0",
"composer/composer": "^2.7.8",
"dms/phpunit-arraysubset-asserts": "^v0.5.0",
"doctrine/cache": "~1.4",
"ext-dom": "*",
"ext-openssl": "*",
"ext-sockets": "*",
"phpunit/phpunit": "^10.0",
"psr/cache": "^2.0 || ^3.0",
"psr/simple-cache": "^2.0 || ^3.0",
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
"yoast/phpunit-polyfills": "^2.0"
},
"suggest": {
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
"doctrine/cache": "To use the DoctrineCacheAdapter",
"ext-curl": "To send requests using cURL",
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
"ext-pcntl": "To use client-side monitoring",
"ext-sockets": "To use client-side monitoring"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Aws\\": "src/"
},
"exclude-from-classmap": [
"src/data/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Amazon Web Services",
"homepage": "https://aws.amazon.com"
}
],
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
"homepage": "https://aws.amazon.com/sdk-for-php",
"keywords": [
"amazon",
"aws",
"cloud",
"dynamodb",
"ec2",
"glacier",
"s3",
"sdk"
],
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.390.2"
},
"time": "2026-07-31T18:06:07+00:00"
},
{
"name": "brick/math",
"version": "0.12.3",
@@ -2543,6 +2694,61 @@
},
"time": "2024-10-08T08:58:34+00:00"
},
{
"name": "league/flysystem-aws-s3-v3",
"version": "3.35.2",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
"reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/8475ef9adfc6498b85469e2abec6fe3118cd08c4",
"reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4",
"shasum": ""
},
"require": {
"aws/aws-sdk-php": "^3.371.5",
"league/flysystem": "^3.10.0",
"league/mime-type-detection": "^1.0.0",
"php": "^8.0.2"
},
"conflict": {
"guzzlehttp/guzzle": "<7.0",
"guzzlehttp/ringphp": "<1.1.1"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\Flysystem\\AwsS3V3\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frankdejonge.nl"
}
],
"description": "AWS S3 filesystem adapter for Flysystem.",
"keywords": [
"Flysystem",
"aws",
"file",
"files",
"filesystem",
"s3",
"storage"
],
"support": {
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.2"
},
"time": "2026-07-01T23:25:49+00:00"
},
{
"name": "league/flysystem-local",
"version": "3.29.0",
@@ -3001,6 +3207,72 @@
],
"time": "2025-03-24T10:02:05+00:00"
},
{
"name": "mtdowling/jmespath.php",
"version": "2.9.2",
"source": {
"type": "git",
"url": "https://github.com/jmespath/jmespath.php.git",
"reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/2157c5e50e813ec6a96c1eed3be7f64a20fb32a8",
"reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"symfony/polyfill-mbstring": "^1.17"
},
"require-dev": {
"composer/xdebug-handler": "^3.0.3",
"phpunit/phpunit": "^8.5.52"
},
"bin": [
"bin/jp.php"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.9-dev"
}
},
"autoload": {
"files": [
"src/JmesPath.php"
],
"psr-4": {
"JmesPath\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
}
],
"description": "Declaratively specify how to extract elements from a JSON document",
"keywords": [
"json",
"jsonpath"
],
"support": {
"issues": "https://github.com/jmespath/jmespath.php/issues",
"source": "https://github.com/jmespath/jmespath.php/tree/2.9.2"
},
"time": "2026-07-06T18:56:19+00:00"
},
{
"name": "nesbot/carbon",
"version": "3.9.1",
@@ -6021,6 +6293,76 @@
],
"time": "2024-09-25T14:20:29+00:00"
},
{
"name": "symfony/filesystem",
"version": "v7.4.15",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
"reference": "ff16a16bf87fdf264638b8f6995b3515975e3c79"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/ff16a16bf87fdf264638b8f6995b3515975e3c79",
"reference": "ff16a16bf87fdf264638b8f6995b3515975e3c79",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-mbstring": "~1.8"
},
"require-dev": {
"symfony/process": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Filesystem\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/filesystem/tree/v7.4.15"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-07-22T07:36:05+00:00"
},
{
"name": "symfony/finder",
"version": "v7.2.2",
@@ -10768,5 +11110,5 @@
"php": "^8.2"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
"plugin-api-version": "2.6.0"
}
+12 -2
View File
@@ -36,12 +36,22 @@ return [
'throw' => false,
],
// 注意:本專案所有上傳程式碼都寫死 Storage::disk('public'),不吃 'default' disk。
// 因此這裡讓 'public' disk 的 driver 直接讀 FILESYSTEM_DISK,而非新增一顆獨立的
// 's3' disk 再改呼叫點——FILESYSTEM_DISK=local 時以下 S3 專屬欄位會被忽略。
// 詳見 openspec/changes/cloud-ready-s3-storage/design.md 的 D1。
'public' => [
'driver' => 'local',
'driver' => env('FILESYSTEM_DISK', 'local'),
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'url' => env('AWS_URL') ?: env('APP_URL').'/storage',
'visibility' => 'public',
'throw' => false,
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
],
's3' => [
@@ -1,21 +1,21 @@
## 1. 依賴與設定 [後端]
- [ ] 1.1 [後端] `composer require league/flysystem-aws-s3-v3`
- [ ] 1.2 [後端] `config/filesystems.php``public` disk 改為 D1 設計(`driver``env('FILESYSTEM_DISK', 'local')`,補上 `key`/`secret`/`region`/`bucket`/`endpoint`/`use_path_style_endpoint`),並加註解說明此專案對 `FILESYSTEM_DISK` 的特殊用法
- [ ] 1.3 [後端] `.env.example``FILESYSTEM_DISK` 保留 `local` 預設,更新行內註解;補齊 `AWS_ENDPOINT``AWS_URL``AWS_USE_PATH_STYLE_ENDPOINT` 三個變數與說明
- [x] 1.1 [後端] `composer require league/flysystem-aws-s3-v3`
- [x] 1.2 [後端] `config/filesystems.php``public` disk 改為 D1 設計(`driver``env('FILESYSTEM_DISK', 'local')`,補上 `key`/`secret`/`region`/`bucket`/`endpoint`/`use_path_style_endpoint`),並加註解說明此專案對 `FILESYSTEM_DISK` 的特殊用法
- [x] 1.3 [後端] `.env.example``FILESYSTEM_DISK` 保留 `local` 預設,更新行內註解;補齊 `AWS_ENDPOINT``AWS_URL``AWS_USE_PATH_STYLE_ENDPOINT` 三個變數與說明
## 2. 遷移 Command [後端]
- [ ] 2.1 [後端] 新增 `app/Console/Commands/MigrateStorageToS3.php``storage:migrate-to-s3` 指令),掃描 `storage/app/public/` 並上傳至 `Storage::disk('s3')`,保留相對路徑,排除 `storage:link` 符號連結
- [ ] 2.2 [後端] 實作 `--dry-run` 參數:只列出檔案清單與總數,不執行上傳
- [ ] 2.3 [後端] 上傳後用 `Storage::disk('s3')->exists()` 驗證,失敗檔案收集成清單並於結尾印出,command 以非 0 exit code 結束(若有失敗)
- [ ] 2.4 [後端] 確認 command 不刪除任何本機檔案(無論成功或失敗)
- [x] 2.1 [後端] 新增 `app/Console/Commands/MigrateStorageToS3.php``storage:migrate-to-s3` 指令),掃描 `storage/app/public/` 並上傳至 `Storage::disk('s3')`,保留相對路徑,排除 `storage:link` 符號連結
- [x] 2.2 [後端] 實作 `--dry-run` 參數:只列出檔案清單與總數,不執行上傳
- [x] 2.3 [後端] 上傳後用 `Storage::disk('s3')->exists()` 驗證,失敗檔案收集成清單並於結尾印出,command 以非 0 exit code 結束(若有失敗)
- [x] 2.4 [後端] 確認 command 不刪除任何本機檔案(無論成功或失敗)
## 3. 測試 [後端]
- [ ] 3.1 [後端] `MigrateStorageToS3` command 測試:正常上傳、`--dry-run` 不執行上傳、部分失敗時正確回報且不中斷
- [ ] 3.2 [後端] `config/filesystems.php` 改動後,既有 `CourseImageTest``ProviderCertification` 相關測試在 `FILESYSTEM_DISK=local`(預設)下全數通過,確認零回歸
- [ ] 3.3 [後端] 新增測試驗證 `FILESYSTEM_DISK=local` 與未設定時行為一致(呼叫 `Storage::disk('public')` 不需任何 AWS credentials 即可運作)
- [x] 3.1 [後端] `MigrateStorageToS3` command 測試:正常上傳、`--dry-run` 不執行上傳、部分失敗時正確回報且不中斷
- [x] 3.2 [後端] `config/filesystems.php` 改動後,既有 `CourseImageTest``ProviderCertification` 相關測試在 `FILESYSTEM_DISK=local`(預設)下全數通過,確認零回歸
- [x] 3.3 [後端] 新增測試驗證 `FILESYSTEM_DISK=local` 與未設定時行為一致(呼叫 `Storage::disk('public')` 不需任何 AWS credentials 即可運作)
## 4. R2 環境準備 [整合測試]
@@ -0,0 +1,43 @@
<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class FilesystemPublicDiskTest extends TestCase
{
protected function tearDown(): void
{
@unlink(storage_path('app/public/filesystem-public-disk-test.txt'));
parent::tearDown();
}
public function test_public_disk_works_on_local_driver_without_aws_credentials(): void
{
Config::set('filesystems.disks.public.driver', 'local');
Config::set('filesystems.disks.public.key', null);
Config::set('filesystems.disks.public.secret', null);
Config::set('filesystems.disks.public.region', null);
Config::set('filesystems.disks.public.bucket', null);
Config::set('filesystems.disks.public.endpoint', null);
Storage::forgetDisk('public');
Storage::disk('public')->put('filesystem-public-disk-test.txt', 'ok');
$this->assertTrue(Storage::disk('public')->exists('filesystem-public-disk-test.txt'));
Storage::disk('public')->delete('filesystem-public-disk-test.txt');
}
public function test_public_disk_driver_follows_filesystem_disk_env(): void
{
$this->assertSame(
env('FILESYSTEM_DISK', 'local'),
config('filesystems.disks.public.driver'),
'public disk 的 driver 應完全跟隨 FILESYSTEM_DISK env(未設定時預設 local'
);
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\Storage;
use Mockery;
use RuntimeException;
use Tests\TestCase;
class MigrateStorageToS3Test extends TestCase
{
private array $createdFiles = [];
protected function tearDown(): void
{
foreach ($this->createdFiles as $relativePath) {
@unlink(storage_path('app/public/'.$relativePath));
}
$this->createdFiles = [];
parent::tearDown();
}
private function putLocalFile(string $relativePath, string $contents = 'test-content'): void
{
$fullPath = storage_path('app/public/'.$relativePath);
@mkdir(dirname($fullPath), 0777, true);
file_put_contents($fullPath, $contents);
$this->createdFiles[] = $relativePath;
}
public function test_uploads_existing_local_files_to_s3(): void
{
Storage::fake('s3');
$this->putLocalFile('migrate-test/one.txt', 'file-one');
$this->putLocalFile('migrate-test/two.txt', 'file-two');
$this->artisan('storage:migrate-to-s3')->assertSuccessful();
Storage::disk('s3')->assertExists('migrate-test/one.txt');
Storage::disk('s3')->assertExists('migrate-test/two.txt');
}
public function test_dry_run_lists_files_without_uploading(): void
{
Storage::fake('s3');
$this->putLocalFile('migrate-test/dry.txt');
$this->artisan('storage:migrate-to-s3', ['--dry-run' => true])->assertSuccessful();
Storage::disk('s3')->assertMissing('migrate-test/dry.txt');
}
public function test_no_files_to_migrate_still_succeeds(): void
{
Storage::fake('s3');
$this->artisan('storage:migrate-to-s3')->assertSuccessful();
}
public function test_reports_failure_and_does_not_stop_on_partial_upload_error(): void
{
$this->putLocalFile('migrate-test/ok.txt', 'ok');
$this->putLocalFile('migrate-test/bad.txt', 'bad');
// storage/app/public 是共用的本機目錄,可能已存在其他測試無關的檔案(如手動測試留下的
// 上傳圖片),所以這裡不斷言呼叫次數,只鎖定我們自己建立的兩個檔案的行為,其餘路徑一律放行。
$mockDisk = Mockery::mock();
$mockDisk->shouldReceive('put')
->with('migrate-test/bad.txt', Mockery::any())
->once()
->andThrow(new RuntimeException('simulated network failure'));
$mockDisk->shouldReceive('put')
->withArgs(fn (string $path) => $path !== 'migrate-test/bad.txt')
->andReturn(true);
$mockDisk->shouldReceive('exists')->andReturn(true);
Storage::partialMock()->shouldReceive('disk')->with('s3')->andReturn($mockDisk);
$this->artisan('storage:migrate-to-s3')->assertFailed();
}
}