test(feature): 補齊五個核心業務流程 Feature test——Offer/Schedule CRUD、Admin 管理、通知觸發、預約列表
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:
2026-06-16 18:23:11 +08:00
parent c53e9657b3
commit 49a4b89655
11 changed files with 1220 additions and 0 deletions
+120
View File
@@ -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);
}
}
+131
View File
@@ -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::indexauth:sanctum,無 role check
* GET /api/provider/bookings ProviderBookingController::indexauth:sanctum,無 role check
* GET /api/admin/bookings AdminBookingController::indexauth: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);
}
}
+167
View File
@@ -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);
}
}
+143
View File
@@ -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 課程 CRUDcoach-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]);
}
}
+202
View File
@@ -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,
]);
}
}