test(booking): 補預約核心測試——狀態機/防超賣/Scheduler/聊天授權/Admin 權限邊界
稽核 P2-1:平台核心業務(預約)原本零測試覆蓋。 - BookingLifecycleTest:七狀態機合法/非法轉移、名額帳務、24h 取消截止、授權邊界(17 案例) - BookingOversellTest:confirm 時 lockForUpdate 防超賣不變式、名額釋放回收(3 案例) - BookingSchedulerTest:48h 過期與日期完成的時間邊界(6 案例) - BookingChatAuthTest:HTTP 與 presence channel 雙防線(8 案例) - AdminEndpointAuthTest:非 admin 一律 403、Admin 不可繞過狀態機(5 案例) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
<?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\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Admin 端點權限邊界(admin-* 規格群)。
|
||||
*
|
||||
* /api/admin/* 可停權用戶、刪除課程與評價、讀取全平台個資,
|
||||
* 任何非 admin 角色(含已登入的 member/provider)都必須被 EnsureAdmin
|
||||
* middleware 擋在 403,未認證請求擋在 401。
|
||||
*/
|
||||
class AdminEndpointAuthTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const ADMIN_GET_ENDPOINTS = [
|
||||
'/api/admin/stats',
|
||||
'/api/admin/members',
|
||||
'/api/admin/providers',
|
||||
'/api/admin/offers',
|
||||
'/api/admin/bookings',
|
||||
'/api/admin/reviews',
|
||||
];
|
||||
|
||||
public function test_member_token_is_rejected_on_all_admin_endpoints(): void
|
||||
{
|
||||
$member = User::factory()->create(['role' => 'member']);
|
||||
|
||||
foreach (self::ADMIN_GET_ENDPOINTS as $endpoint) {
|
||||
$this->actingAs($member)->getJson($endpoint)->assertStatus(403);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_provider_token_is_rejected_on_all_admin_endpoints(): void
|
||||
{
|
||||
$provider = User::factory()->create(['role' => 'provider']);
|
||||
|
||||
foreach (self::ADMIN_GET_ENDPOINTS as $endpoint) {
|
||||
$this->actingAs($provider)->getJson($endpoint)->assertStatus(403);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_unauthenticated_request_is_rejected_on_all_admin_endpoints(): void
|
||||
{
|
||||
foreach (self::ADMIN_GET_ENDPOINTS as $endpoint) {
|
||||
$this->getJson($endpoint)->assertStatus(401);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_admin_token_can_access_admin_endpoints(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
foreach (self::ADMIN_GET_ENDPOINTS as $endpoint) {
|
||||
$this->actingAs($admin)->getJson($endpoint)->assertOk();
|
||||
}
|
||||
}
|
||||
|
||||
public function test_admin_complete_follows_booking_state_machine(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$provider = User::factory()->create(['role' => 'provider']);
|
||||
$member = User::factory()->create(['role' => 'member']);
|
||||
|
||||
$offer = DivingOffer::create([
|
||||
'provider_id' => $provider->id,
|
||||
'title' => 'Admin 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()->subDay()->toDateString(),
|
||||
'start_time' => '09:00',
|
||||
'max_participants' => 4,
|
||||
'current_participants' => 1,
|
||||
'status' => ScheduleStatus::Open,
|
||||
]);
|
||||
|
||||
$confirmed = Booking::create([
|
||||
'schedule_id' => $schedule->id,
|
||||
'member_id' => $member->id,
|
||||
'participants' => 1,
|
||||
'total_price' => 1000,
|
||||
'status' => BookingStatus::Confirmed,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->putJson("/api/admin/bookings/{$confirmed->id}/complete")
|
||||
->assertOk();
|
||||
$this->assertSame(BookingStatus::Completed, $confirmed->fresh()->status);
|
||||
|
||||
// Admin 也不能繞過狀態機:rejected 是終態
|
||||
$rejected = Booking::create([
|
||||
'schedule_id' => $schedule->id,
|
||||
'member_id' => User::factory()->create(['role' => 'member'])->id,
|
||||
'participants' => 1,
|
||||
'total_price' => 1000,
|
||||
'status' => BookingStatus::Rejected,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->putJson("/api/admin/bookings/{$rejected->id}/complete")
|
||||
->assertStatus(422);
|
||||
$this->assertSame(BookingStatus::Rejected, $rejected->fresh()->status);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user