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,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 案例零 regression(Unit 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 201,body 包含 `{ 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 403,body 包含 `{ status: false }`
|
||||
|
||||
#### Scenario: Provider 刪除自己的課程
|
||||
- **WHEN** 已認證的 Provider 送出 `DELETE /api/provider/offers/{id}`(該課程屬於此 Provider)
|
||||
- **THEN** 回傳 HTTP 200,body 包含 `{ 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 201,body 包含 `{ 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 200,DB 記錄已更新
|
||||
|
||||
#### 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`(記錄保留,不刪除);已是終態的 booking(completed/expired/已取消)不受影響
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Admin 使用者管理測試覆蓋
|
||||
測試套件 SHALL 驗證 Admin 查詢會員/教練列表、切換帳號啟用狀態、切換教練驗證狀態的正確行為。
|
||||
|
||||
#### Scenario: Admin 取得會員列表
|
||||
- **WHEN** Admin 送出 `GET /api/admin/members`
|
||||
- **THEN** 回傳 HTTP 200,data 陣列只包含 role=member 的使用者,含 meta 分頁資訊
|
||||
|
||||
#### Scenario: Admin 取得教練列表
|
||||
- **WHEN** Admin 送出 `GET /api/admin/providers`
|
||||
- **THEN** 回傳 HTTP 200,data 陣列只包含 role=provider 的使用者
|
||||
|
||||
#### Scenario: Admin 切換會員帳號啟用狀態
|
||||
- **WHEN** Admin 送出 `POST /api/admin/members/{id}/toggle-active`(目標會員 is_active=true)
|
||||
- **THEN** 回傳 HTTP 200,DB 記錄 is_active 由 true 變為 false
|
||||
|
||||
#### Scenario: Admin 切換教練驗證狀態並清除快取
|
||||
- **WHEN** Admin 送出 `POST /api/admin/providers/{id}/toggle-verified`(目標教練 is_verified=false)
|
||||
- **THEN** 回傳 HTTP 200,provider_profiles.is_verified 由 false 變為 true,diving_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 id;Member B 的 booking id 不在 data 中
|
||||
|
||||
#### Scenario: Provider 只能看到屬於自己課程的預約
|
||||
- **WHEN** Provider A 呼叫 `GET /api/provider/bookings`,系統中有屬於 Provider A 課程與 Provider B 課程各一筆預約
|
||||
- **THEN** 回傳 data 中只包含 Provider A 課程的 booking id;Provider 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::create,is_verified=true)
|
||||
- `makeOffer(User $provider): DivingOffer`(DivingOffer::create 最小必填欄位)
|
||||
- [x] 1.2 [整合測試] `test_provider_creates_offer_successfully`:POST 201,data.provider_id = auth provider id,DB 存在
|
||||
- [x] 1.3 [整合測試] `test_create_offer_missing_required_field_returns_422`:缺 title 送出,回傳 422(代表性一個欄位即可)
|
||||
- [x] 1.4 [整合測試] `test_provider_updates_own_offer`:PUT 200,DB 欄位已變更
|
||||
- [x] 1.5 [整合測試] `test_provider_cannot_update_others_offer`:Provider B 更新 Provider A 的課程,403
|
||||
- [x] 1.6 [整合測試] `test_provider_deletes_own_offer`:DELETE 200,DB 記錄不存在
|
||||
- [x] 1.7 [整合測試] `test_provider_cannot_delete_others_offer`:403
|
||||
- [x] 1.8 [整合測試] `test_unauthenticated_request_is_rejected`:無 token POST,401
|
||||
|
||||
## 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 201,status=open
|
||||
- [x] 2.3 [整合測試] `test_provider_cannot_create_schedule_for_others_offer`:403
|
||||
- [x] 2.4 [整合測試] `test_past_date_returns_422`:scheduled_date = yesterday,422(代表性驗證案例)
|
||||
- [x] 2.5 [整合測試] `test_provider_updates_schedule_max_participants`:PUT 200,DB 更新
|
||||
- [x] 2.6 [整合測試] `test_max_participants_below_current_returns_422`:current_participants=3,PUT max_participants=2,422
|
||||
- [x] 2.7 [整合測試] `test_provider_cannot_update_others_schedule`:403
|
||||
- [x] 2.8 [整合測試] `test_delete_schedule_marks_active_bookings_as_provider_cancelled`:建立一筆 pending + 一筆 confirmed booking,DELETE 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 actingAs,GET /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 + schedule(open, 未來日期) + 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 各有一筆 booking,actingAs ProviderA → `GET /api/provider/bookings`,斷言 data 中只含 ProviderA schedule 的 booking id
|
||||
- [x] 5.4 [整合測試] `test_admin_sees_all_bookings`:建立 2 筆不同 member 的 booking,actingAs 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 201,body 包含 `{ 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 403,body 包含 `{ status: false }`
|
||||
|
||||
#### Scenario: Provider 刪除自己的課程
|
||||
- **WHEN** 已認證的 Provider 送出 `DELETE /api/provider/offers/{id}`(該課程屬於此 Provider)
|
||||
- **THEN** 回傳 HTTP 200,body 包含 `{ 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 201,body 包含 `{ 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 200,DB 記錄已更新
|
||||
|
||||
#### 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`(記錄保留,不刪除);已是終態的 booking(completed/expired/已取消)不受影響
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Admin 使用者管理測試覆蓋
|
||||
測試套件 SHALL 驗證 Admin 查詢會員/教練列表、切換帳號啟用狀態、切換教練驗證狀態的正確行為。
|
||||
|
||||
#### Scenario: Admin 取得會員列表
|
||||
- **WHEN** Admin 送出 `GET /api/admin/members`
|
||||
- **THEN** 回傳 HTTP 200,data 陣列只包含 role=member 的使用者
|
||||
|
||||
#### Scenario: Admin 取得教練列表
|
||||
- **WHEN** Admin 送出 `GET /api/admin/providers`
|
||||
- **THEN** 回傳 HTTP 200,data 陣列只包含 role=provider 的使用者
|
||||
|
||||
#### Scenario: Admin 切換會員帳號啟用狀態
|
||||
- **WHEN** Admin 送出 `PUT /api/admin/members/{id}/toggle-active`(目標會員 is_active=true)
|
||||
- **THEN** 回傳 HTTP 200,DB 記錄 is_active 由 true 變為 false
|
||||
|
||||
#### Scenario: Admin 切換教練驗證狀態
|
||||
- **WHEN** Admin 送出 `PUT /api/admin/providers/{id}/toggle-verified`(目標教練 is_verified=false)
|
||||
- **THEN** 回傳 HTTP 200,provider_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 id;Member B 的 booking id 不在 data 中
|
||||
|
||||
#### Scenario: Provider 只能看到屬於自己課程的預約
|
||||
- **WHEN** Provider A 呼叫 `GET /api/provider/bookings`,系統中有屬於 Provider A 與 Provider B 課程各一筆預約
|
||||
- **THEN** 回傳 data 只包含 Provider A 課程的 booking id;Provider 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
|
||||
@@ -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