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>
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
<?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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user