chore(openspec): 歸檔 feature-test-coverage、provider-verification-workflow 變更
Run Tests / test (pull_request) Failing after 1m53s
Run Tests / test (pull_request) Failing after 1m53s
兩個 change 全任務完成(feature-test-coverage 40/40、provider-verification-workflow 48/48)。 同步規格: - feature-test-coverage/spec.md 補齊 ## Requirements 區塊標頭(結構修正) - Admin 教練審核 scenario 改為 approve 端點(toggle-verified 已由 #36 移除) 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 已驗證)
|
||||
+146
@@ -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,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-06-11
|
||||
@@ -0,0 +1,57 @@
|
||||
# Design — provider-verification-workflow
|
||||
|
||||
## D1:四狀態而非三狀態
|
||||
|
||||
路線圖原列 pending/approved/rejected 三態,但「註冊後補件送審」決策(Hank 2026-06-11 拍板)使「註冊完還沒送審」與「已送審待審」必須區分——Admin 佇列只該出現後者。故:
|
||||
|
||||
```
|
||||
unsubmitted ──submit──> pending ──approve──> approved
|
||||
↑ │ │
|
||||
│ reject reject(撤銷,原因必填)
|
||||
│ ↓ ↓
|
||||
└──(重新上傳)── rejected ──submit──> pending
|
||||
```
|
||||
|
||||
- 合法轉移:`unsubmitted→pending`、`pending→approved|rejected`、`rejected→pending`、`approved→rejected`(撤銷)
|
||||
- `rejected→pending` 重送時清空 `rejection_reason`
|
||||
- 欄位用 string + 應用層常數(與 BookingStatus enum 同模式,建 `App\Enums\VerificationStatus`)
|
||||
|
||||
## D2:取代而非並存 boolean
|
||||
|
||||
`is_verified` 欄位**移除**(migration: true→approved、false→unsubmitted),不留同步欄位——兩個真實來源必然漂移(Rule 7)。API 相容由 `ProviderProfile::$appends` 的 `is_verified` accessor 提供(`= status === approved`),前端/Swagger 既有讀取點不破壞;查詢端(`visibleToPublic`、預約入口)改查 `verification_status`。
|
||||
|
||||
## D3:證照獨立資料表
|
||||
|
||||
`provider_certifications`(id, user_id, image_path, created_at)。不塞 JSON 欄位:要逐張刪除、要 FK 清理、上限 3 張需 count 查詢。圖片複用 `CompressesImages::compressToJpeg`(O3.1 的 trait,第二個使用者,驗證抽共用的價值),存 `providers/{user_id}/certifications/`。刪除政策:`unsubmitted`/`rejected` 可增刪;`pending`/`approved` 鎖定(審核依據不可變動)。
|
||||
|
||||
## D4:端點設計
|
||||
|
||||
**Provider(auth:sanctum + provider prefix)**
|
||||
- `GET /api/provider/verification`——status、rejection_reason、certifications[]
|
||||
- `POST /api/provider/verification/certifications`——上傳(≤3 張、≤10MB、壓縮);僅 unsubmitted/rejected
|
||||
- `DELETE /api/provider/verification/certifications/{id}`——僅 unsubmitted/rejected、僅本人
|
||||
- `POST /api/provider/verification/submit`——至少 1 張證照;unsubmitted/rejected → pending
|
||||
|
||||
**Admin(auth:sanctum + admin)**
|
||||
- `GET /api/admin/verifications?status=pending`——佇列(預設 pending,可查全部狀態),含教練資料與證照 URL
|
||||
- `PUT /api/admin/verifications/{userId}/approve`——pending → approved
|
||||
- `PUT /api/admin/verifications/{userId}/reject`——pending/approved → rejected,`reason` 必填(max 500)
|
||||
|
||||
approve/reject 皆 flush `diving_offers` 快取 tag(可見性立即生效,沿用 toggle 的既有做法)。`toggle-verified` 端點與 `AdminUserController::toggleProviderVerified` 移除——保留會提供繞過狀態機的後門。
|
||||
|
||||
控制器:新建 `ProviderVerificationController` 與 `AdminVerificationController`(單一職責),不塞進 AuthController/AdminUserController。
|
||||
|
||||
## D5:通知
|
||||
|
||||
`ProviderVerificationApprovedNotification`、`ProviderVerificationRejectedNotification`(含原因),照 Booking* 模式(database + mail channel,try/catch 包裹不阻斷主流程)。送審時**不**通知 Admin(Admin 有佇列頁;避免通知氾濫),列為未來選項。
|
||||
|
||||
## D6:前端
|
||||
|
||||
- **教練端** `views/coach/VerificationView.vue`(route `/coach/verification`):狀態卡(四態 + 駁回原因)、證照上傳/刪除(沿用 OfferFormView 的圖片上傳模式)、送審按鈕;CoachLayout 側欄加入口與狀態 badge
|
||||
- **Admin 端** `views/admin/ProvidersView.vue` 擴充:狀態 badge 四態化、「待審核」filter、展開查看證照圖、通過/駁回操作(駁回原因用 inline 輸入,沿用該頁既有操作風格);不另開新頁(教練列表即審核入口,避免兩頁資料重複)
|
||||
|
||||
## D7:Demo 資料與測試遷移
|
||||
|
||||
- DemoSeeder:3 位已驗證 → `approved`;1 位未驗證 → 給 1 張證照 + `pending`(展示審核佇列)
|
||||
- 既有測試 helper(DivingOfferVisibility / BookingLifecycle / BookingOversell / ReviewTest):`is_verified => true/false` 改 `verification_status => approved/unsubmitted`
|
||||
- 新測試:送審狀態機(含非法轉移)、證照上限與鎖定、Admin 審核權限邊界、通知發送、approve/reject 後可見性立即生效
|
||||
@@ -0,0 +1,26 @@
|
||||
## Why
|
||||
|
||||
目前教練驗證只有一顆 Admin 的 `is_verified` 開關(2026-06-11 修補後具備可見性約束力),但**沒有審核「流程」**:教練無從提交資質證明、Admin 沒有審核依據與佇列、駁回無原因可言、結果無通知。路線圖(`docs/analysis/2026-06-11-future-roadmap-feasibility.md` §2.1)將此列為金流前的信任基礎建設。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **資料模型**:`provider_profiles.is_verified`(boolean)升級為 `verification_status` 狀態機——`unsubmitted`(註冊預設)→ `pending`(已送審)→ `approved` / `rejected`(含 `rejection_reason`);新增 `provider_certifications` 資料表存證照圖片
|
||||
- **教練端**:後台新增「驗證申請」頁——上傳證照(1~3 張,複用 `CompressesImages` 壓縮管線)、送審、查看駁回原因、重新送審(產品決策:**註冊後補件送審**,不擋註冊流程)
|
||||
- **Admin 端**:審核佇列(pending 優先)、查看證照、通過/駁回(駁回原因必填);移除舊 `toggle-verified` 開關端點(與狀態機衝突)
|
||||
- **通知**:審核通過/駁回通知教練(站內 + Email,複用 notification 管線)
|
||||
- **相容性**:`ProviderProfile` 以 accessor 保留 API 輸出的 `is_verified` 欄位(= status 是否為 approved);`visibleToPublic` scope 與預約入口檢查改查 `verification_status = 'approved'`,語意不變
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `provider-verification`:boolean 語意升級為四狀態機;新增教練送審端點、Admin 審核端點、通知 requirements;可見性規則改以 `approved` 判定(行為等價)
|
||||
- `admin-user-management`:`toggle-verified` 端點移除,由 approve / reject 取代
|
||||
|
||||
## Impact
|
||||
|
||||
- **資料庫**:migration 轉換既有資料(`is_verified=true → approved`、`false → unsubmitted`)、新表 `provider_certifications`
|
||||
- **後端**:新增 `ProviderVerificationController`、`AdminVerificationController`、2 個 Notification class;修改 `DivingOffer::visibleToPublic`、`MemberBookingController`、`AdminUserController`(移除 toggle)、`ProviderProfile`、兩個 Seeder
|
||||
- **前端**:新增 `coach/VerificationView.vue`;改 `admin/ProvidersView.vue`(狀態 badge、審核操作)
|
||||
- **測試**:既有用到 `is_verified` 的 4 個測試檔改用 `verification_status`;新增送審流程與審核權限測試
|
||||
- **行為變更**:Admin 不再能單鍵切換驗證,必須走審核流(撤銷 = 駁回既有 approved 教練並附原因);既有預約不受狀態變動影響(沿用 provider-verification 規格既定政策)
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 管理員切換教練驗證狀態
|
||||
(本 requirement 由 provider-verification 的「Admin 審核佇列與裁決」取代)
|
||||
|
||||
`PUT /api/admin/providers/{id}/toggle-verified` SHALL 移除——單鍵切換允許繞過審核狀態機(無原因駁回、未送審直接通過)。教練驗證狀態的變更一律透過 `PUT /api/admin/verifications/{userId}/approve|reject`。
|
||||
|
||||
#### Scenario: toggle 端點回 404
|
||||
- **WHEN** Admin 請求 `PUT /api/admin/providers/{id}/toggle-verified`
|
||||
- **THEN** 回傳 HTTP 404
|
||||
|
||||
#### Scenario: 教練列表仍顯示驗證狀態
|
||||
- **WHEN** Admin 請求 `GET /api/admin/providers`
|
||||
- **THEN** 每筆教練資料包含 `verification_status`(與相容用 `is_verified` accessor)
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 教練驗證狀態機
|
||||
`provider_profiles.verification_status` SHALL 為四狀態字串:`unsubmitted`(註冊預設)、`pending`、`approved`、`rejected`。合法轉移僅限:`unsubmitted→pending`(送審)、`pending→approved`(通過)、`pending→rejected`(駁回)、`rejected→pending`(重新送審,同時清空 `rejection_reason`)、`approved→rejected`(撤銷,原因必填)。原 boolean `is_verified` 欄位移除,API 輸出以 accessor 保留 `is_verified`(= status 為 approved)。
|
||||
|
||||
#### Scenario: 新教練註冊預設未送審
|
||||
- **WHEN** 教練完成註冊
|
||||
- **THEN** `verification_status = unsubmitted`,不出現在 Admin 待審佇列
|
||||
|
||||
#### Scenario: 非法轉移被拒絕
|
||||
- **WHEN** 嘗試對 `unsubmitted` 教練執行 approve,或對 `approved` 教練執行 approve
|
||||
- **THEN** 回傳 HTTP 422,狀態不變
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 教練上傳證照與送審
|
||||
教練 SHALL 能於後台上傳 1~3 張證照圖片(jpeg/png/webp、≤10MB,伺服器端壓縮為 `.jpg`,存 `providers/{user_id}/certifications/`)並送出審核。證照僅於 `unsubmitted` / `rejected` 狀態可增刪;`pending` / `approved` 狀態鎖定。送審需至少 1 張證照。
|
||||
|
||||
#### Scenario: 上傳證照
|
||||
- **WHEN** `unsubmitted` 教練 `POST /api/provider/verification/certifications` 上傳合法圖片且現有 < 3 張
|
||||
- **THEN** 壓縮儲存並建立 `provider_certifications` 紀錄,回傳圖片資訊
|
||||
|
||||
#### Scenario: 超過 3 張上限
|
||||
- **WHEN** 教練已有 3 張證照再上傳
|
||||
- **THEN** 回傳 HTTP 422
|
||||
|
||||
#### Scenario: 送審成功
|
||||
- **WHEN** 教練至少有 1 張證照,`POST /api/provider/verification/submit`
|
||||
- **THEN** 狀態轉為 `pending`,`rejection_reason` 清空
|
||||
|
||||
#### Scenario: 無證照不可送審
|
||||
- **WHEN** 教練無任何證照即送審
|
||||
- **THEN** 回傳 HTTP 422,狀態不變
|
||||
|
||||
#### Scenario: pending 期間證照鎖定
|
||||
- **WHEN** `pending` 或 `approved` 教練嘗試上傳或刪除證照
|
||||
- **THEN** 回傳 HTTP 422
|
||||
|
||||
#### Scenario: 查詢自身驗證狀態
|
||||
- **WHEN** 教練 `GET /api/provider/verification`
|
||||
- **THEN** 回傳 `status`、`rejection_reason`、`certifications[]`(id、url)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Admin 審核佇列與裁決
|
||||
Admin SHALL 能查詢審核佇列(`GET /api/admin/verifications?status=pending`,預設 pending,含教練資料與證照 URL),並對 `pending` 教練執行通過(`PUT /api/admin/verifications/{userId}/approve`)或駁回(`PUT /api/admin/verifications/{userId}/reject`,`reason` 必填、max 500)。Admin SHALL 能駁回 `approved` 教練(撤銷驗證,原因必填)。裁決後 SHALL flush `diving_offers` 快取(可見性立即生效)。原 `PUT /api/admin/providers/{id}/toggle-verified` 端點 SHALL 移除。
|
||||
|
||||
#### Scenario: 通過審核
|
||||
- **WHEN** Admin 對 pending 教練執行 approve
|
||||
- **THEN** 狀態轉 `approved`,教練課程立即恢復公開可見性
|
||||
|
||||
#### Scenario: 駁回需附原因
|
||||
- **WHEN** Admin 執行 reject 未帶 `reason`
|
||||
- **THEN** 回傳 HTTP 422,狀態不變
|
||||
|
||||
#### Scenario: 撤銷已通過教練
|
||||
- **WHEN** Admin 對 approved 教練執行 reject 並附原因
|
||||
- **THEN** 狀態轉 `rejected`,課程立即從公開列表消失;既有預約照常(沿用既定政策)
|
||||
|
||||
#### Scenario: 舊 toggle 端點已移除
|
||||
- **WHEN** 任何人請求 `PUT /api/admin/providers/{id}/toggle-verified`
|
||||
- **THEN** 回傳 HTTP 404
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 審核結果通知
|
||||
系統 SHALL 於 approve / reject 後通知該教練(站內 + Email):通過通知告知課程已可公開曝光;駁回通知包含駁回原因。通知失敗不阻斷審核主流程。
|
||||
|
||||
#### Scenario: 通過通知
|
||||
- **WHEN** Admin approve
|
||||
- **THEN** 教練收到站內通知與 Email,內容含審核通過訊息
|
||||
|
||||
#### Scenario: 駁回通知含原因
|
||||
- **WHEN** Admin reject 並附原因
|
||||
- **THEN** 教練收到的通知內容包含該原因
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 未驗證教練的課程不對公開端點曝光
|
||||
公開課程端點(列表、詳情、schedules、reviews)SHALL 僅曝光 `provider_id` 為 null 或教練 `verification_status = 'approved'` 的課程(原以 `is_verified = true` 判定,語意等價)。`POST /api/member/bookings` 的可預約檢查同步改以 `approved` 判定。其餘行為(404、422、既有預約不受影響)不變。
|
||||
|
||||
#### Scenario: 非 approved 狀態一律不曝光
|
||||
- **WHEN** 課程教練的狀態為 unsubmitted / pending / rejected
|
||||
- **THEN** 公開端點不回傳該課程,新預約被拒
|
||||
@@ -0,0 +1,47 @@
|
||||
## 1. 資料模型
|
||||
|
||||
- [x] 1.1 新增 `App\Enums\VerificationStatus`(Unsubmitted / Pending / Approved / Rejected,含 `VALID_TRANSITIONS` 與 `canTransitionTo()`,仿 BookingStatus 模式)
|
||||
- [x] 1.2 Migration:`provider_profiles` 加 `verification_status`(string, default 'unsubmitted')與 `rejection_reason`(text nullable);資料轉換 `is_verified=true→approved`、`false→unsubmitted`;drop `is_verified`
|
||||
- [x] 1.3 Migration:新表 `provider_certifications`(id, user_id FK cascade, image_path, timestamps)
|
||||
- [x] 1.4 `ProviderProfile`:fillable/casts 更新、`is_verified` accessor(appends,= approved)、`certifications` 由 User 關聯
|
||||
- [x] 1.5 新 Model `ProviderCertification`(belongsTo User、url accessor,仿 CourseImage)
|
||||
|
||||
## 2. 教練端 API
|
||||
|
||||
- [x] 2.1 `ProviderVerificationController`:`show`(status + reason + certifications)、`uploadCertification`(≤3 張、複用 CompressesImages、僅 unsubmitted/rejected)、`deleteCertification`(僅本人 + 僅 unsubmitted/rejected)、`submit`(≥1 張證照、unsubmitted/rejected→pending、清 rejection_reason)
|
||||
- [x] 2.2 routes:`/api/provider/verification*` 四端點(auth:sanctum + provider group)
|
||||
|
||||
## 3. Admin 端 API
|
||||
|
||||
- [x] 3.1 `AdminVerificationController`:`index`(?status= 預設 pending,含教練資料與證照)、`approve`(pending→approved)、`reject`(pending|approved→rejected,reason 必填 max 500);approve/reject flush diving_offers 快取 + 通知教練
|
||||
- [x] 3.2 routes:`/api/admin/verifications*` 三端點;移除 toggle-verified route 與 `AdminUserController::toggleProviderVerified`
|
||||
- [x] 3.3 `AdminUserController::providers` 列表回應含 `verification_status`
|
||||
|
||||
## 4. 可見性判定切換
|
||||
|
||||
- [x] 4.1 `DivingOffer::visibleToPublic`:`is_verified=true` → `verification_status='approved'`
|
||||
- [x] 4.2 `MemberBookingController::store` 可預約檢查同步改判 approved
|
||||
|
||||
## 5. 通知
|
||||
|
||||
- [x] 5.1 `ProviderVerificationApprovedNotification` + `ProviderVerificationRejectedNotification`(含原因;database + mail,仿 Booking* 模式)
|
||||
|
||||
## 6. Seeder
|
||||
|
||||
- [x] 6.1 DemoSeeder / DevelopmentSeeder:`is_verified` 改 `verification_status`;demo 第 4 位教練給 1 張證照 + pending(展示佇列)
|
||||
|
||||
## 7. 前端
|
||||
|
||||
- [x] 7.1 `views/coach/VerificationView.vue` + route `/coach/verification`:狀態卡(四態 + 駁回原因)、證照上傳/刪除、送審;CoachLayout 側欄入口
|
||||
- [x] 7.2 `views/admin/ProvidersView.vue`:badge 四態化、待審 filter、查看證照、通過/駁回(原因輸入);移除 toggle 呼叫
|
||||
|
||||
## 8. 測試
|
||||
|
||||
- [x] 8.1 既有測試遷移:DivingOfferVisibilityTest / BookingLifecycleTest / BookingOversellTest / ReviewTest 的 helper 改 `verification_status`
|
||||
- [x] 8.2 `ProviderVerificationTest`(新):狀態機合法/非法轉移、證照上限/鎖定/本人限定、送審前置條件、重送清空原因
|
||||
- [x] 8.3 `AdminVerificationTest`(新):佇列過濾、approve/reject 權限(非 admin 403)、reject 無原因 422、撤銷 approved、裁決後可見性立即生效(快取)、通知發送斷言、toggle 端點 404
|
||||
- [x] 8.4 容器內全套件綠(基準 155 passed)
|
||||
|
||||
## 9. 規格同步
|
||||
|
||||
- [x] 9.1 specs 增量套用至主規格 `provider-verification` 與 `admin-user-management`
|
||||
Reference in New Issue
Block a user