test(feature): 補齊五個核心業務流程 Feature test——Offer/Schedule CRUD、Admin 管理、通知觸發、預約列表
Run Tests / test (pull_request) Successful in 1m51s
Run Tests / test (pull_request) Successful in 1m51s
ProviderOfferCrudTest(7 案例):store/update/destroy 正常流程 + 所有權邊界(403)+ 未認證(401) ProviderScheduleCrudTest(7 案例):store/update/destroy + 容量不變式(422)+ destroy 同步取消 active bookings AdminUserManagementTest(5 案例):列表角色過濾 + toggle-active/toggle-verified DB 副作用 + 非 admin 403 NotificationTriggerTest(5 案例):5 條直接觸發路徑,收件者各不同(BookingCreated/CancelledByMember → provider;其餘 → member) BookingListTest(4 案例):三角色資料隔離邊界 + 未認證 401 全套件 215 passed / 529 assertions,零 regression。 同步新增 openspec/changes/feature-test-coverage/ 與 openspec/specs/feature-test-coverage/spec.md。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\MemberProfile;
|
||||
use App\Models\ProviderProfile;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Admin 使用者管理(admin-user-management 規格)。
|
||||
*
|
||||
* 列表端點必須依 role 過濾,否則 admin 介面會混入不同角色資料;
|
||||
* toggle-verified 的快取清除副作用已由 DivingOfferVisibilityTest 覆蓋,
|
||||
* 這裡只驗資料庫欄位變更與 HTTP 200。
|
||||
*/
|
||||
class AdminUserManagementTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function makeAdmin(): User
|
||||
{
|
||||
return User::factory()->create(['role' => 'admin']);
|
||||
}
|
||||
|
||||
private function makeMember(): User
|
||||
{
|
||||
$member = User::factory()->create(['role' => 'member']);
|
||||
MemberProfile::create(['user_id' => $member->id]);
|
||||
return $member;
|
||||
}
|
||||
|
||||
private function makeProvider(bool $isVerified = false): User
|
||||
{
|
||||
$provider = User::factory()->create(['role' => 'provider']);
|
||||
ProviderProfile::create(['user_id' => $provider->id, 'is_verified' => $isVerified]);
|
||||
return $provider;
|
||||
}
|
||||
|
||||
// ── 列表 ─────────────────────────────────────────────────
|
||||
|
||||
public function test_admin_lists_members_only(): void
|
||||
{
|
||||
$admin = $this->makeAdmin();
|
||||
$member = $this->makeMember();
|
||||
$provider = $this->makeProvider();
|
||||
|
||||
$response = $this->actingAs($admin)->getJson('/api/admin/members');
|
||||
|
||||
$response->assertOk()->assertJsonPath('status', true);
|
||||
|
||||
$ids = array_column($response->json('data'), 'id');
|
||||
$this->assertContains($member->id, $ids);
|
||||
$this->assertNotContains($provider->id, $ids);
|
||||
}
|
||||
|
||||
public function test_admin_lists_providers_only(): void
|
||||
{
|
||||
$admin = $this->makeAdmin();
|
||||
$member = $this->makeMember();
|
||||
$provider = $this->makeProvider();
|
||||
|
||||
$response = $this->actingAs($admin)->getJson('/api/admin/providers');
|
||||
|
||||
$response->assertOk()->assertJsonPath('status', true);
|
||||
|
||||
$ids = array_column($response->json('data'), 'id');
|
||||
$this->assertContains($provider->id, $ids);
|
||||
$this->assertNotContains($member->id, $ids);
|
||||
}
|
||||
|
||||
// ── toggle-active ────────────────────────────────────────
|
||||
|
||||
public function test_admin_toggles_member_active_status(): void
|
||||
{
|
||||
$admin = $this->makeAdmin();
|
||||
// is_active 預設由 DB 決定,factory 不設值;直接測試 toggle 的翻轉效果
|
||||
$member = User::factory()->create(['role' => 'member', 'is_active' => true]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->putJson("/api/admin/members/{$member->id}/toggle-active")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.is_active', false);
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'id' => $member->id,
|
||||
'is_active' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── toggle-verified ──────────────────────────────────────
|
||||
|
||||
public function test_admin_toggles_provider_verified_status(): void
|
||||
{
|
||||
$admin = $this->makeAdmin();
|
||||
$provider = $this->makeProvider(isVerified: false);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->putJson("/api/admin/providers/{$provider->id}/toggle-verified")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.is_verified', true);
|
||||
|
||||
$this->assertDatabaseHas('provider_profiles', [
|
||||
'user_id' => $provider->id,
|
||||
'is_verified' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 角色保護 ─────────────────────────────────────────────
|
||||
|
||||
public function test_non_admin_is_forbidden(): void
|
||||
{
|
||||
$member = $this->makeMember();
|
||||
|
||||
$this->actingAs($member)
|
||||
->getJson('/api/admin/members')
|
||||
->assertStatus(403);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?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 Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 預約列表端點資料隔離(booking-lifecycle 規格)。
|
||||
*
|
||||
* 路由對應(routes/api.php 已確認):
|
||||
* GET /api/member/bookings → MemberBookingController::index(auth:sanctum,無 role check)
|
||||
* GET /api/provider/bookings → ProviderBookingController::index(auth:sanctum,無 role check)
|
||||
* GET /api/admin/bookings → AdminBookingController::index(auth:sanctum + admin middleware)
|
||||
*
|
||||
* 資料隔離靠 controller query 條件,不靠 middleware role 強制,
|
||||
* 因此測試需分別以正確角色 actingAs 驗證隔離邊界。
|
||||
*/
|
||||
class BookingListTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function makeProviderWithSchedule(): array
|
||||
{
|
||||
$provider = User::factory()->create(['role' => 'provider']);
|
||||
ProviderProfile::create(['user_id' => $provider->id, 'is_verified' => true]);
|
||||
|
||||
$offer = DivingOffer::create([
|
||||
'provider_id' => $provider->id,
|
||||
'title' => 'Dive Course',
|
||||
'location' => 'Kenting',
|
||||
'price' => 3000,
|
||||
'region' => '南部',
|
||||
'rating' => 0,
|
||||
'reviews' => 0,
|
||||
]);
|
||||
|
||||
$schedule = CourseSchedule::create([
|
||||
'diving_offer_id' => $offer->id,
|
||||
'provider_id' => $provider->id,
|
||||
'scheduled_date' => now()->addDays(30)->toDateString(),
|
||||
'start_time' => '09:00',
|
||||
'max_participants' => 10,
|
||||
'current_participants' => 0,
|
||||
'status' => ScheduleStatus::Open,
|
||||
]);
|
||||
|
||||
return [$provider, $schedule];
|
||||
}
|
||||
|
||||
private function makePendingBooking(User $member, CourseSchedule $schedule): Booking
|
||||
{
|
||||
return Booking::create([
|
||||
'schedule_id' => $schedule->id,
|
||||
'member_id' => $member->id,
|
||||
'participants' => 1,
|
||||
'total_price' => 3000,
|
||||
'status' => BookingStatus::Pending,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Member 列表 ──────────────────────────────────────────
|
||||
|
||||
public function test_member_sees_only_own_bookings(): void
|
||||
{
|
||||
[$provider, $schedule] = $this->makeProviderWithSchedule();
|
||||
$memberA = User::factory()->create(['role' => 'member']);
|
||||
$memberB = User::factory()->create(['role' => 'member']);
|
||||
$bookingA = $this->makePendingBooking($memberA, $schedule);
|
||||
$bookingB = $this->makePendingBooking($memberB, $schedule);
|
||||
|
||||
$response = $this->actingAs($memberA)->getJson('/api/member/bookings');
|
||||
|
||||
$response->assertOk();
|
||||
$ids = array_column($response->json('data'), 'id');
|
||||
$this->assertContains($bookingA->id, $ids);
|
||||
$this->assertNotContains($bookingB->id, $ids);
|
||||
}
|
||||
|
||||
// ── Provider 列表 ────────────────────────────────────────
|
||||
|
||||
public function test_provider_sees_only_own_course_bookings(): void
|
||||
{
|
||||
[$providerA, $scheduleA] = $this->makeProviderWithSchedule();
|
||||
[$providerB, $scheduleB] = $this->makeProviderWithSchedule();
|
||||
$member = User::factory()->create(['role' => 'member']);
|
||||
$bookingA = $this->makePendingBooking($member, $scheduleA);
|
||||
$bookingB = $this->makePendingBooking($member, $scheduleB);
|
||||
|
||||
$response = $this->actingAs($providerA)->getJson('/api/provider/bookings');
|
||||
|
||||
$response->assertOk();
|
||||
$ids = array_column($response->json('data'), 'id');
|
||||
$this->assertContains($bookingA->id, $ids);
|
||||
$this->assertNotContains($bookingB->id, $ids);
|
||||
}
|
||||
|
||||
// ── Admin 列表 ───────────────────────────────────────────
|
||||
|
||||
public function test_admin_sees_all_bookings(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
[$providerA, $scheduleA] = $this->makeProviderWithSchedule();
|
||||
[$providerB, $scheduleB] = $this->makeProviderWithSchedule();
|
||||
$memberA = User::factory()->create(['role' => 'member']);
|
||||
$memberB = User::factory()->create(['role' => 'member']);
|
||||
$bookingA = $this->makePendingBooking($memberA, $scheduleA);
|
||||
$bookingB = $this->makePendingBooking($memberB, $scheduleB);
|
||||
|
||||
$response = $this->actingAs($admin)->getJson('/api/admin/bookings');
|
||||
|
||||
$response->assertOk();
|
||||
$ids = array_column($response->json('data'), 'id');
|
||||
$this->assertContains($bookingA->id, $ids);
|
||||
$this->assertContains($bookingB->id, $ids);
|
||||
}
|
||||
|
||||
// ── 未認證 ───────────────────────────────────────────────
|
||||
|
||||
public function test_unauthenticated_request_returns_401(): void
|
||||
{
|
||||
$this->getJson('/api/member/bookings')->assertStatus(401);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?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 App\Notifications\BookingCancelledNotification;
|
||||
use App\Notifications\BookingConfirmedNotification;
|
||||
use App\Notifications\BookingCreatedNotification;
|
||||
use App\Notifications\BookingRejectedNotification;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 預約通知直接觸發路徑(notification-triggers 規格)。
|
||||
*
|
||||
* 補齊 BookingSchedulerTest 以外的 controller 直接觸發路徑,
|
||||
* 確保收件者正確:新預約與 member 取消通知教練,其餘通知學員。
|
||||
*
|
||||
* 收件者依 controller 原始碼確認:
|
||||
* - BookingCreated → $provider->notify()
|
||||
* - BookingConfirmed → $booking->member->notify()
|
||||
* - BookingRejected → $booking->member->notify()
|
||||
* - BookingCancelled (member) → $provider->notify()
|
||||
* - BookingCancelled (provider) → $booking->member->notify()
|
||||
*/
|
||||
class NotificationTriggerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Notification::fake();
|
||||
}
|
||||
|
||||
private function makeProvider(): User
|
||||
{
|
||||
$provider = User::factory()->create(['role' => 'provider']);
|
||||
ProviderProfile::create(['user_id' => $provider->id, 'is_verified' => true]);
|
||||
return $provider;
|
||||
}
|
||||
|
||||
private function makeSchedule(User $provider): CourseSchedule
|
||||
{
|
||||
$offer = DivingOffer::create([
|
||||
'provider_id' => $provider->id,
|
||||
'title' => 'Dive Course',
|
||||
'location' => 'Kenting',
|
||||
'price' => 3000,
|
||||
'region' => '南部',
|
||||
'rating' => 0,
|
||||
'reviews' => 0,
|
||||
]);
|
||||
|
||||
return CourseSchedule::create([
|
||||
'diving_offer_id' => $offer->id,
|
||||
'provider_id' => $provider->id,
|
||||
'scheduled_date' => now()->addDays(60)->toDateString(),
|
||||
'start_time' => '09:00',
|
||||
'max_participants' => 10,
|
||||
'current_participants' => 0,
|
||||
'status' => ScheduleStatus::Open,
|
||||
]);
|
||||
}
|
||||
|
||||
private function makePendingBooking(User $member, CourseSchedule $schedule): Booking
|
||||
{
|
||||
return Booking::create([
|
||||
'schedule_id' => $schedule->id,
|
||||
'member_id' => $member->id,
|
||||
'participants' => 1,
|
||||
'total_price' => 3000,
|
||||
'status' => BookingStatus::Pending,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 建立預約 → 通知教練 ──────────────────────────────────
|
||||
|
||||
public function test_booking_created_notifies_provider(): void
|
||||
{
|
||||
$provider = $this->makeProvider();
|
||||
$schedule = $this->makeSchedule($provider);
|
||||
$member = User::factory()->create(['role' => 'member']);
|
||||
|
||||
$this->actingAs($member)->postJson('/api/member/bookings', [
|
||||
'schedule_id' => $schedule->id,
|
||||
'participants' => 1,
|
||||
])->assertStatus(201);
|
||||
|
||||
Notification::assertSentTo($provider, BookingCreatedNotification::class);
|
||||
}
|
||||
|
||||
// ── 確認預約 → 通知學員 ──────────────────────────────────
|
||||
|
||||
public function test_booking_confirmed_notifies_member(): void
|
||||
{
|
||||
$provider = $this->makeProvider();
|
||||
$schedule = $this->makeSchedule($provider);
|
||||
$member = User::factory()->create(['role' => 'member']);
|
||||
$booking = $this->makePendingBooking($member, $schedule);
|
||||
|
||||
$this->actingAs($provider)
|
||||
->putJson("/api/provider/bookings/{$booking->id}/confirm")
|
||||
->assertOk();
|
||||
|
||||
Notification::assertSentTo($member, BookingConfirmedNotification::class);
|
||||
}
|
||||
|
||||
// ── 拒絕預約 → 通知學員 ──────────────────────────────────
|
||||
|
||||
public function test_booking_rejected_notifies_member(): void
|
||||
{
|
||||
$provider = $this->makeProvider();
|
||||
$schedule = $this->makeSchedule($provider);
|
||||
$member = User::factory()->create(['role' => 'member']);
|
||||
$booking = $this->makePendingBooking($member, $schedule);
|
||||
|
||||
$this->actingAs($provider)
|
||||
->putJson("/api/provider/bookings/{$booking->id}/reject")
|
||||
->assertOk();
|
||||
|
||||
Notification::assertSentTo($member, BookingRejectedNotification::class);
|
||||
}
|
||||
|
||||
// ── Member 取消 → 通知教練 ───────────────────────────────
|
||||
|
||||
public function test_member_cancel_notifies_provider(): void
|
||||
{
|
||||
$provider = $this->makeProvider();
|
||||
$schedule = $this->makeSchedule($provider);
|
||||
$member = User::factory()->create(['role' => 'member']);
|
||||
$booking = $this->makePendingBooking($member, $schedule);
|
||||
|
||||
$this->actingAs($member)
|
||||
->deleteJson("/api/member/bookings/{$booking->id}")
|
||||
->assertOk();
|
||||
|
||||
Notification::assertSentTo($provider, BookingCancelledNotification::class);
|
||||
}
|
||||
|
||||
// ── Provider 取消 → 通知學員 ─────────────────────────────
|
||||
|
||||
public function test_provider_cancel_notifies_member(): void
|
||||
{
|
||||
$provider = $this->makeProvider();
|
||||
$schedule = $this->makeSchedule($provider);
|
||||
$member = User::factory()->create(['role' => 'member']);
|
||||
$booking = $this->makePendingBooking($member, $schedule);
|
||||
|
||||
// 先確認,再取消(provider_cancelled 只能從 confirmed 轉換)
|
||||
$booking->update(['status' => BookingStatus::Confirmed]);
|
||||
$schedule->increment('current_participants', 1);
|
||||
|
||||
$this->actingAs($provider)
|
||||
->putJson("/api/provider/bookings/{$booking->id}/cancel")
|
||||
->assertOk();
|
||||
|
||||
Notification::assertSentTo($member, BookingCancelledNotification::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\DivingOffer;
|
||||
use App\Models\ProviderProfile;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Provider 課程 CRUD(coach-offers-api 規格)。
|
||||
*
|
||||
* 所有權驗證是核心不變式:provider_id 決定哪位教練可操作,
|
||||
* 未加檢查會讓任何已登入教練刪改他人課程。
|
||||
*/
|
||||
class ProviderOfferCrudTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function makeProvider(): User
|
||||
{
|
||||
$provider = User::factory()->create(['role' => 'provider']);
|
||||
ProviderProfile::create(['user_id' => $provider->id, 'is_verified' => true]);
|
||||
return $provider;
|
||||
}
|
||||
|
||||
private function makeOffer(User $provider): DivingOffer
|
||||
{
|
||||
return DivingOffer::create([
|
||||
'provider_id' => $provider->id,
|
||||
'title' => 'Test Dive Course',
|
||||
'location' => 'Kenting',
|
||||
'price' => 3000,
|
||||
'region' => '南部',
|
||||
'rating' => 0,
|
||||
'reviews' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── store ────────────────────────────────────────────────
|
||||
|
||||
public function test_provider_creates_offer_successfully(): void
|
||||
{
|
||||
$provider = $this->makeProvider();
|
||||
|
||||
$response = $this->actingAs($provider)->postJson('/api/provider/offers', [
|
||||
'title' => 'New Dive Course',
|
||||
'location' => 'Kenting',
|
||||
'price' => 5000,
|
||||
'region' => '南部',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJsonPath('status', true)
|
||||
->assertJsonPath('data.provider_id', $provider->id);
|
||||
|
||||
$this->assertDatabaseHas('diving_offers', [
|
||||
'title' => 'New Dive Course',
|
||||
'provider_id' => $provider->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_create_offer_missing_required_field_returns_422(): void
|
||||
{
|
||||
$provider = $this->makeProvider();
|
||||
|
||||
$this->actingAs($provider)->postJson('/api/provider/offers', [
|
||||
// title is missing
|
||||
'location' => 'Kenting',
|
||||
'price' => 5000,
|
||||
'region' => '南部',
|
||||
])->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_unauthenticated_request_is_rejected(): void
|
||||
{
|
||||
$this->postJson('/api/provider/offers', [
|
||||
'title' => 'New Dive Course',
|
||||
'location' => 'Kenting',
|
||||
'price' => 5000,
|
||||
'region' => '南部',
|
||||
])->assertStatus(401);
|
||||
}
|
||||
|
||||
// ── update ───────────────────────────────────────────────
|
||||
|
||||
public function test_provider_updates_own_offer(): void
|
||||
{
|
||||
$provider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($provider);
|
||||
|
||||
$this->actingAs($provider)->putJson("/api/provider/offers/{$offer->id}", [
|
||||
'title' => 'Updated Title',
|
||||
'price' => 9999,
|
||||
])->assertOk()->assertJsonPath('status', true);
|
||||
|
||||
$this->assertDatabaseHas('diving_offers', [
|
||||
'id' => $offer->id,
|
||||
'title' => 'Updated Title',
|
||||
'price' => 9999,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_provider_cannot_update_others_offer(): void
|
||||
{
|
||||
$ownerProvider = $this->makeProvider();
|
||||
$otherProvider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($ownerProvider);
|
||||
|
||||
$this->actingAs($otherProvider)
|
||||
->putJson("/api/provider/offers/{$offer->id}", ['title' => 'Hacked Title'])
|
||||
->assertStatus(403);
|
||||
}
|
||||
|
||||
// ── destroy ──────────────────────────────────────────────
|
||||
|
||||
public function test_provider_deletes_own_offer(): void
|
||||
{
|
||||
$provider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($provider);
|
||||
|
||||
$this->actingAs($provider)
|
||||
->deleteJson("/api/provider/offers/{$offer->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('status', true);
|
||||
|
||||
$this->assertDatabaseMissing('diving_offers', ['id' => $offer->id]);
|
||||
}
|
||||
|
||||
public function test_provider_cannot_delete_others_offer(): void
|
||||
{
|
||||
$ownerProvider = $this->makeProvider();
|
||||
$otherProvider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($ownerProvider);
|
||||
|
||||
$this->actingAs($otherProvider)
|
||||
->deleteJson("/api/provider/offers/{$offer->id}")
|
||||
->assertStatus(403);
|
||||
|
||||
$this->assertDatabaseHas('diving_offers', ['id' => $offer->id]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
<?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 Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Provider 時段管理(course-scheduling 規格)。
|
||||
*
|
||||
* 容量不變式:max_participants 不可低於 current_participants,
|
||||
* 否則已確認名額會被靜默截斷;destroy 必須同步取消進行中預約,
|
||||
* 不處理會讓 member 對不存在/取消的時段保有 pending/confirmed 狀態。
|
||||
*/
|
||||
class ProviderScheduleCrudTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function makeProvider(): User
|
||||
{
|
||||
$provider = User::factory()->create(['role' => 'provider']);
|
||||
ProviderProfile::create(['user_id' => $provider->id, 'is_verified' => true]);
|
||||
return $provider;
|
||||
}
|
||||
|
||||
private function makeOffer(User $provider): DivingOffer
|
||||
{
|
||||
return DivingOffer::create([
|
||||
'provider_id' => $provider->id,
|
||||
'title' => 'Dive Course',
|
||||
'location' => 'Kenting',
|
||||
'price' => 3000,
|
||||
'region' => '南部',
|
||||
'rating' => 0,
|
||||
'reviews' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
private function makeSchedule(DivingOffer $offer, int $currentParticipants = 0): CourseSchedule
|
||||
{
|
||||
return CourseSchedule::create([
|
||||
'diving_offer_id' => $offer->id,
|
||||
'provider_id' => $offer->provider_id,
|
||||
'scheduled_date' => now()->addDays(30)->toDateString(),
|
||||
'start_time' => '09:00',
|
||||
'max_participants' => 10,
|
||||
'current_participants' => $currentParticipants,
|
||||
'status' => ScheduleStatus::Open,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── store ────────────────────────────────────────────────
|
||||
|
||||
public function test_provider_creates_schedule_successfully(): void
|
||||
{
|
||||
$provider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($provider);
|
||||
|
||||
$response = $this->actingAs($provider)->postJson('/api/provider/schedules', [
|
||||
'diving_offer_id' => $offer->id,
|
||||
'scheduled_date' => now()->addDays(10)->toDateString(),
|
||||
'start_time' => '09:00',
|
||||
'max_participants' => 8,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJsonPath('status', true)
|
||||
->assertJsonPath('data.status', 'open');
|
||||
}
|
||||
|
||||
public function test_provider_cannot_create_schedule_for_others_offer(): void
|
||||
{
|
||||
$ownerProvider = $this->makeProvider();
|
||||
$otherProvider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($ownerProvider);
|
||||
|
||||
$this->actingAs($otherProvider)->postJson('/api/provider/schedules', [
|
||||
'diving_offer_id' => $offer->id,
|
||||
'scheduled_date' => now()->addDays(10)->toDateString(),
|
||||
'start_time' => '09:00',
|
||||
'max_participants' => 8,
|
||||
])->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_past_date_returns_422(): void
|
||||
{
|
||||
$provider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($provider);
|
||||
|
||||
$this->actingAs($provider)->postJson('/api/provider/schedules', [
|
||||
'diving_offer_id' => $offer->id,
|
||||
'scheduled_date' => now()->subDay()->toDateString(),
|
||||
'start_time' => '09:00',
|
||||
'max_participants' => 8,
|
||||
])->assertStatus(422);
|
||||
}
|
||||
|
||||
// ── update ───────────────────────────────────────────────
|
||||
|
||||
public function test_provider_updates_schedule_max_participants(): void
|
||||
{
|
||||
$provider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($provider);
|
||||
$schedule = $this->makeSchedule($offer);
|
||||
|
||||
$this->actingAs($provider)
|
||||
->putJson("/api/provider/schedules/{$schedule->id}", ['max_participants' => 20])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.max_participants', 20);
|
||||
|
||||
$this->assertDatabaseHas('course_schedules', [
|
||||
'id' => $schedule->id,
|
||||
'max_participants' => 20,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_max_participants_below_current_returns_422(): void
|
||||
{
|
||||
$provider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($provider);
|
||||
$schedule = $this->makeSchedule($offer, currentParticipants: 3);
|
||||
|
||||
$this->actingAs($provider)
|
||||
->putJson("/api/provider/schedules/{$schedule->id}", ['max_participants' => 2])
|
||||
->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_provider_cannot_update_others_schedule(): void
|
||||
{
|
||||
$ownerProvider = $this->makeProvider();
|
||||
$otherProvider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($ownerProvider);
|
||||
$schedule = $this->makeSchedule($offer);
|
||||
|
||||
$this->actingAs($otherProvider)
|
||||
->putJson("/api/provider/schedules/{$schedule->id}", ['max_participants' => 20])
|
||||
->assertStatus(403);
|
||||
}
|
||||
|
||||
// ── destroy ──────────────────────────────────────────────
|
||||
|
||||
public function test_delete_schedule_marks_active_bookings_as_provider_cancelled(): void
|
||||
{
|
||||
$provider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($provider);
|
||||
$schedule = $this->makeSchedule($offer);
|
||||
$member = User::factory()->create(['role' => 'member']);
|
||||
|
||||
$pendingBooking = Booking::create([
|
||||
'schedule_id' => $schedule->id,
|
||||
'member_id' => $member->id,
|
||||
'participants' => 1,
|
||||
'total_price' => 3000,
|
||||
'status' => BookingStatus::Pending,
|
||||
]);
|
||||
|
||||
$confirmedBooking = Booking::create([
|
||||
'schedule_id' => $schedule->id,
|
||||
'member_id' => User::factory()->create(['role' => 'member'])->id,
|
||||
'participants' => 1,
|
||||
'total_price' => 3000,
|
||||
'status' => BookingStatus::Confirmed,
|
||||
]);
|
||||
|
||||
$completedBooking = Booking::create([
|
||||
'schedule_id' => $schedule->id,
|
||||
'member_id' => User::factory()->create(['role' => 'member'])->id,
|
||||
'participants' => 1,
|
||||
'total_price' => 3000,
|
||||
'status' => BookingStatus::Completed,
|
||||
]);
|
||||
|
||||
$this->actingAs($provider)
|
||||
->deleteJson("/api/provider/schedules/{$schedule->id}")
|
||||
->assertOk();
|
||||
|
||||
$this->assertDatabaseHas('course_schedules', [
|
||||
'id' => $schedule->id,
|
||||
'status' => ScheduleStatus::Cancelled->value,
|
||||
]);
|
||||
$this->assertDatabaseHas('bookings', [
|
||||
'id' => $pendingBooking->id,
|
||||
'status' => BookingStatus::ProviderCancelled->value,
|
||||
]);
|
||||
$this->assertDatabaseHas('bookings', [
|
||||
'id' => $confirmedBooking->id,
|
||||
'status' => BookingStatus::ProviderCancelled->value,
|
||||
]);
|
||||
// 終態 booking 不受影響
|
||||
$this->assertDatabaseHas('bookings', [
|
||||
'id' => $completedBooking->id,
|
||||
'status' => BookingStatus::Completed->value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user