From 49a4b8965593a350897a4c9a90e26ffb0f173c02 Mon Sep 17 00:00:00 2001 From: Hank Date: Tue, 16 Jun 2026 18:23:11 +0800 Subject: [PATCH] =?UTF-8?q?test(feature):=20=E8=A3=9C=E9=BD=8A=E4=BA=94?= =?UTF-8?q?=E5=80=8B=E6=A0=B8=E5=BF=83=E6=A5=AD=E5=8B=99=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=20Feature=20test=E2=80=94=E2=80=94Offer/Schedule=20CRUD?= =?UTF-8?q?=E3=80=81Admin=20=E7=AE=A1=E7=90=86=E3=80=81=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E8=A7=B8=E7=99=BC=E3=80=81=E9=A0=90=E7=B4=84=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../feature-test-coverage/.openspec.yaml | 2 + .../changes/feature-test-coverage/design.md | 56 +++++ .../changes/feature-test-coverage/proposal.md | 26 +++ .../specs/feature-test-coverage/spec.md | 146 +++++++++++++ .../changes/feature-test-coverage/tasks.md | 83 +++++++ openspec/specs/feature-test-coverage/spec.md | 144 +++++++++++++ tests/Feature/AdminUserManagementTest.php | 120 +++++++++++ tests/Feature/BookingListTest.php | 131 ++++++++++++ tests/Feature/NotificationTriggerTest.php | 167 +++++++++++++++ tests/Feature/ProviderOfferCrudTest.php | 143 +++++++++++++ tests/Feature/ProviderScheduleCrudTest.php | 202 ++++++++++++++++++ 11 files changed, 1220 insertions(+) create mode 100644 openspec/changes/feature-test-coverage/.openspec.yaml create mode 100644 openspec/changes/feature-test-coverage/design.md create mode 100644 openspec/changes/feature-test-coverage/proposal.md create mode 100644 openspec/changes/feature-test-coverage/specs/feature-test-coverage/spec.md create mode 100644 openspec/changes/feature-test-coverage/tasks.md create mode 100644 openspec/specs/feature-test-coverage/spec.md create mode 100644 tests/Feature/AdminUserManagementTest.php create mode 100644 tests/Feature/BookingListTest.php create mode 100644 tests/Feature/NotificationTriggerTest.php create mode 100644 tests/Feature/ProviderOfferCrudTest.php create mode 100644 tests/Feature/ProviderScheduleCrudTest.php diff --git a/openspec/changes/feature-test-coverage/.openspec.yaml b/openspec/changes/feature-test-coverage/.openspec.yaml new file mode 100644 index 0000000..a903f7f --- /dev/null +++ b/openspec/changes/feature-test-coverage/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-16 diff --git a/openspec/changes/feature-test-coverage/design.md b/openspec/changes/feature-test-coverage/design.md new file mode 100644 index 0000000..e4dd250 --- /dev/null +++ b/openspec/changes/feature-test-coverage/design.md @@ -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 | 路由已確認存在;角色資料隔離邊界 | diff --git a/openspec/changes/feature-test-coverage/proposal.md b/openspec/changes/feature-test-coverage/proposal.md new file mode 100644 index 0000000..2c43f64 --- /dev/null +++ b/openspec/changes/feature-test-coverage/proposal.md @@ -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 + + +## Impact + +- 新增 Feature test 檔案:`tests/Feature/` 下 5 個新測試類別(約 40~55 案例) +- 不修改任何 app 程式碼 +- 不影響現有 API 行為或資料結構 +- 現有 187 案例零 regression(Unit test 已驗證) diff --git a/openspec/changes/feature-test-coverage/specs/feature-test-coverage/spec.md b/openspec/changes/feature-test-coverage/specs/feature-test-coverage/spec.md new file mode 100644 index 0000000..676bc8b --- /dev/null +++ b/openspec/changes/feature-test-coverage/specs/feature-test-coverage/spec.md @@ -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 diff --git a/openspec/changes/feature-test-coverage/tasks.md b/openspec/changes/feature-test-coverage/tasks.md new file mode 100644 index 0000000..472a6e4 --- /dev/null +++ b/openspec/changes/feature-test-coverage/tasks.md @@ -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` diff --git a/openspec/specs/feature-test-coverage/spec.md b/openspec/specs/feature-test-coverage/spec.md new file mode 100644 index 0000000..46cf91b --- /dev/null +++ b/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 diff --git a/tests/Feature/AdminUserManagementTest.php b/tests/Feature/AdminUserManagementTest.php new file mode 100644 index 0000000..25fe966 --- /dev/null +++ b/tests/Feature/AdminUserManagementTest.php @@ -0,0 +1,120 @@ +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); + } +} diff --git a/tests/Feature/BookingListTest.php b/tests/Feature/BookingListTest.php new file mode 100644 index 0000000..c4a1798 --- /dev/null +++ b/tests/Feature/BookingListTest.php @@ -0,0 +1,131 @@ +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); + } +} diff --git a/tests/Feature/NotificationTriggerTest.php b/tests/Feature/NotificationTriggerTest.php new file mode 100644 index 0000000..3797b0e --- /dev/null +++ b/tests/Feature/NotificationTriggerTest.php @@ -0,0 +1,167 @@ +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); + } +} diff --git a/tests/Feature/ProviderOfferCrudTest.php b/tests/Feature/ProviderOfferCrudTest.php new file mode 100644 index 0000000..bf47156 --- /dev/null +++ b/tests/Feature/ProviderOfferCrudTest.php @@ -0,0 +1,143 @@ +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]); + } +} diff --git a/tests/Feature/ProviderScheduleCrudTest.php b/tests/Feature/ProviderScheduleCrudTest.php new file mode 100644 index 0000000..f549de9 --- /dev/null +++ b/tests/Feature/ProviderScheduleCrudTest.php @@ -0,0 +1,202 @@ +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, + ]); + } +}