From 63b25f96ace00b96b81d2f3c38f8181a79fa3a83 Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 11 Jun 2026 17:46:46 +0800 Subject: [PATCH] =?UTF-8?q?test(booking):=20=E8=A3=9C=E9=A0=90=E7=B4=84?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E6=B8=AC=E8=A9=A6=E2=80=94=E2=80=94=E7=8B=80?= =?UTF-8?q?=E6=85=8B=E6=A9=9F/=E9=98=B2=E8=B6=85=E8=B3=A3/Scheduler/?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E6=8E=88=E6=AC=8A/Admin=20=E6=AC=8A=E9=99=90?= =?UTF-8?q?=E9=82=8A=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 稽核 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 --- tests/Feature/AdminEndpointAuthTest.php | 122 +++++++++ tests/Feature/BookingChatAuthTest.php | 148 +++++++++++ tests/Feature/BookingLifecycleTest.php | 334 ++++++++++++++++++++++++ tests/Feature/BookingOversellTest.php | 143 ++++++++++ tests/Feature/BookingSchedulerTest.php | 134 ++++++++++ 5 files changed, 881 insertions(+) create mode 100644 tests/Feature/AdminEndpointAuthTest.php create mode 100644 tests/Feature/BookingChatAuthTest.php create mode 100644 tests/Feature/BookingLifecycleTest.php create mode 100644 tests/Feature/BookingOversellTest.php create mode 100644 tests/Feature/BookingSchedulerTest.php diff --git a/tests/Feature/AdminEndpointAuthTest.php b/tests/Feature/AdminEndpointAuthTest.php new file mode 100644 index 0000000..a570265 --- /dev/null +++ b/tests/Feature/AdminEndpointAuthTest.php @@ -0,0 +1,122 @@ +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); + } +} diff --git a/tests/Feature/BookingChatAuthTest.php b/tests/Feature/BookingChatAuthTest.php new file mode 100644 index 0000000..e77d93e --- /dev/null +++ b/tests/Feature/BookingChatAuthTest.php @@ -0,0 +1,148 @@ +member = User::factory()->create(['role' => 'member']); + $this->provider = User::factory()->create(['role' => 'provider']); + + $offer = DivingOffer::create([ + 'provider_id' => $this->provider->id, + 'title' => 'Chat Course', + 'location' => 'Test', + 'spot' => 'Test Spot', + 'price' => 1000, + 'region' => '南部', + 'rating' => 0, + 'reviews' => 0, + ]); + + $schedule = CourseSchedule::create([ + 'diving_offer_id' => $offer->id, + 'provider_id' => $this->provider->id, + 'scheduled_date' => now()->addDays(7)->toDateString(), + 'start_time' => '09:00', + 'max_participants' => 4, + 'current_participants' => 1, + 'status' => ScheduleStatus::Open, + ]); + + $this->booking = Booking::create([ + 'schedule_id' => $schedule->id, + 'member_id' => $this->member->id, + 'participants' => 1, + 'total_price' => 1000, + 'status' => BookingStatus::Confirmed, + ]); + } + + // ── HTTP 端點授權 ──────────────────────────────────────── + + public function test_participants_can_read_messages_on_confirmed_booking(): void + { + $this->actingAs($this->member) + ->getJson("/api/bookings/{$this->booking->id}/messages") + ->assertOk(); + + $this->actingAs($this->provider) + ->getJson("/api/bookings/{$this->booking->id}/messages") + ->assertOk(); + } + + public function test_outsider_member_cannot_read_messages(): void + { + $outsider = User::factory()->create(['role' => 'member']); + + $this->actingAs($outsider) + ->getJson("/api/bookings/{$this->booking->id}/messages") + ->assertStatus(403); + } + + public function test_other_provider_cannot_read_messages(): void + { + $otherProvider = User::factory()->create(['role' => 'provider']); + + $this->actingAs($otherProvider) + ->getJson("/api/bookings/{$this->booking->id}/messages") + ->assertStatus(403); + } + + public function test_messages_forbidden_on_pending_booking(): void + { + $this->booking->update(['status' => BookingStatus::Pending]); + + $this->actingAs($this->member) + ->getJson("/api/bookings/{$this->booking->id}/messages") + ->assertStatus(403); + } + + public function test_outsider_cannot_send_message(): void + { + $outsider = User::factory()->create(['role' => 'member']); + + $this->actingAs($outsider) + ->postJson("/api/bookings/{$this->booking->id}/messages", ['body' => 'hi']) + ->assertStatus(403); + } + + // ── Presence channel 訂閱驗證 ──────────────────────────── + + public function test_presence_channel_admits_both_participants(): void + { + $channel = new BookingPresenceChannel(); + + $memberResult = $channel->join($this->member, $this->booking); + $this->assertIsArray($memberResult); + $this->assertSame($this->member->id, $memberResult['user_id']); + + $providerResult = $channel->join($this->provider, $this->booking); + $this->assertIsArray($providerResult); + $this->assertSame('provider', $providerResult['user_type']); + } + + public function test_presence_channel_rejects_outsiders(): void + { + $channel = new BookingPresenceChannel(); + + $this->assertFalse($channel->join(User::factory()->create(['role' => 'member']), $this->booking)); + $this->assertFalse($channel->join(User::factory()->create(['role' => 'provider']), $this->booking)); + } + + public function test_presence_channel_rejects_non_confirmed_booking(): void + { + $this->booking->update(['status' => BookingStatus::Pending]); + $this->booking->refresh(); + + $channel = new BookingPresenceChannel(); + + $this->assertFalse($channel->join($this->member, $this->booking)); + } +} diff --git a/tests/Feature/BookingLifecycleTest.php b/tests/Feature/BookingLifecycleTest.php new file mode 100644 index 0000000..e25ff2b --- /dev/null +++ b/tests/Feature/BookingLifecycleTest.php @@ -0,0 +1,334 @@ +create(['role' => 'member']); + } + + private function makeProvider(): User + { + return User::factory()->create(['role' => 'provider']); + } + + private function makeOffer(User $provider): DivingOffer + { + return DivingOffer::create([ + 'provider_id' => $provider->id, + 'title' => 'Lifecycle Course', + 'location' => 'Test', + 'spot' => 'Test Spot', + 'price' => 1000, + 'region' => '南部', + 'rating' => 0, + 'reviews' => 0, + ]); + } + + private function makeSchedule(DivingOffer $offer, array $attributes = []): CourseSchedule + { + return CourseSchedule::create(array_merge([ + 'diving_offer_id' => $offer->id, + 'provider_id' => $offer->provider_id, + 'scheduled_date' => now()->addDays(7)->toDateString(), + 'start_time' => '09:00', + 'max_participants' => 4, + 'current_participants' => 0, + 'status' => ScheduleStatus::Open, + ], $attributes)); + } + + private function makeBooking(User $member, CourseSchedule $schedule, BookingStatus $status, int $participants = 1): Booking + { + return Booking::create([ + 'schedule_id' => $schedule->id, + 'member_id' => $member->id, + 'participants' => $participants, + 'total_price' => 1000 * $participants, + 'status' => $status, + ]); + } + + // ── 建立預約(pending) ────────────────────────────────── + + public function test_member_creates_pending_booking_without_occupying_spots(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider)); + $member = $this->makeMember(); + + $response = $this->actingAs($member)->postJson('/api/member/bookings', [ + 'schedule_id' => $schedule->id, + 'participants' => 2, + ]); + + $response->assertStatus(201)->assertJsonPath('data.status', 'pending'); + // 價格快照 = 單價 × 人數 + $response->assertJsonPath('data.total_price', 2000); + // pending 不佔名額 + $this->assertSame(0, $schedule->fresh()->current_participants); + } + + public function test_member_cannot_book_same_schedule_twice(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider)); + $member = $this->makeMember(); + $this->makeBooking($member, $schedule, BookingStatus::Pending); + + $this->actingAs($member)->postJson('/api/member/bookings', [ + 'schedule_id' => $schedule->id, + 'participants' => 1, + ])->assertStatus(422); + } + + public function test_booking_rejected_when_participants_exceed_remaining_spots(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider), [ + 'max_participants' => 4, + 'current_participants' => 3, + ]); + + $this->actingAs($this->makeMember())->postJson('/api/member/bookings', [ + 'schedule_id' => $schedule->id, + 'participants' => 2, + ])->assertStatus(422); + } + + public function test_booking_rejected_on_non_open_schedule(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider), [ + 'status' => ScheduleStatus::Cancelled, + ]); + + $this->actingAs($this->makeMember())->postJson('/api/member/bookings', [ + 'schedule_id' => $schedule->id, + 'participants' => 1, + ])->assertStatus(422); + } + + // ── Provider 確認 / 拒絕 ───────────────────────────────── + + public function test_provider_confirms_pending_booking_and_occupies_spots(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider)); + $booking = $this->makeBooking($this->makeMember(), $schedule, BookingStatus::Pending, 2); + + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$booking->id}/confirm") + ->assertOk(); + + $this->assertSame(BookingStatus::Confirmed, $booking->fresh()->status); + $this->assertSame(2, $schedule->fresh()->current_participants); + } + + public function test_confirming_last_spots_marks_schedule_full(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider), ['max_participants' => 2]); + $booking = $this->makeBooking($this->makeMember(), $schedule, BookingStatus::Pending, 2); + + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$booking->id}/confirm") + ->assertOk(); + + $this->assertSame(ScheduleStatus::Full, $schedule->fresh()->status); + } + + public function test_provider_cannot_confirm_rejected_booking(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider)); + $booking = $this->makeBooking($this->makeMember(), $schedule, BookingStatus::Rejected); + + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$booking->id}/confirm") + ->assertStatus(422); + + $this->assertSame(BookingStatus::Rejected, $booking->fresh()->status); + } + + public function test_provider_rejects_pending_booking(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider)); + $booking = $this->makeBooking($this->makeMember(), $schedule, BookingStatus::Pending); + + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$booking->id}/reject") + ->assertOk(); + + $this->assertSame(BookingStatus::Rejected, $booking->fresh()->status); + // 從未佔用名額,拒絕後也不應變動 + $this->assertSame(0, $schedule->fresh()->current_participants); + } + + // ── 完成 ───────────────────────────────────────────────── + + public function test_provider_completes_confirmed_booking(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider), ['current_participants' => 1]); + $booking = $this->makeBooking($this->makeMember(), $schedule, BookingStatus::Confirmed); + + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$booking->id}/complete") + ->assertOk(); + + $this->assertSame(BookingStatus::Completed, $booking->fresh()->status); + } + + public function test_provider_cannot_complete_pending_booking(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider)); + $booking = $this->makeBooking($this->makeMember(), $schedule, BookingStatus::Pending); + + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$booking->id}/complete") + ->assertStatus(422); + } + + // ── 取消與名額釋放 ─────────────────────────────────────── + + public function test_provider_cancel_releases_spots_and_reopens_full_schedule(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider), [ + 'max_participants' => 2, + 'current_participants' => 2, + 'status' => ScheduleStatus::Full, + ]); + $booking = $this->makeBooking($this->makeMember(), $schedule, BookingStatus::Confirmed, 2); + + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$booking->id}/cancel") + ->assertOk(); + + $schedule->refresh(); + $this->assertSame(BookingStatus::ProviderCancelled, $booking->fresh()->status); + $this->assertSame(0, $schedule->current_participants); + $this->assertSame(ScheduleStatus::Open, $schedule->status); + } + + public function test_member_cancels_pending_booking(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider)); + $member = $this->makeMember(); + $booking = $this->makeBooking($member, $schedule, BookingStatus::Pending); + + $this->actingAs($member) + ->deleteJson("/api/member/bookings/{$booking->id}") + ->assertOk(); + + $this->assertSame(BookingStatus::MemberCancelled, $booking->fresh()->status); + } + + public function test_member_cancel_confirmed_booking_releases_spots(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider), ['current_participants' => 2]); + $member = $this->makeMember(); + $booking = $this->makeBooking($member, $schedule, BookingStatus::Confirmed, 2); + + $this->actingAs($member) + ->deleteJson("/api/member/bookings/{$booking->id}") + ->assertOk(); + + $this->assertSame(BookingStatus::MemberCancelled, $booking->fresh()->status); + $this->assertSame(0, $schedule->fresh()->current_participants); + } + + public function test_member_cannot_cancel_within_24_hours_of_course_start(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider), [ + 'scheduled_date' => now()->addHours(5)->toDateString(), + 'start_time' => now()->addHours(5)->format('H:i'), + ]); + $member = $this->makeMember(); + $booking = $this->makeBooking($member, $schedule, BookingStatus::Confirmed); + + $this->actingAs($member) + ->deleteJson("/api/member/bookings/{$booking->id}") + ->assertStatus(422); + + $this->assertSame(BookingStatus::Confirmed, $booking->fresh()->status); + } + + public function test_member_cannot_cancel_completed_booking(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider)); + $member = $this->makeMember(); + $booking = $this->makeBooking($member, $schedule, BookingStatus::Completed); + + $this->actingAs($member) + ->deleteJson("/api/member/bookings/{$booking->id}") + ->assertStatus(422); + } + + // ── 授權邊界 ───────────────────────────────────────────── + + public function test_other_provider_cannot_operate_booking(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider)); + $booking = $this->makeBooking($this->makeMember(), $schedule, BookingStatus::Pending); + + $this->actingAs($this->makeProvider()) + ->putJson("/api/provider/bookings/{$booking->id}/confirm") + ->assertStatus(403); + } + + public function test_other_member_cannot_view_or_cancel_booking(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider)); + $booking = $this->makeBooking($this->makeMember(), $schedule, BookingStatus::Pending); + $intruder = $this->makeMember(); + + $this->actingAs($intruder) + ->getJson("/api/member/bookings/{$booking->id}") + ->assertStatus(403); + + $this->actingAs($intruder) + ->deleteJson("/api/member/bookings/{$booking->id}") + ->assertStatus(403); + } +} diff --git a/tests/Feature/BookingOversellTest.php b/tests/Feature/BookingOversellTest.php new file mode 100644 index 0000000..418e9d6 --- /dev/null +++ b/tests/Feature/BookingOversellTest.php @@ -0,0 +1,143 @@ +create(['role' => 'provider']); + + $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 > 容量 2,allowed 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); + } +} diff --git a/tests/Feature/BookingSchedulerTest.php b/tests/Feature/BookingSchedulerTest.php new file mode 100644 index 0000000..e4fbbae --- /dev/null +++ b/tests/Feature/BookingSchedulerTest.php @@ -0,0 +1,134 @@ +create(['role' => 'provider']); + + $offer = DivingOffer::create([ + 'provider_id' => $provider->id, + 'title' => 'Scheduler 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' => $scheduledDate, + 'start_time' => '09:00', + 'max_participants' => 4, + 'current_participants' => 0, + 'status' => ScheduleStatus::Open, + ]); + + return Booking::create([ + 'schedule_id' => $schedule->id, + 'member_id' => User::factory()->create(['role' => 'member'])->id, + 'participants' => 1, + 'total_price' => 1000, + 'status' => $status, + ]); + } + + private function backdateBooking(Booking $booking, int $hours): void + { + $booking->created_at = now()->subHours($hours); + $booking->save(); + } + + // ── app:expire-pending-bookings ────────────────────────── + + public function test_pending_older_than_48_hours_is_expired(): void + { + $booking = $this->makeBookingOnDate(BookingStatus::Pending, now()->addDays(7)->toDateString()); + $this->backdateBooking($booking, 49); + + $this->artisan('app:expire-pending-bookings')->assertSuccessful(); + + $this->assertSame(BookingStatus::Expired, $booking->fresh()->status); + } + + public function test_pending_within_48_hours_is_not_expired(): void + { + $booking = $this->makeBookingOnDate(BookingStatus::Pending, now()->addDays(7)->toDateString()); + $this->backdateBooking($booking, 47); + + $this->artisan('app:expire-pending-bookings')->assertSuccessful(); + + $this->assertSame(BookingStatus::Pending, $booking->fresh()->status); + } + + public function test_expire_command_does_not_touch_confirmed_bookings(): void + { + $booking = $this->makeBookingOnDate(BookingStatus::Confirmed, now()->addDays(7)->toDateString()); + $this->backdateBooking($booking, 100); + + $this->artisan('app:expire-pending-bookings')->assertSuccessful(); + + $this->assertSame(BookingStatus::Confirmed, $booking->fresh()->status); + } + + // ── app:complete-finished-bookings ─────────────────────── + + public function test_confirmed_booking_with_past_date_is_completed_and_member_notified(): void + { + $booking = $this->makeBookingOnDate(BookingStatus::Confirmed, now()->subDay()->toDateString()); + + $this->artisan('app:complete-finished-bookings')->assertSuccessful(); + + $this->assertSame(BookingStatus::Completed, $booking->fresh()->status); + Notification::assertSentTo($booking->member, BookingCompletedNotification::class); + } + + public function test_confirmed_booking_today_is_not_completed_yet(): void + { + $booking = $this->makeBookingOnDate(BookingStatus::Confirmed, now()->toDateString()); + + $this->artisan('app:complete-finished-bookings')->assertSuccessful(); + + $this->assertSame(BookingStatus::Confirmed, $booking->fresh()->status); + } + + public function test_complete_command_does_not_touch_pending_bookings(): void + { + // pending 即使課程日期已過也不應被標記完成(應由 expire 流程處理) + $booking = $this->makeBookingOnDate(BookingStatus::Pending, now()->subDay()->toDateString()); + + $this->artisan('app:complete-finished-bookings')->assertSuccessful(); + + $this->assertSame(BookingStatus::Pending, $booking->fresh()->status); + } +}