Files
a620906209 4baa4cb52b feat:實作課程圖片上傳 — 封面 + 相簿管理
後端:
- Migration:diving_offers 新增 cover_image 欄位、新增 course_images 表(含索引)
- CourseImage Model(CREATED_AT、url accessor)
- DivingOffer:cover_image_url accessor、hasMany courseImages、static::deleting() 孤兒清理
- CourseImageController:封面上傳/刪除、相簿上傳(max 3)/刪除,統一 mimes+size 驗證
- DivingOfferController:index/show 回傳加入 cover_image_url 與 images 陣列
- 修正 APP_URL 加入 port(:8080),Storage::url() 才能產生正確圖片連結

前端:
- courseImageApi.js:uploadCover/deleteCover/uploadImage/deleteImage
- CourseCard:有封面顯示 <img>,無封面顯示漸層佔位
- CourseDetailView:封面大圖 + 相簿縮圖橫列(點擊開新分頁)
- OfferFormView(編輯模式):封面預覽/更換/刪除、相簿縮圖管理(達 3 張隱藏上傳按鈕)

基礎設施:
- docker-entrypoint.sh:加入 storage:link --force
- docker-compose.yml:移除 storage-data named volume(改用 bind mount,避免 Nginx 讀不到圖片)

測試:
- CourseImageTest.php:14 個 Feature Test 全部 PASS(Storage::fake)
  涵蓋:上傳成功/格式驗證/大小驗證/所有權、刪除/無封面不報錯、
        相簿上限/sort_order 遞增、孤兒清理

OpenSpec:
- course-images change 歸檔至 archive/2026-05-12-course-images
- 新增 specs/course-image-upload 主規格(含 bind mount 持久化說明)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 03:54:45 +08:00

228 lines
7.6 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\CourseImage;
use App\Models\DivingOffer;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class CourseImageTest extends TestCase
{
use RefreshDatabase;
private function makeProvider(): User
{
return User::factory()->create(['role' => 'provider']);
}
private function makeOffer(User $provider): DivingOffer
{
return DivingOffer::create([
'provider_id' => $provider->id,
'title' => 'Test Course',
'location' => 'Test',
'spot' => 'Test Spot',
'price' => 1000,
'region' => '南部',
'rating' => 0,
'reviews' => 0,
]);
}
private function fakeImage(string $name = 'test.png'): UploadedFile
{
return UploadedFile::fake()->image($name, 100, 100)->size(500);
}
public function test_upload_cover_success(): void
{
Storage::fake('public');
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$this->actingAs($provider)
->postJson("/api/provider/offers/{$offer->id}/cover", ['image' => $this->fakeImage()])
->assertOk()->assertJsonPath('status', true);
$offer->refresh();
$this->assertNotNull($offer->cover_image);
Storage::disk('public')->assertExists($offer->cover_image);
}
public function test_upload_cover_wrong_mime(): void
{
Storage::fake('public');
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$this->actingAs($provider)
->postJson("/api/provider/offers/{$offer->id}/cover", [
'image' => UploadedFile::fake()->create('doc.pdf', 100, 'application/pdf'),
])->assertStatus(422);
}
public function test_upload_cover_too_large(): void
{
Storage::fake('public');
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$this->actingAs($provider)
->postJson("/api/provider/offers/{$offer->id}/cover", [
'image' => UploadedFile::fake()->image('big.png')->size(3000),
])->assertStatus(422);
}
public function test_upload_cover_forbidden_for_other_provider(): void
{
Storage::fake('public');
$offer = $this->makeOffer($this->makeProvider());
$this->actingAs($this->makeProvider())
->postJson("/api/provider/offers/{$offer->id}/cover", ['image' => $this->fakeImage()])
->assertStatus(403);
}
public function test_delete_cover_removes_file(): void
{
Storage::fake('public');
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$this->actingAs($provider)
->postJson("/api/provider/offers/{$offer->id}/cover", ['image' => $this->fakeImage()]);
$offer->refresh();
$oldPath = $offer->cover_image;
$this->actingAs($provider)
->deleteJson("/api/provider/offers/{$offer->id}/cover")
->assertOk();
Storage::disk('public')->assertMissing($oldPath);
$this->assertNull($offer->fresh()->cover_image);
}
public function test_delete_cover_when_no_cover_is_ok(): void
{
Storage::fake('public');
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$this->actingAs($provider)
->deleteJson("/api/provider/offers/{$offer->id}/cover")
->assertOk();
}
public function test_delete_cover_forbidden_for_other_provider(): void
{
Storage::fake('public');
$offer = $this->makeOffer($this->makeProvider());
$this->actingAs($this->makeProvider())
->deleteJson("/api/provider/offers/{$offer->id}/cover")
->assertStatus(403);
}
public function test_upload_gallery_image_success(): void
{
Storage::fake('public');
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$this->actingAs($provider)
->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()])
->assertStatus(201);
$this->assertDatabaseCount('course_images', 1);
}
public function test_gallery_max_3_images(): void
{
Storage::fake('public');
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
for ($i = 0; $i < 3; $i++) {
$this->actingAs($provider)
->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()]);
}
$this->actingAs($provider)
->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()])
->assertStatus(422);
}
public function test_gallery_sort_order_increments(): void
{
Storage::fake('public');
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$r1 = $this->actingAs($provider)->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()]);
$r2 = $this->actingAs($provider)->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()]);
$this->assertEquals(1, $r1->json('data.sort_order'));
$this->assertEquals(2, $r2->json('data.sort_order'));
}
public function test_upload_image_forbidden_for_other_provider(): void
{
Storage::fake('public');
$offer = $this->makeOffer($this->makeProvider());
$this->actingAs($this->makeProvider())
->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()])
->assertStatus(403);
}
public function test_delete_gallery_image_removes_file(): void
{
Storage::fake('public');
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$res = $this->actingAs($provider)->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()]);
$imgId = $res->json('data.id');
$path = CourseImage::find($imgId)->image_path;
$this->actingAs($provider)->deleteJson("/api/provider/images/{$imgId}")->assertOk();
$this->assertDatabaseMissing('course_images', ['id' => $imgId]);
Storage::disk('public')->assertMissing($path);
}
public function test_delete_image_forbidden_for_other_provider(): void
{
Storage::fake('public');
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$res = $this->actingAs($provider)->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()]);
$imgId = $res->json('data.id');
$this->actingAs($this->makeProvider())
->deleteJson("/api/provider/images/{$imgId}")
->assertStatus(403);
}
public function test_deleting_offer_removes_storage_directory(): void
{
Storage::fake('public');
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$this->actingAs($provider)->postJson("/api/provider/offers/{$offer->id}/cover", ['image' => $this->fakeImage()]);
$this->actingAs($provider)->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()]);
$offerId = $offer->id;
$offer->delete();
Storage::disk('public')->assertDirectoryEmpty("offers/{$offerId}");
}
}