Merge pull request 'test(feature): 補齊五個核心業務流程 Feature test——Offer/Schedule CRUD、Admin 管理、通知觸發、預約列表' (#38) from feat/feature-test-coverage into master
Deploy Production / deploy (push) Successful in 19s
Run Tests / test (push) Failing after 1m50s

Reviewed-on: #38
This commit was merged in pull request #38.
This commit is contained in:
2026-06-16 10:24:17 +00:00
11 changed files with 1220 additions and 0 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-16
@@ -0,0 +1,56 @@
## Context
現有 187 個 Feature test 分佈在認證、狀態機邊界、聊天授權等安全層,核心業務端點(Provider 課程管理、時段管理、Admin 使用者操作、通知觸發、預約列表)完全未覆蓋。缺口已於 2026-06-11 稽核報告中識別(通知系統「僅 Scheduler 路徑覆蓋」),此 change 補齊這五個區塊。
**現有 Factory 狀況**`database/factories/` 只有 `UserFactory``DivingOffer``CourseSchedule``Booking` 均無 factory——現有測試一律使用 `Model::create([...])` 直接建立,新測試沿用此慣例,不新增 factory(避免引入 app 行為)。
**認證方式**:現有 Feature test 全部使用 `$this->actingAs($user)` + `getJson/postJson` 等 HTTP helpers,不手動建立 token。新測試維持相同模式。
## Goals / Non-Goals
**Goals:**
- 5 個新 Feature test 類別:`ProviderOfferCrudTest``ProviderScheduleCrudTest``AdminUserManagementTest``NotificationTriggerTest``BookingListTest`
- 涵蓋正常流程(happy path)+ 所有權邊界(403+ 輸入驗證(422)
- 通知測試補齊直接事件路徑(建立/確認/拒絕/取消),不只驗證 Scheduler 完課路徑
**Non-Goals:**
- 不修改任何 app 程式碼
- 不補 Unit test(已於上一個 change 完成)
- 不測試 UI / 前端行為
- 不測試通知 Email 實際發送(`notification-email` 規格另有覆蓋)
## Decisions
### D1:延續現有 Feature test 架構,不引入 Service layer mock
現有測試全部使用 `RefreshDatabase` + 真實 DB,未 mock 任何 repository/service。維持一致性優先於執行速度——187 案例目前 42 秒,新增 ~50 案例後預估 55 秒以內,可接受。
**替代方案**mock `Cache::tags()``Notification`。決議:`Notification::fake()` 繼續使用(現有慣例),`Cache::tags()` 讓它真實運行(Redis 在 Docker 中可用)。
### D2:通知測試使用 `Notification::fake()` + `assertSentTo()`
現有 `BookingSchedulerTest` 已驗證 Scheduler 完課觸發通知。`NotificationTriggerTest` 補齊 controller 直接觸發的路徑(`store``confirm``reject``member cancel``provider cancel`),同樣 fake 後斷言 `Notification::assertSentTo()`,不依賴 queue worker。
### D3:預約列表測試不涵蓋分頁(暫緩 O3.2)
`MemberBookingController::index()` 目前為 `->get()` 全量回傳(O3.2 未完成),測試僅斷言回傳正確的資料集合(`data` 陣列包含/不包含正確的 booking)。分頁斷言待 O3.2 實作後加入。
### D4:所有權邊界是必測案例,非選測
Provider CRUD 的「他人不可操作」403 路徑在現有 `BookingLifecycleTest` 已有先例,這裡對 Offer 和 Schedule 同樣必須測。Admin 操作已有 `AdminEndpointAuthTest` 覆蓋角色層的 401/403`AdminUserManagementTest` 聚焦資料正確性與操作副作用(is_active 切換、is_verified 切換 + 快取清除)。
## Risks / Trade-offs
- **`Cache::tags()` 與 driver 相容性**`phpunit.xml` 設定 `CACHE_STORE=array`。Laravel 11 的 `array` store 繼承 `TaggableStore`,因此 `Cache::tags(['diving_offers'])->flush()` 在測試環境可正常執行(`DivingOfferVisibilityTest::test_toggle_verified_takes_effect_immediately_despite_cache` 已驗證)。真正的風險是:若未來 CI 或本機改用 `file``database` 等不支援 tags 的 driver,所有呼叫 `Cache::tags()` 的 controller 測試都會拋出 `BadMethodCallException`。→ 緩解:在 tasks 加上驗收項目,確認 `phpunit.xml``CACHE_STORE``array``redis`,並加入 README 說明。
- **Admin toggle-verified 的快取副作用驗證方式**`DivingOfferVisibilityTest` 已有端對端驗證(toggle → 公開列表立即消失)。`AdminUserManagementTest` 不重複這個斷言,只驗證 `is_verified` 欄位變更與 HTTP 200 即可,避免測試重複。
- **Notification::fake() 無法驗證 mail 實際送出**:這是刻意的邊界——只驗證通知物件被派發至正確收件者,不驗證 email 模板內容(屬於 `notification-email` 規格的範疇)。
## 各測試類別案例清單
| 測試類別 | 案例 | 涵蓋邊界 |
|----------|------|---------|
| `ProviderOfferCrudTest` | store 成功、缺必填 422、update 成功、update 他人 403、destroy 成功、destroy 他人 403、未認證 401 | happy path + ownership + auth |
| `ProviderScheduleCrudTest` | store 成功、store 他人課程 403、過去日期 422、update max_participants 成功、update 低於 current 422、update 他人 403、destroy 同時取消 bookings | happy path + 容量不變式 + ownership |
| `AdminUserManagementTest` | 列出 members 只含 member role、列出 providers 只含 provider role、toggle-active 改變 is_active、toggle-verified 改變 is_verified、非 admin 403 | 資料正確性 + 角色隔離 |
| `NotificationTriggerTest` | BookingCreated→provider、Confirmed→member、Rejected→member、CancelledByMember→provider、CancelledByProvider→member | 5 條直接觸發路徑,收件者各不同 |
| `BookingListTest` | `GET /api/member/bookings`MemberBookingController::index)、`GET /api/provider/bookings`ProviderBookingController::index)、`GET /api/admin/bookings`AdminBookingController::index)——各建兩方資料斷言隔離、admin 全看、未認證 401 | 路由已確認存在;角色資料隔離邊界 |
@@ -0,0 +1,26 @@
## Why
稽核報告(2026-06-11)標註通知系統「僅 Scheduler 路徑覆蓋」,且 Provider Offer/Schedule CRUD、Admin 使用者管理、預約列表端點至今零 Feature test。187 個現有案例全集中在認證與狀態機邊界,核心業務流程端點的回歸一旦發生,測試套件無法攔截。
## What Changes
- 新增 `ProviderOfferCrudTest`:教練課程的建立、更新、刪除,含所有權驗證(他人不可操作)
- 新增 `ProviderScheduleCrudTest`:課程時段的建立、更新、刪除,含容量驗證與所有權邊界
- 新增 `AdminUserManagementTest`:Admin 查詢使用者列表、切換教練驗證狀態(toggle-verified
- 新增 `NotificationTriggerTest`:預約事件直接觸發通知(建立、確認、拒絕、取消、完成);補足現有 Scheduler 路徑以外的觸發路徑
- 新增 `BookingListTest`Member / Provider / Admin 各自的預約列表端點(分頁與角色過濾)
## Capabilities
### New Capabilities
- `feature-test-coverage`:定義核心業務流程端點的測試覆蓋契約,涵蓋 Provider Offer CRUD、Schedule CRUD、Admin 使用者管理、通知觸發路徑、預約列表端點;對應 auth-test-coverage 在認證層的角色
### Modified Capabilities
<!-- 現有行為規格無異動,僅補測試覆蓋,無須 delta spec -->
## Impact
- 新增 Feature test 檔案:`tests/Feature/` 下 5 個新測試類別(約 40~55 案例)
- 不修改任何 app 程式碼
- 不影響現有 API 行為或資料結構
- 現有 187 案例零 regressionUnit test 已驗證)
@@ -0,0 +1,146 @@
# feature-test-coverage Specification
## Purpose
定義核心業務流程端點的測試覆蓋契約:Provider 課程 CRUD、時段管理、Admin 使用者操作、通知直接觸發路徑、預約列表端點。補齊 2026-06-11 稽核報告識別的 Feature test 缺口,確保這些端點的回歸能被測試套件即時攔截。
## ADDED Requirements
---
### Requirement: Provider 課程 CRUD 測試覆蓋
測試套件 SHALL 驗證 `POST /api/provider/offers``PUT /api/provider/offers/{id}``DELETE /api/provider/offers/{id}` 的正常流程與所有權邊界。
#### Scenario: Provider 建立課程成功
- **WHEN** 已認證的 Provider 送出有效的 title / location / price / region 至 `POST /api/provider/offers`
- **THEN** 回傳 HTTP 201body 包含 `{ status: true, data: { id, title, provider_id } }`DB 存在對應記錄且 `provider_id` 為當前 Provider
#### Scenario: 建立課程缺少必填欄位回傳 422
- **WHEN** Provider 送出缺少 `title``price` 的請求至 `POST /api/provider/offers`
- **THEN** 回傳 HTTP 422,body 包含對應欄位的驗證錯誤
#### Scenario: Provider 更新自己的課程
- **WHEN** 已認證的 Provider 送出有效更新欄位至 `PUT /api/provider/offers/{id}`(該課程屬於此 Provider
- **THEN** 回傳 HTTP 200,body 包含更新後的課程資料,DB 記錄已變更
#### Scenario: Provider 不可更新他人課程
- **WHEN** Provider A 嘗試 `PUT /api/provider/offers/{id}`,但該課程屬於 Provider B
- **THEN** 回傳 HTTP 403body 包含 `{ status: false }`
#### Scenario: Provider 刪除自己的課程
- **WHEN** 已認證的 Provider 送出 `DELETE /api/provider/offers/{id}`(該課程屬於此 Provider
- **THEN** 回傳 HTTP 200body 包含 `{ status: true }`DB 記錄已不存在
#### Scenario: Provider 不可刪除他人課程
- **WHEN** Provider A 嘗試 `DELETE /api/provider/offers/{id}`,但該課程屬於 Provider B
- **THEN** 回傳 HTTP 403
#### Scenario: 未認證請求被拒絕
- **WHEN** 未帶任何 token 送出 `POST /api/provider/offers`
- **THEN** 回傳 HTTP 401
---
### Requirement: Provider 時段管理測試覆蓋
測試套件 SHALL 驗證 `POST /api/provider/schedules``PUT /api/provider/schedules/{id}``DELETE /api/provider/schedules/{id}` 的正常流程、所有權邊界與容量驗證。
#### Scenario: Provider 建立時段成功
- **WHEN** Provider 送出有效的 diving_offer_id / scheduled_date(未來日期)/ start_time / max_participants 至 `POST /api/provider/schedules`
- **THEN** 回傳 HTTP 201body 包含 `{ status: true, data: { id, scheduled_date, status: "open" } }`
#### Scenario: Provider 不可為他人課程建立時段
- **WHEN** Provider A 送出 Provider B 課程的 diving_offer_id 至 `POST /api/provider/schedules`
- **THEN** 回傳 HTTP 403
#### Scenario: 時段日期不可為過去
- **WHEN** Provider 送出 scheduled_date 為過去日期
- **THEN** 回傳 HTTP 422
#### Scenario: Provider 更新時段人數上限
- **WHEN** Provider 送出新的 max_participants 至 `PUT /api/provider/schedules/{id}`(新值 ≥ 目前 current_participants
- **THEN** 回傳 HTTP 200DB 記錄已更新
#### Scenario: 人數上限不可低於已確認人數
- **WHEN** Provider 嘗試將 max_participants 設為低於時段 current_participants 的值
- **THEN** 回傳 HTTP 422,message 說明不可低於已確認人數
#### Scenario: Provider 不可更新他人時段
- **WHEN** Provider A 嘗試 `PUT /api/provider/schedules/{id}`,但該時段屬於 Provider B
- **THEN** 回傳 HTTP 403
#### Scenario: 刪除(取消)時段同時將進行中預約標記為 provider_cancelled
- **WHEN** Provider 對有 pending/confirmed 預約的時段送出 `DELETE /api/provider/schedules/{id}`
- **THEN** 回傳 HTTP 200;時段 status 改為 `cancelled`(記錄保留,不刪除);該時段的 pending/confirmed booking status 改為 `provider_cancelled`(記錄保留,不刪除);已是終態的 bookingcompleted/expired/已取消)不受影響
---
### Requirement: Admin 使用者管理測試覆蓋
測試套件 SHALL 驗證 Admin 查詢會員/教練列表、切換帳號啟用狀態、切換教練驗證狀態的正確行為。
#### Scenario: Admin 取得會員列表
- **WHEN** Admin 送出 `GET /api/admin/members`
- **THEN** 回傳 HTTP 200data 陣列只包含 role=member 的使用者,含 meta 分頁資訊
#### Scenario: Admin 取得教練列表
- **WHEN** Admin 送出 `GET /api/admin/providers`
- **THEN** 回傳 HTTP 200data 陣列只包含 role=provider 的使用者
#### Scenario: Admin 切換會員帳號啟用狀態
- **WHEN** Admin 送出 `POST /api/admin/members/{id}/toggle-active`(目標會員 is_active=true
- **THEN** 回傳 HTTP 200DB 記錄 is_active 由 true 變為 false
#### Scenario: Admin 切換教練驗證狀態並清除快取
- **WHEN** Admin 送出 `POST /api/admin/providers/{id}/toggle-verified`(目標教練 is_verified=false
- **THEN** 回傳 HTTP 200provider_profiles.is_verified 由 false 變為 truediving_offers 快取被清除(後續公開列表可見該教練課程)
#### Scenario: 非 Admin 角色被拒絕
- **WHEN** role=member 的使用者送出 `GET /api/admin/members`
- **THEN** 回傳 HTTP 403
---
### Requirement: 通知直接觸發路徑測試覆蓋
測試套件 SHALL 驗證預約 controller 操作直接觸發的通知事件,補足 Scheduler 路徑以外的覆蓋缺口。收件者依實際 controller 實作:新預約與 member 取消通知教練,其餘通知學員。
#### Scenario: 建立預約觸發 BookingCreatedNotification 至教練
- **WHEN** Member 成功建立預約(`POST /api/member/bookings`
- **THEN** `BookingCreatedNotification` 被派發至該預約所屬的 **Provider**`MemberBookingController::store` 呼叫 `$provider->notify(...)`
#### Scenario: Provider 確認預約觸發 BookingConfirmedNotification 至學員
- **WHEN** Provider 對 pending 預約呼叫 `PUT /api/provider/bookings/{id}/confirm`
- **THEN** `BookingConfirmedNotification` 被派發至該預約的 **Member**
#### Scenario: Provider 拒絕預約觸發 BookingRejectedNotification 至學員
- **WHEN** Provider 對 pending 預約呼叫 `PUT /api/provider/bookings/{id}/reject`
- **THEN** `BookingRejectedNotification` 被派發至該預約的 **Member**
#### Scenario: Member 取消預約觸發 BookingCancelledNotification 至教練
- **WHEN** Member 對 pending 預約呼叫 `DELETE /api/member/bookings/{id}`
- **THEN** `BookingCancelledNotification` 被派發至該預約所屬的 **Provider**`cancelledBy: 'member'`
#### Scenario: Provider 取消預約觸發 BookingCancelledNotification 至學員
- **WHEN** Provider 對 confirmed 預約呼叫 `PUT /api/provider/bookings/{id}/cancel`
- **THEN** `BookingCancelledNotification` 被派發至該預約的 **Member**`cancelledBy: 'provider'`
---
### Requirement: 預約列表端點測試覆蓋
測試套件 SHALL 驗證下列三個已存在路由的資料隔離行為:
- `GET /api/member/bookings`middleware: `auth:sanctum`,資料隔離在 controller `where member_id = auth()->id()`
- `GET /api/provider/bookings`middleware: `auth:sanctum`,資料隔離在 controller `whereHas schedule.provider_id`
- `GET /api/admin/bookings`middleware: `auth:sanctum` + `admin`EnsureAdmin 於 middleware 層強制 403
#### Scenario: Member 只能看到自己的預約
- **WHEN** Member A 呼叫 `GET /api/member/bookings`,系統中同時存在 Member A 與 Member B 各一筆預約
- **THEN** 回傳 data 陣列長度為 1,且 id 為 Member A 的 booking idMember B 的 booking id 不在 data 中
#### Scenario: Provider 只能看到屬於自己課程的預約
- **WHEN** Provider A 呼叫 `GET /api/provider/bookings`,系統中有屬於 Provider A 課程與 Provider B 課程各一筆預約
- **THEN** 回傳 data 中只包含 Provider A 課程的 booking idProvider B 課程的 booking id 不在 data 中
#### Scenario: Admin 可查看所有預約
- **WHEN** Admin 呼叫 `GET /api/admin/bookings`,系統中有 2 筆不同 Member 的預約
- **THEN** 回傳 data count ≥ 2,兩筆 booking id 均在 data 中
#### Scenario: 未認證請求被拒絕
- **WHEN** 未帶 token 呼叫 `GET /api/member/bookings`
- **THEN** 回傳 HTTP 401
@@ -0,0 +1,83 @@
## 0. 前置確認(實作前一次性)
- [x] 0.1 確認 `phpunit.xml``CACHE_STORE=array`Laravel 11 array store 支援 tags,若非 array/redis 須先修正再實作)
- [x] 0.2 確認現有認證模式:所有新測試使用 `$this->actingAs($user)` + `postJson/putJson`,不手動建立 Sanctum token
- [x] 0.3 確認 factory 狀況:`DivingOffer``CourseSchedule``Booking` 均無 factory,一律使用 `Model::create([...])` 直接建立,不新增 factory
## 1. Provider 課程 CRUD 測試
- [x] 1.1 [整合測試] 建立 `tests/Feature/ProviderOfferCrudTest.php`,加入 private helpers
- `makeProvider(): User`User::create + ProviderProfile::createis_verified=true
- `makeOffer(User $provider): DivingOffer`DivingOffer::create 最小必填欄位)
- [x] 1.2 [整合測試] `test_provider_creates_offer_successfully`POST 201data.provider_id = auth provider idDB 存在
- [x] 1.3 [整合測試] `test_create_offer_missing_required_field_returns_422`:缺 title 送出,回傳 422(代表性一個欄位即可)
- [x] 1.4 [整合測試] `test_provider_updates_own_offer`PUT 200DB 欄位已變更
- [x] 1.5 [整合測試] `test_provider_cannot_update_others_offer`Provider B 更新 Provider A 的課程,403
- [x] 1.6 [整合測試] `test_provider_deletes_own_offer`DELETE 200DB 記錄不存在
- [x] 1.7 [整合測試] `test_provider_cannot_delete_others_offer`403
- [x] 1.8 [整合測試] `test_unauthenticated_request_is_rejected`:無 token POST401
## 2. Provider 時段管理測試
- [x] 2.1 [整合測試] 建立 `tests/Feature/ProviderScheduleCrudTest.php`,加入 private helpers
- `makeProvider(): User`
- `makeOffer(User $provider): DivingOffer`
- `makeSchedule(DivingOffer $offer, int $currentParticipants = 0): CourseSchedule`
- [x] 2.2 [整合測試] `test_provider_creates_schedule_successfully`POST 201status=open
- [x] 2.3 [整合測試] `test_provider_cannot_create_schedule_for_others_offer`403
- [x] 2.4 [整合測試] `test_past_date_returns_422`scheduled_date = yesterday422(代表性驗證案例)
- [x] 2.5 [整合測試] `test_provider_updates_schedule_max_participants`PUT 200DB 更新
- [x] 2.6 [整合測試] `test_max_participants_below_current_returns_422`current_participants=3PUT max_participants=2422
- [x] 2.7 [整合測試] `test_provider_cannot_update_others_schedule`403
- [x] 2.8 [整合測試] `test_delete_schedule_marks_active_bookings_as_provider_cancelled`:建立一筆 pending + 一筆 confirmed bookingDELETE 200;斷言時段 status=`cancelled`(記錄保留);兩筆 booking status=`provider_cancelled`(記錄保留,不刪除);不在 pending/confirmed 的 booking 不受影響
## 3. Admin 使用者管理測試
- [x] 3.1 [整合測試] 建立 `tests/Feature/AdminUserManagementTest.php`,加入 private helpers
- `makeAdmin(): User`role=admin
- `makeMember(): User`role=member + MemberProfile
- `makeProvider(bool $isVerified = false): User`role=provider + ProviderProfile
- [x] 3.2 [整合測試] `test_admin_lists_members_only`:建立 member + provider 各一,GET /api/admin/members 回傳 data 只含 member role
- [x] 3.3 [整合測試] `test_admin_lists_providers_only`GET /api/admin/providers 回傳 data 只含 provider role
- [x] 3.4 [整合測試] `test_admin_toggles_member_active_status`:目標 is_active=true → POST toggle-active → DB is_active=false
- [x] 3.5 [整合測試] `test_admin_toggles_provider_verified_status`:目標 is_verified=false → POST toggle-verified → DB is_verified=true(不重複 DivingOfferVisibilityTest 的快取副作用驗證,只斷言 DB 欄位與 HTTP 200
- [x] 3.6 [整合測試] `test_non_admin_is_forbidden`role=member actingAsGET /api/admin/members 回傳 403
## 4. 通知直接觸發路徑測試
收件者依實際 controller 實作(已查閱原始碼 + app/Notifications/ 確認 class 存在):
- `BookingCreatedNotification``MemberBookingController::store``$provider->notify(new BookingCreatedNotification($booking))` → **教練**收到
- `BookingConfirmedNotification``ProviderBookingController::confirm``$booking->member->notify(...)` → **學員**收到
- `BookingRejectedNotification``ProviderBookingController::reject``$booking->member->notify(...)` → **學員**收到
- `BookingCancelledNotification`cancelledBy: 'member'):`MemberBookingController::destroy``$provider->notify(...)` → **教練**收到
- `BookingCancelledNotification`cancelledBy: 'provider'):`ProviderBookingController::cancel``$booking->member->notify(...)` → **學員**收到
- 注意:member/provider cancel 使用**同一個 class** `BookingCancelledNotification``assertSentTo` 只需確認 class 名稱與收件者即可
- [x] 4.1 [整合測試] 建立 `tests/Feature/NotificationTriggerTest.php`setUp 加 `Notification::fake()`;加入 helper 建立 member + provider(含 ProviderProfile + offer + scheduleopen, 未來日期) + pending booking
- [x] 4.2 [整合測試] `test_booking_created_notifies_provider`Member `POST /api/member/bookings``assertSentTo($provider, BookingCreatedNotification::class)`
- [x] 4.3 [整合測試] `test_booking_confirmed_notifies_member`Provider `PUT /api/provider/bookings/{id}/confirm``assertSentTo($member, BookingConfirmedNotification::class)`
- [x] 4.4 [整合測試] `test_booking_rejected_notifies_member`Provider `PUT /api/provider/bookings/{id}/reject``assertSentTo($member, BookingRejectedNotification::class)`
- [x] 4.5 [整合測試] `test_member_cancel_notifies_provider`Member `DELETE /api/member/bookings/{id}`pending, 遠未來日期) → `assertSentTo($provider, BookingCancelledNotification::class)`
- [x] 4.6 [整合測試] `test_provider_cancel_notifies_member`Provider `PUT /api/provider/bookings/{id}/cancel`(先 confirm 再 cancel`assertSentTo($member, BookingCancelledNotification::class)`
## 5. 預約列表端點測試
路由對應(已查閱 routes/api.php + EnsureAdmin middleware 確認):
- `GET /api/member/bookings` → middleware: `auth:sanctum`(無 role 檢查)→ `MemberBookingController::index`,隔離靠 `where member_id = auth()->id()`
- `GET /api/provider/bookings` → middleware: `auth:sanctum`(無 role 檢查)→ `ProviderBookingController::index`,隔離靠 `whereHas schedule.provider_id`
- `GET /api/admin/bookings` → middleware: `auth:sanctum` + `admin``EnsureAdmin` 強制 403)→ `AdminBookingController::index`
- [x] 5.1 [整合測試] 建立 `tests/Feature/BookingListTest.php`,加入 helpers
- `makeProviderWithSchedule(): array`(回傳 [provider, schedule],含 ProviderProfile + DivingOffer + CourseSchedule
- `makePendingBooking(User $member, CourseSchedule $schedule): Booking`
- [x] 5.2 [整合測試] `test_member_sees_only_own_bookings`MemberA 和 MemberB 各建一筆 pending booking(同一 schedule),actingAs MemberA → `GET /api/member/bookings`,斷言 data count=1 且 data[0].id = MemberA booking id
- [x] 5.3 [整合測試] `test_provider_sees_only_own_course_bookings`ProviderA 和 ProviderB 各有一筆 bookingactingAs ProviderA → `GET /api/provider/bookings`,斷言 data 中只含 ProviderA schedule 的 booking id
- [x] 5.4 [整合測試] `test_admin_sees_all_bookings`:建立 2 筆不同 member 的 bookingactingAs admin → `GET /api/admin/bookings`,斷言兩個 booking id 均在 data 中
- [x] 5.5 [整合測試] `test_unauthenticated_request_returns_401`:無 token `GET /api/member/bookings`401
## 6. 驗收
- [x] 6.1 容器內 `php artisan test` 全綠(基準 187 passed,預估完成後 ≥ 230 passed
- [x] 6.2 確認 5 個新測試類別的案例方法數與 spec scenarios 對應(可多不可少)
- [x] 6.3 確認 `phpunit.xml` 仍為 `CACHE_STORE=array`,未被意外改動
- [x] 6.4 將本 change 的 specs 增量套用至主規格 `openspec/specs/feature-test-coverage/spec.md`
@@ -0,0 +1,144 @@
# feature-test-coverage Specification
## Purpose
定義核心業務流程端點的測試覆蓋契約:Provider 課程 CRUD、時段管理、Admin 使用者操作、通知直接觸發路徑、預約列表端點。補齊 2026-06-11 稽核報告識別的 Feature test 缺口,確保這些端點的回歸能被測試套件即時攔截。
---
### Requirement: Provider 課程 CRUD 測試覆蓋
測試套件 SHALL 驗證 `POST /api/provider/offers``PUT /api/provider/offers/{id}``DELETE /api/provider/offers/{id}` 的正常流程與所有權邊界。
#### Scenario: Provider 建立課程成功
- **WHEN** 已認證的 Provider 送出有效的 title / location / price / region 至 `POST /api/provider/offers`
- **THEN** 回傳 HTTP 201body 包含 `{ status: true, data: { id, title, provider_id } }`DB 存在對應記錄且 `provider_id` 為當前 Provider
#### Scenario: 建立課程缺少必填欄位回傳 422
- **WHEN** Provider 送出缺少 `title` 的請求至 `POST /api/provider/offers`
- **THEN** 回傳 HTTP 422,body 包含對應欄位的驗證錯誤
#### Scenario: Provider 更新自己的課程
- **WHEN** 已認證的 Provider 送出有效更新欄位至 `PUT /api/provider/offers/{id}`(該課程屬於此 Provider
- **THEN** 回傳 HTTP 200,body 包含更新後的課程資料,DB 記錄已變更
#### Scenario: Provider 不可更新他人課程
- **WHEN** Provider A 嘗試 `PUT /api/provider/offers/{id}`,但該課程屬於 Provider B
- **THEN** 回傳 HTTP 403body 包含 `{ status: false }`
#### Scenario: Provider 刪除自己的課程
- **WHEN** 已認證的 Provider 送出 `DELETE /api/provider/offers/{id}`(該課程屬於此 Provider
- **THEN** 回傳 HTTP 200body 包含 `{ status: true }`DB 記錄已不存在
#### Scenario: Provider 不可刪除他人課程
- **WHEN** Provider A 嘗試 `DELETE /api/provider/offers/{id}`,但該課程屬於 Provider B
- **THEN** 回傳 HTTP 403
#### Scenario: 未認證請求被拒絕
- **WHEN** 未帶任何 token 送出 `POST /api/provider/offers`
- **THEN** 回傳 HTTP 401
---
### Requirement: Provider 時段管理測試覆蓋
測試套件 SHALL 驗證 `POST /api/provider/schedules``PUT /api/provider/schedules/{id}``DELETE /api/provider/schedules/{id}` 的正常流程、所有權邊界與容量驗證。
#### Scenario: Provider 建立時段成功
- **WHEN** Provider 送出有效的 diving_offer_id / scheduled_date(未來日期)/ start_time / max_participants 至 `POST /api/provider/schedules`
- **THEN** 回傳 HTTP 201body 包含 `{ status: true, data: { id, scheduled_date, status: "open" } }`
#### Scenario: Provider 不可為他人課程建立時段
- **WHEN** Provider A 送出 Provider B 課程的 diving_offer_id 至 `POST /api/provider/schedules`
- **THEN** 回傳 HTTP 403
#### Scenario: 時段日期不可為過去
- **WHEN** Provider 送出 scheduled_date 為過去日期
- **THEN** 回傳 HTTP 422
#### Scenario: Provider 更新時段人數上限
- **WHEN** Provider 送出新的 max_participants 至 `PUT /api/provider/schedules/{id}`(新值 ≥ 目前 current_participants
- **THEN** 回傳 HTTP 200DB 記錄已更新
#### Scenario: 人數上限不可低於已確認人數
- **WHEN** Provider 嘗試將 max_participants 設為低於時段 current_participants 的值
- **THEN** 回傳 HTTP 422,message 說明不可低於已確認人數
#### Scenario: Provider 不可更新他人時段
- **WHEN** Provider A 嘗試 `PUT /api/provider/schedules/{id}`,但該時段屬於 Provider B
- **THEN** 回傳 HTTP 403
#### Scenario: 刪除時段同時將進行中預約標記為 provider_cancelled
- **WHEN** Provider 對有 pending/confirmed 預約的時段送出 `DELETE /api/provider/schedules/{id}`
- **THEN** 回傳 HTTP 200;時段 status 改為 `cancelled`(記錄保留,不刪除);該時段的 pending/confirmed booking status 改為 `provider_cancelled`(記錄保留,不刪除);已是終態的 bookingcompleted/expired/已取消)不受影響
---
### Requirement: Admin 使用者管理測試覆蓋
測試套件 SHALL 驗證 Admin 查詢會員/教練列表、切換帳號啟用狀態、切換教練驗證狀態的正確行為。
#### Scenario: Admin 取得會員列表
- **WHEN** Admin 送出 `GET /api/admin/members`
- **THEN** 回傳 HTTP 200data 陣列只包含 role=member 的使用者
#### Scenario: Admin 取得教練列表
- **WHEN** Admin 送出 `GET /api/admin/providers`
- **THEN** 回傳 HTTP 200data 陣列只包含 role=provider 的使用者
#### Scenario: Admin 切換會員帳號啟用狀態
- **WHEN** Admin 送出 `PUT /api/admin/members/{id}/toggle-active`(目標會員 is_active=true
- **THEN** 回傳 HTTP 200DB 記錄 is_active 由 true 變為 false
#### Scenario: Admin 切換教練驗證狀態
- **WHEN** Admin 送出 `PUT /api/admin/providers/{id}/toggle-verified`(目標教練 is_verified=false
- **THEN** 回傳 HTTP 200provider_profiles.is_verified 由 false 變為 true
#### Scenario: 非 Admin 角色被拒絕
- **WHEN** role=member 的使用者送出 `GET /api/admin/members`
- **THEN** 回傳 HTTP 403
---
### Requirement: 通知直接觸發路徑測試覆蓋
測試套件 SHALL 驗證預約 controller 操作直接觸發的通知事件,補足 Scheduler 路徑以外的覆蓋缺口。收件者依實際 controller 實作:新預約與 member 取消通知教練,其餘通知學員。
#### Scenario: 建立預約觸發 BookingCreatedNotification 至教練
- **WHEN** Member 成功建立預約(`POST /api/member/bookings`
- **THEN** `BookingCreatedNotification` 被派發至該預約所屬的 **Provider**
#### Scenario: Provider 確認預約觸發 BookingConfirmedNotification 至學員
- **WHEN** Provider 對 pending 預約呼叫 `PUT /api/provider/bookings/{id}/confirm`
- **THEN** `BookingConfirmedNotification` 被派發至該預約的 **Member**
#### Scenario: Provider 拒絕預約觸發 BookingRejectedNotification 至學員
- **WHEN** Provider 對 pending 預約呼叫 `PUT /api/provider/bookings/{id}/reject`
- **THEN** `BookingRejectedNotification` 被派發至該預約的 **Member**
#### Scenario: Member 取消預約觸發 BookingCancelledNotification 至教練
- **WHEN** Member 對 pending 預約呼叫 `DELETE /api/member/bookings/{id}`
- **THEN** `BookingCancelledNotification` 被派發至該預約所屬的 **Provider**
#### Scenario: Provider 取消預約觸發 BookingCancelledNotification 至學員
- **WHEN** Provider 對 confirmed 預約呼叫 `PUT /api/provider/bookings/{id}/cancel`
- **THEN** `BookingCancelledNotification` 被派發至該預約的 **Member**
---
### Requirement: 預約列表端點測試覆蓋
測試套件 SHALL 驗證下列三個路由的資料隔離行為:
- `GET /api/member/bookings`middleware: `auth:sanctum`,隔離靠 `where member_id = auth()->id()`
- `GET /api/provider/bookings`middleware: `auth:sanctum`,隔離靠 `whereHas schedule.provider_id`
- `GET /api/admin/bookings`middleware: `auth:sanctum` + `admin`EnsureAdmin 於 middleware 層強制 403
#### Scenario: Member 只能看到自己的預約
- **WHEN** Member A 呼叫 `GET /api/member/bookings`,系統中同時存在 Member A 與 Member B 各一筆預約
- **THEN** 回傳 data 只包含 Member A 的 booking idMember B 的 booking id 不在 data 中
#### Scenario: Provider 只能看到屬於自己課程的預約
- **WHEN** Provider A 呼叫 `GET /api/provider/bookings`,系統中有屬於 Provider A 與 Provider B 課程各一筆預約
- **THEN** 回傳 data 只包含 Provider A 課程的 booking idProvider B 課程的 booking id 不在 data 中
#### Scenario: Admin 可查看所有預約
- **WHEN** Admin 呼叫 `GET /api/admin/bookings`,系統中有 2 筆不同 Member 的預約
- **THEN** 回傳 data 包含兩筆 booking id
#### Scenario: 未認證請求被拒絕
- **WHEN** 未帶 token 呼叫 `GET /api/member/bookings`
- **THEN** 回傳 HTTP 401
+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,
]);
}
}