6414222a18
Run Tests / test (pull_request) Successful in 2m0s
實作 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>
293 lines
10 KiB
PHP
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}");
|
|
}
|
|
}
|
|
|