Files
CFDivePlatform/tests/Feature/CourseImageTest.php
T
a620906209 6414222a18
Run Tests / test (pull_request) Successful in 2m0s
feat(images): 課程圖片伺服器端壓縮——scaleDown 2048 + JPEG 85,上限放寬至 10MB
實作 openspec change course-image-compression(O3.1 體感優化):
- 新增 App\Traits\CompressesImages:與聊天圖片管線參數一致的共用壓縮邏輯
- CourseImageController 封面/相簿套用壓縮,上傳上限 2MB→10MB(容納手機原圖)
- 前端課程卡片與相簿縮圖加 loading=lazy(詳情頁首屏封面刻意不加)
- CourseImageTest +4 案例(10MB 邊界、轉存 .jpg、>2048 縮小、小圖不放大)
- course-image-upload 主規格同步(新增伺服器端壓縮 requirement)

容器內全套件 155 passed / 400 assertions。
聊天端改用 trait 留待後續(該處無測試覆蓋,不在本 change 重構)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:43:24 +08:00

293 lines
10 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(11000),
])->assertStatus(422);
}
public function test_upload_cover_accepts_phone_sized_original(): void
{
// 原上限 2MB 會擋掉手機原圖;放寬至 10MB 後 5MB 應通過(伺服器端會壓縮)
Storage::fake('public');
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$this->actingAs($provider)
->postJson("/api/provider/offers/{$offer->id}/cover", [
'image' => UploadedFile::fake()->image('phone.jpg', 800, 600)->size(5000),
])->assertOk();
}
public function test_uploaded_cover_is_stored_as_jpeg(): 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('photo.png', 400, 300),
])->assertOk();
$path = $offer->fresh()->cover_image;
$this->assertStringEndsWith('.jpg', $path);
Storage::disk('public')->assertExists($path);
}
public function test_oversized_cover_is_scaled_down_to_2048(): void
{
// 2048px 上限保護的是課程列表頁的載入重量:原圖直存會讓手機照片
// (數 MB)直接進列表,壓縮管線是 O3.1 體感優化的核心
Storage::fake('public');
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$this->actingAs($provider)
->postJson("/api/provider/offers/{$offer->id}/cover", [
'image' => UploadedFile::fake()->image('huge.jpg', 3000, 2500),
])->assertOk();
$stored = Storage::disk('public')->get($offer->fresh()->cover_image);
[$width, $height] = getimagesizefromstring($stored);
$this->assertLessThanOrEqual(2048, $width);
$this->assertLessThanOrEqual(2048, $height);
}
public function test_small_image_is_not_upscaled(): 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('small.jpg', 640, 480),
])->assertOk();
$stored = Storage::disk('public')->get($offer->fresh()->cover_image);
[$width, $height] = getimagesizefromstring($stored);
$this->assertSame(640, $width);
$this->assertSame(480, $height);
}
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}");
}
}