Files
CFDivePlatform/tests/Feature/BookingOversellTest.php
T
a620906209 43720482c4
Run Tests / test (pull_request) Successful in 2m3s
feat(verification): 教練審核流程完整版——狀態機/證照送審/Admin 裁決/通知
實作 openspec change provider-verification-workflow(25/25 tasks):
- is_verified 升級為 verification_status 四狀態機(unsubmitted/pending/
  approved/rejected),migration 自動轉換既有資料,API 以 accessor 保留
  is_verified 相容輸出
- 教練端:證照上傳(≤3 張、複用 CompressesImages)、送審、駁回原因
  顯示與重送(/coach/verification 新頁面)
- Admin 端:審核佇列、通過/駁回(原因必填)、撤銷 approved;移除
  toggle-verified 端點(防繞過狀態機);裁決後 flush 課程快取
- 通知:審核通過/駁回(站內+Email,駁回含原因)
- 可見性與預約入口改判 approved(行為等價)
- 測試 +18(ProviderVerificationTest 10、AdminVerificationTest 8),
  既有 4 個測試檔 helper 遷移;Swagger 同步
- 規格同步:provider-verification 全面改寫、admin-user-management
  toggle 段落改為移除聲明

容器內全套件 173 passed / 439 assertions。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:26:12 +08:00

150 lines
5.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Tests\Feature;
use App\Enums\BookingStatus;
use App\Enums\ScheduleStatus;
use App\Models\Booking;
use App\Models\CourseSchedule;
use App\Models\DivingOffer;
use App\Models\ProviderProfile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
/**
* 防超賣不變式:confirmed 預約的總人數永遠不得超過時段容量。
*
* pending 不佔名額是刻意設計(教練尚未承諾),因此同一時段允許
* pending 總和超過容量——超賣防線在 confirm 時的 lockForUpdate 二次
* 驗證。這裡的測試保護的是金錢與信任:超賣等於收了無法履行的預約。
*/
class BookingOversellTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Notification::fake();
}
private function makeProviderWithSchedule(int $maxParticipants): array
{
$provider = User::factory()->create(['role' => 'provider']);
ProviderProfile::create([
'user_id' => $provider->id,
'verification_status' => 'approved',
]);
$offer = DivingOffer::create([
'provider_id' => $provider->id,
'title' => 'Oversell Course',
'location' => 'Test',
'spot' => 'Test Spot',
'price' => 1000,
'region' => '南部',
'rating' => 0,
'reviews' => 0,
]);
$schedule = CourseSchedule::create([
'diving_offer_id' => $offer->id,
'provider_id' => $provider->id,
'scheduled_date' => now()->addDays(7)->toDateString(),
'start_time' => '09:00',
'max_participants' => $maxParticipants,
'current_participants' => 0,
'status' => ScheduleStatus::Open,
]);
return [$provider, $schedule];
}
private function makePendingBooking(CourseSchedule $schedule, int $participants): Booking
{
return Booking::create([
'schedule_id' => $schedule->id,
'member_id' => User::factory()->create(['role' => 'member'])->id,
'participants' => $participants,
'total_price' => 1000 * $participants,
'status' => BookingStatus::Pending,
]);
}
public function test_multiple_pendings_may_exceed_capacity_but_confirm_is_gated(): void
{
[$provider, $schedule] = $this->makeProviderWithSchedule(2);
// pending 總和 3 > 容量 2allowed by design
$first = $this->makePendingBooking($schedule, 2);
$second = $this->makePendingBooking($schedule, 1);
// 確認第一筆:佔滿容量
$this->actingAs($provider)
->putJson("/api/provider/bookings/{$first->id}/confirm")
->assertOk();
// 確認第二筆:名額不足,必須失敗
$this->actingAs($provider)
->putJson("/api/provider/bookings/{$second->id}/confirm")
->assertStatus(422);
$schedule->refresh();
$this->assertSame(2, $schedule->current_participants);
$this->assertSame(BookingStatus::Pending, $second->fresh()->status);
// 不變式:confirmed 總人數 <= 容量
$confirmedTotal = Booking::where('schedule_id', $schedule->id)
->where('status', BookingStatus::Confirmed->value)
->sum('participants');
$this->assertLessThanOrEqual($schedule->max_participants, $confirmedTotal);
}
public function test_full_schedule_rejects_new_booking_requests(): void
{
[$provider, $schedule] = $this->makeProviderWithSchedule(2);
$booking = $this->makePendingBooking($schedule, 2);
$this->actingAs($provider)
->putJson("/api/provider/bookings/{$booking->id}/confirm")
->assertOk();
// 滿員後時段轉 full,新預約在 Layer 1 即被擋下
$this->assertSame(ScheduleStatus::Full, $schedule->fresh()->status);
$this->actingAs(User::factory()->create(['role' => 'member']))
->postJson('/api/member/bookings', [
'schedule_id' => $schedule->id,
'participants' => 1,
])->assertStatus(422);
}
public function test_released_spots_can_be_confirmed_again(): void
{
[$provider, $schedule] = $this->makeProviderWithSchedule(2);
$first = $this->makePendingBooking($schedule, 2);
$second = $this->makePendingBooking($schedule, 2);
$this->actingAs($provider)
->putJson("/api/provider/bookings/{$first->id}/confirm")
->assertOk();
// 教練取消第一筆 → 名額釋放、full 轉回 open
$this->actingAs($provider)
->putJson("/api/provider/bookings/{$first->id}/cancel")
->assertOk();
// 釋放後第二筆可確認
$this->actingAs($provider)
->putJson("/api/provider/bookings/{$second->id}/confirm")
->assertOk();
$schedule->refresh();
$this->assertSame(2, $schedule->current_participants);
$this->assertSame(BookingStatus::Confirmed, $second->fresh()->status);
}
}