diff --git a/app/Docs/MemberApiDoc.php b/app/Docs/MemberApiDoc.php index d310bd2..0e883d7 100644 --- a/app/Docs/MemberApiDoc.php +++ b/app/Docs/MemberApiDoc.php @@ -38,40 +38,48 @@ class MemberApiDoc * operationId="createBooking", * tags={"會員預約"}, * security={{"bearerAuth": {}}}, + * * @OA\RequestBody( * required=true, + * * @OA\JsonContent( * required={"offer_id","schedule_id","participants"}, + * * @OA\Property(property="offer_id", type="integer", example=1, description="課程 ID"), * @OA\Property(property="schedule_id", type="integer", example=2, description="時段 ID"), * @OA\Property(property="participants", type="integer", example=2, description="參加人數"), * @OA\Property(property="note", type="string", nullable=true, example="需要器材租借") * ) * ), + * * @OA\Response( * response=201, * description="預約建立成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="message", type="string", example="預約成功"), * @OA\Property(property="data", ref="#/components/schemas/Booking") * ) * ), + * * @OA\Response( * response=422, * description="驗證失敗或時段已滿", + * * @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse") * ), + * * @OA\Response( * response=403, * description="無權限(非 member 角色)", + * * @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse") * ) * ) */ - public function createBooking() - { - } + public function createBooking() {} /** * 取得我的預約列表 @@ -83,22 +91,26 @@ class MemberApiDoc * operationId="listMemberBookings", * tags={"會員預約"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)), * @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)), + * * @OA\Response( * response=200, * description="取得成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")), * @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta") * ) - * ) + * ), + * + * @OA\Response(response=403, description="非 member 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function listMemberBookings() - { - } + public function listMemberBookings() {} /** * 取得單一預約 @@ -109,22 +121,25 @@ class MemberApiDoc * operationId="getMemberBooking", * tags={"會員預約"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")), + * * @OA\Response( * response=200, * description="取得成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="data", ref="#/components/schemas/Booking") * ) * ), + * * @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), - * @OA\Response(response=403, description="無權限存取", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) + * @OA\Response(response=403, description="非 member 角色或非本人預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function getMemberBooking() - { - } + public function getMemberBooking() {} /** * 取消預約 @@ -136,22 +151,25 @@ class MemberApiDoc * operationId="cancelBooking", * tags={"會員預約"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")), + * * @OA\Response( * response=200, * description="取消成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="message", type="string", example="預約已取消") * ) * ), - * @OA\Response(response=403, description="無權限或狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), + * + * @OA\Response(response=403, description="非 member 角色、非本人預約或狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function cancelBooking() - { - } + public function cancelBooking() {} // ----------------------------------------------------------------------- // Member Reviews @@ -167,32 +185,37 @@ class MemberApiDoc * operationId="createReview", * tags={"會員評價"}, * security={{"bearerAuth": {}}}, + * * @OA\RequestBody( * required=true, + * * @OA\JsonContent( * required={"booking_id","rating"}, + * * @OA\Property(property="booking_id", type="integer", example=5), * @OA\Property(property="rating", type="integer", minimum=1, maximum=5, example=4), * @OA\Property(property="comment", type="string", nullable=true, example="課程非常棒!"), * @OA\Property(property="is_anonymous", type="boolean", example=false) * ) * ), + * * @OA\Response( * response=201, * description="評價建立成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="message", type="string", example="評價已提交"), * @OA\Property(property="data", ref="#/components/schemas/Review") * ) * ), - * @OA\Response(response=403, description="預約未完成或非本人預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), + * + * @OA\Response(response=403, description="非 member 角色、預約未完成或非本人預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function createReview() - { - } + public function createReview() {} /** * 更新評價 @@ -203,30 +226,36 @@ class MemberApiDoc * operationId="updateReview", * tags={"會員評價"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")), + * * @OA\RequestBody( * required=true, + * * @OA\JsonContent( + * * @OA\Property(property="rating", type="integer", minimum=1, maximum=5, example=5), * @OA\Property(property="comment", type="string", nullable=true, example="更新後的評語"), * @OA\Property(property="is_anonymous", type="boolean", example=true) * ) * ), + * * @OA\Response( * response=200, * description="更新成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="data", ref="#/components/schemas/Review") * ) * ), - * @OA\Response(response=403, description="非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), + * + * @OA\Response(response=403, description="非 member 角色或非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function updateReview() - { - } + public function updateReview() {} /** * 刪除評價 @@ -237,22 +266,25 @@ class MemberApiDoc * operationId="deleteReview", * tags={"會員評價"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")), + * * @OA\Response( * response=200, * description="刪除成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="message", type="string", example="評價已刪除") * ) * ), - * @OA\Response(response=403, description="非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), + * + * @OA\Response(response=403, description="非 member 角色或非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function deleteReview() - { - } + public function deleteReview() {} /** * 標記評價為有幫助 @@ -264,22 +296,25 @@ class MemberApiDoc * operationId="markReviewHelpful", * tags={"會員評價"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")), + * * @OA\Response( * response=200, * description="操作成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="helpful_count", type="integer", example=4), * @OA\Property(property="has_voted", type="boolean", example=true) * ) * ), + * * @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function markReviewHelpful() - { - } + public function markReviewHelpful() {} // ----------------------------------------------------------------------- // Booking Messages(即時訊息,Member + Provider 共用) @@ -295,10 +330,13 @@ class MemberApiDoc * operationId="getMessageUnreadCounts", * tags={"即時訊息"}, * security={{"bearerAuth": {}}}, + * * @OA\Response( * response=200, * description="取得成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property( * property="data", @@ -310,9 +348,7 @@ class MemberApiDoc * ) * ) */ - public function getMessageUnreadCounts() - { - } + public function getMessageUnreadCounts() {} /** * 取得預約訊息列表 @@ -324,17 +360,23 @@ class MemberApiDoc * operationId="listBookingMessages", * tags={"即時訊息"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="booking", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")), + * * @OA\Response( * response=200, * description="取得成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property( * property="data", * type="array", + * * @OA\Items( * type="object", + * * @OA\Property(property="id", type="integer", example=1), * @OA\Property(property="booking_id", type="integer", example=12), * @OA\Property(property="sender_type", type="string", enum={"member","provider"}, example="member"), @@ -346,13 +388,12 @@ class MemberApiDoc * ) * ) * ), + * * @OA\Response(response=403, description="非參與方或預約狀態不符", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function listBookingMessages() - { - } + public function listBookingMessages() {} /** * 發送預約訊息 @@ -364,21 +405,29 @@ class MemberApiDoc * operationId="sendBookingMessage", * tags={"即時訊息"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="booking", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")), + * * @OA\RequestBody( * required=true, + * * @OA\MediaType( * mediaType="multipart/form-data", + * * @OA\Schema( + * * @OA\Property(property="message", type="string", nullable=true, example="請問需要自備潛水裝備嗎?"), * @OA\Property(property="image", type="string", format="binary", nullable=true, description="圖片(jpg/png,message 與 image 至少擇一)") * ) * ) * ), + * * @OA\Response( * response=201, * description="訊息已送出", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property( * property="data", @@ -393,13 +442,12 @@ class MemberApiDoc * ) * ) * ), + * * @OA\Response(response=403, description="非參與方或預約狀態不符", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=422, description="message 與 image 皆為空", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function sendBookingMessage() - { - } + public function sendBookingMessage() {} /** * 標記訊息為已讀 @@ -411,21 +459,24 @@ class MemberApiDoc * operationId="markMessagesRead", * tags={"即時訊息"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="booking", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")), + * * @OA\Response( * response=200, * description="標記成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="message", type="string", example="已標記為已讀") * ) * ), + * * @OA\Response(response=403, description="非參與方", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function markMessagesRead() - { - } + public function markMessagesRead() {} // ----------------------------------------------------------------------- // Notifications (Member + Provider 共用) @@ -441,18 +492,24 @@ class MemberApiDoc * operationId="listNotifications", * tags={"通知"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)), * @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)), + * * @OA\Response( * response=200, * description="取得成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property( * property="data", * type="array", + * * @OA\Items( * type="object", + * * @OA\Property(property="id", type="integer", example=1), * @OA\Property(property="type", type="string", example="booking_confirmed"), * @OA\Property(property="title", type="string", example="預約已確認"), @@ -466,9 +523,7 @@ class MemberApiDoc * ) * ) */ - public function listNotifications() - { - } + public function listNotifications() {} /** * 取得未讀通知數量 @@ -479,19 +534,20 @@ class MemberApiDoc * operationId="getUnreadNotificationCount", * tags={"通知"}, * security={{"bearerAuth": {}}}, + * * @OA\Response( * response=200, * description="取得成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="unread_count", type="integer", example=3) * ) * ) * ) */ - public function getUnreadNotificationCount() - { - } + public function getUnreadNotificationCount() {} /** * 標記單一通知為已讀 @@ -502,21 +558,24 @@ class MemberApiDoc * operationId="markNotificationRead", * tags={"通知"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="id", in="path", required=true, description="通知 ID", @OA\Schema(type="integer")), + * * @OA\Response( * response=200, * description="標記成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="message", type="string", example="通知已標記為已讀") * ) * ), + * * @OA\Response(response=404, description="通知不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function markNotificationRead() - { - } + public function markNotificationRead() {} /** * 全部通知標記為已讀 @@ -527,19 +586,20 @@ class MemberApiDoc * operationId="markAllNotificationsRead", * tags={"通知"}, * security={{"bearerAuth": {}}}, + * * @OA\Response( * response=200, * description="標記成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="message", type="string", example="所有通知已標記為已讀") * ) * ) * ) */ - public function markAllNotificationsRead() - { - } + public function markAllNotificationsRead() {} /** * 刪除通知 @@ -550,19 +610,22 @@ class MemberApiDoc * operationId="deleteNotification", * tags={"通知"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="id", in="path", required=true, description="通知 ID", @OA\Schema(type="integer")), + * * @OA\Response( * response=200, * description="刪除成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="message", type="string", example="通知已刪除") * ) * ), + * * @OA\Response(response=404, description="通知不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function deleteNotification() - { - } + public function deleteNotification() {} } diff --git a/app/Docs/ProviderApiDoc.php b/app/Docs/ProviderApiDoc.php index 1c7a09b..a46efbb 100644 --- a/app/Docs/ProviderApiDoc.php +++ b/app/Docs/ProviderApiDoc.php @@ -42,10 +42,13 @@ class ProviderApiDoc * operationId="getProviderVerification", * tags={"教練驗證"}, * security={{"bearerAuth": {}}}, + * * @OA\Response( * response=200, * description="取得成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property( * property="data", @@ -55,8 +58,10 @@ class ProviderApiDoc * @OA\Property( * property="certifications", * type="array", + * * @OA\Items( * type="object", + * * @OA\Property(property="id", type="integer", example=1), * @OA\Property(property="url", type="string", example="http://localhost:8080/storage/providers/5/certifications/uuid.jpg") * ) @@ -64,12 +69,11 @@ class ProviderApiDoc * ) * ) * ), - * @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) + * + * @OA\Response(response=403, description="非 provider 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function getProviderVerification() - { - } + public function getProviderVerification() {} /** * 上傳證照圖片 @@ -81,20 +85,27 @@ class ProviderApiDoc * operationId="uploadCertification", * tags={"教練驗證"}, * security={{"bearerAuth": {}}}, + * * @OA\RequestBody( * required=true, + * * @OA\MediaType( * mediaType="multipart/form-data", + * * @OA\Schema( * required={"certification"}, + * * @OA\Property(property="certification", type="string", format="binary", description="證照圖片(jpg/png,最大 5MB)") * ) * ) * ), + * * @OA\Response( * response=200, * description="上傳成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property( * property="data", @@ -104,13 +115,12 @@ class ProviderApiDoc * ) * ) * ), + * * @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), - * @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) + * @OA\Response(response=403, description="非 provider 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function uploadCertification() - { - } + public function uploadCertification() {} /** * 刪除證照圖片 @@ -122,22 +132,25 @@ class ProviderApiDoc * operationId="deleteCertification", * tags={"教練驗證"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="id", in="path", required=true, description="證照 ID", @OA\Schema(type="integer")), + * * @OA\Response( * response=200, * description="刪除成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="message", type="string", example="證照已刪除") * ) * ), - * @OA\Response(response=403, description="無權限或當前狀態不可刪除", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), + * + * @OA\Response(response=403, description="非 provider 角色、非本人證照或當前狀態不可刪除", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=404, description="證照不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function deleteCertification() - { - } + public function deleteCertification() {} /** * 送出驗證申請 @@ -149,21 +162,23 @@ class ProviderApiDoc * operationId="submitVerification", * tags={"教練驗證"}, * security={{"bearerAuth": {}}}, + * * @OA\Response( * response=200, * description="送審成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="message", type="string", example="驗證申請已送出") * ) * ), + * * @OA\Response(response=422, description="尚未上傳證照或當前狀態不可送審", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), - * @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) + * @OA\Response(response=403, description="非 provider 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function submitVerification() - { - } + public function submitVerification() {} // ----------------------------------------------------------------------- // Provider Offers CRUD @@ -179,22 +194,26 @@ class ProviderApiDoc * operationId="listProviderOffers", * tags={"教練課程"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)), * @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)), + * * @OA\Response( * response=200, * description="取得成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/DivingOffer")), * @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta") * ) - * ) + * ), + * + * @OA\Response(response=403, description="非 provider 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function listProviderOffers() - { - } + public function listProviderOffers() {} /** * 建立課程 @@ -206,10 +225,13 @@ class ProviderApiDoc * operationId="createProviderOffer", * tags={"教練課程"}, * security={{"bearerAuth": {}}}, + * * @OA\RequestBody( * required=true, + * * @OA\JsonContent( * required={"title","description","region","price","max_participants"}, + * * @OA\Property(property="title", type="string", example="基礎開放水域課程"), * @OA\Property(property="description", type="string", example="適合初學者的 OWD 課程"), * @OA\Property(property="region", type="string", example="墾丁"), @@ -218,22 +240,24 @@ class ProviderApiDoc * @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"初學","OWD"}) * ) * ), + * * @OA\Response( * response=201, * description="課程建立成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="message", type="string", example="課程建立成功"), * @OA\Property(property="data", ref="#/components/schemas/DivingOffer") * ) * ), + * * @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), - * @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) + * @OA\Response(response=403, description="非 provider 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function createProviderOffer() - { - } + public function createProviderOffer() {} /** * 取得單一課程(Provider 視角) @@ -245,22 +269,25 @@ class ProviderApiDoc * operationId="getProviderOffer", * tags={"教練課程"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")), + * * @OA\Response( * response=200, * description="取得成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="data", ref="#/components/schemas/DivingOffer") * ) * ), - * @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), + * + * @OA\Response(response=403, description="非 provider 角色或非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function getProviderOffer() - { - } + public function getProviderOffer() {} /** * 更新課程 @@ -271,10 +298,14 @@ class ProviderApiDoc * operationId="updateProviderOffer", * tags={"教練課程"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")), + * * @OA\RequestBody( * required=true, + * * @OA\JsonContent( + * * @OA\Property(property="title", type="string", example="進階開放水域課程"), * @OA\Property(property="description", type="string", example="AOWD 課程"), * @OA\Property(property="region", type="string", example="小琉球"), @@ -284,21 +315,23 @@ class ProviderApiDoc * @OA\Property(property="is_active", type="boolean", example=true) * ) * ), + * * @OA\Response( * response=200, * description="更新成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="data", ref="#/components/schemas/DivingOffer") * ) * ), - * @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), + * + * @OA\Response(response=403, description="非 provider 角色或非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function updateProviderOffer() - { - } + public function updateProviderOffer() {} /** * 刪除課程 @@ -309,22 +342,25 @@ class ProviderApiDoc * operationId="deleteProviderOffer", * tags={"教練課程"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")), + * * @OA\Response( * response=200, * description="刪除成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="message", type="string", example="課程已刪除") * ) * ), - * @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), + * + * @OA\Response(response=403, description="非 provider 角色或非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function deleteProviderOffer() - { - } + public function deleteProviderOffer() {} // ----------------------------------------------------------------------- // Provider Offer Images @@ -340,32 +376,39 @@ class ProviderApiDoc * operationId="uploadOfferCover", * tags={"教練圖片"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")), + * * @OA\RequestBody( * required=true, + * * @OA\MediaType( * mediaType="multipart/form-data", + * * @OA\Schema( * required={"cover_image"}, + * * @OA\Property(property="cover_image", type="string", format="binary", description="封面圖片(jpg/png,最大 5MB)") * ) * ) * ), + * * @OA\Response( * response=200, * description="上傳成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="cover_image_url", type="string", example="https://example.com/covers/1.jpg") * ) * ), + * * @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), - * @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) + * @OA\Response(response=403, description="非 provider 角色或非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function uploadOfferCover() - { - } + public function uploadOfferCover() {} /** * 刪除封面圖片 @@ -376,21 +419,24 @@ class ProviderApiDoc * operationId="deleteOfferCover", * tags={"教練圖片"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")), + * * @OA\Response( * response=200, * description="刪除成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="message", type="string", example="封面圖片已刪除") * ) * ), - * @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) + * + * @OA\Response(response=403, description="非 provider 角色或非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function deleteOfferCover() - { - } + public function deleteOfferCover() {} /** * 上傳相簿圖片 @@ -402,37 +448,45 @@ class ProviderApiDoc * operationId="uploadOfferImages", * tags={"教練圖片"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")), + * * @OA\RequestBody( * required=true, + * * @OA\MediaType( * mediaType="multipart/form-data", + * * @OA\Schema( * required={"images[]"}, + * * @OA\Property( * property="images[]", * type="array", + * * @OA\Items(type="string", format="binary"), * description="相簿圖片(每張最大 5MB)" * ) * ) * ) * ), + * * @OA\Response( * response=200, * description="上傳成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="images", type="array", @OA\Items(type="string"), example={"https://example.com/img/1.jpg"}) * ) * ), + * * @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), - * @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) + * @OA\Response(response=403, description="非 provider 角色或非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function uploadOfferImages() - { - } + public function uploadOfferImages() {} /** * 刪除相簿圖片 @@ -443,22 +497,25 @@ class ProviderApiDoc * operationId="deleteOfferImage", * tags={"教練圖片"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="id", in="path", required=true, description="圖片 ID", @OA\Schema(type="integer")), + * * @OA\Response( * response=200, * description="刪除成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="message", type="string", example="圖片已刪除") * ) * ), - * @OA\Response(response=403, description="非本人圖片", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), + * + * @OA\Response(response=403, description="非 provider 角色或非本人圖片", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=404, description="圖片不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function deleteOfferImage() - { - } + public function deleteOfferImage() {} // ----------------------------------------------------------------------- // Provider Schedules @@ -474,20 +531,24 @@ class ProviderApiDoc * operationId="listProviderSchedules", * tags={"教練時段"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="offer_id", in="query", required=false, description="課程 ID 篩選", @OA\Schema(type="integer")), + * * @OA\Response( * response=200, * description="取得成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/CourseSchedule")) * ) - * ) + * ), + * + * @OA\Response(response=403, description="非 provider 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function listProviderSchedules() - { - } + public function listProviderSchedules() {} /** * 建立時段 @@ -498,31 +559,36 @@ class ProviderApiDoc * operationId="createProviderSchedule", * tags={"教練時段"}, * security={{"bearerAuth": {}}}, + * * @OA\RequestBody( * required=true, + * * @OA\JsonContent( * required={"offer_id","start_date","end_date","available_slots"}, + * * @OA\Property(property="offer_id", type="integer", example=1), * @OA\Property(property="start_date", type="string", format="date", example="2025-07-01"), * @OA\Property(property="end_date", type="string", format="date", example="2025-07-03"), * @OA\Property(property="available_slots", type="integer", example=4) * ) * ), + * * @OA\Response( * response=201, * description="時段建立成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="data", ref="#/components/schemas/CourseSchedule") * ) * ), + * * @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), - * @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) + * @OA\Response(response=403, description="非 provider 角色或非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function createProviderSchedule() - { - } + public function createProviderSchedule() {} /** * 更新時段 @@ -533,31 +599,37 @@ class ProviderApiDoc * operationId="updateProviderSchedule", * tags={"教練時段"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="id", in="path", required=true, description="時段 ID", @OA\Schema(type="integer")), + * * @OA\RequestBody( * required=true, + * * @OA\JsonContent( + * * @OA\Property(property="start_date", type="string", format="date", example="2025-07-05"), * @OA\Property(property="end_date", type="string", format="date", example="2025-07-07"), * @OA\Property(property="available_slots", type="integer", example=6), * @OA\Property(property="is_active", type="boolean", example=true) * ) * ), + * * @OA\Response( * response=200, * description="更新成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="data", ref="#/components/schemas/CourseSchedule") * ) * ), - * @OA\Response(response=403, description="非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), + * + * @OA\Response(response=403, description="非 provider 角色或非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=404, description="時段不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function updateProviderSchedule() - { - } + public function updateProviderSchedule() {} /** * 刪除時段 @@ -568,22 +640,25 @@ class ProviderApiDoc * operationId="deleteProviderSchedule", * tags={"教練時段"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="id", in="path", required=true, description="時段 ID", @OA\Schema(type="integer")), + * * @OA\Response( * response=200, * description="刪除成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="message", type="string", example="時段已刪除") * ) * ), - * @OA\Response(response=403, description="非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), + * + * @OA\Response(response=403, description="非 provider 角色或非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=404, description="時段不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function deleteProviderSchedule() - { - } + public function deleteProviderSchedule() {} // ----------------------------------------------------------------------- // Provider Bookings Management @@ -599,22 +674,26 @@ class ProviderApiDoc * operationId="listProviderBookings", * tags={"教練預約"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)), * @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)), + * * @OA\Response( * response=200, * description="取得成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")), * @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta") * ) - * ) + * ), + * + * @OA\Response(response=403, description="非 provider 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function listProviderBookings() - { - } + public function listProviderBookings() {} /** * 確認預約 @@ -625,23 +704,26 @@ class ProviderApiDoc * operationId="confirmBooking", * tags={"教練預約"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")), + * * @OA\Response( * response=200, * description="確認成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="message", type="string", example="預約已確認"), * @OA\Property(property="data", ref="#/components/schemas/Booking") * ) * ), - * @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), + * + * @OA\Response(response=403, description="非 provider 角色或非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=422, description="狀態不允許確認", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function confirmBooking() - { - } + public function confirmBooking() {} /** * 拒絕預約 @@ -652,23 +734,26 @@ class ProviderApiDoc * operationId="rejectBooking", * tags={"教練預約"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")), + * * @OA\Response( * response=200, * description="拒絕成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="message", type="string", example="預約已拒絕"), * @OA\Property(property="data", ref="#/components/schemas/Booking") * ) * ), - * @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), + * + * @OA\Response(response=403, description="非 provider 角色或非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=422, description="狀態不允許拒絕", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function rejectBooking() - { - } + public function rejectBooking() {} /** * 標記預約完成 @@ -679,23 +764,26 @@ class ProviderApiDoc * operationId="completeBookingByProvider", * tags={"教練預約"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")), + * * @OA\Response( * response=200, * description="標記成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="message", type="string", example="預約已完成"), * @OA\Property(property="data", ref="#/components/schemas/Booking") * ) * ), - * @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), + * + * @OA\Response(response=403, description="非 provider 角色或非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=422, description="狀態不允許完成", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function completeBookingByProvider() - { - } + public function completeBookingByProvider() {} /** * 取消預約(Provider) @@ -706,21 +794,24 @@ class ProviderApiDoc * operationId="cancelBookingByProvider", * tags={"教練預約"}, * security={{"bearerAuth": {}}}, + * * @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")), + * * @OA\Response( * response=200, * description="取消成功", + * * @OA\JsonContent( + * * @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="message", type="string", example="預約已取消"), * @OA\Property(property="data", ref="#/components/schemas/Booking") * ) * ), - * @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), + * + * @OA\Response(response=403, description="非 provider 角色或非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=422, description="狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function cancelBookingByProvider() - { - } + public function cancelBookingByProvider() {} } diff --git a/app/Http/Middleware/EnsureMember.php b/app/Http/Middleware/EnsureMember.php new file mode 100644 index 0000000..b085e5c --- /dev/null +++ b/app/Http/Middleware/EnsureMember.php @@ -0,0 +1,19 @@ +user() || ! $request->user()->isMember()) { + return response()->json(['status' => false, 'message' => '無權限存取'], 403); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/EnsureProvider.php b/app/Http/Middleware/EnsureProvider.php new file mode 100644 index 0000000..9f70d09 --- /dev/null +++ b/app/Http/Middleware/EnsureProvider.php @@ -0,0 +1,19 @@ +user() || ! $request->user()->isProvider()) { + return response()->json(['status' => false, 'message' => '無權限存取'], 403); + } + + return $next($request); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 00ffe4f..246f7b6 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -16,6 +16,8 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->trustProxies(at: ['*']); $middleware->alias([ 'admin' => \App\Http\Middleware\EnsureAdmin::class, + 'member' => \App\Http\Middleware\EnsureMember::class, + 'provider' => \App\Http\Middleware\EnsureProvider::class, ]); }) ->withExceptions(function (Exceptions $exceptions) { diff --git a/openspec/changes/add-role-middleware/tasks.md b/openspec/changes/add-role-middleware/tasks.md index 33f6ed0..c437d97 100644 --- a/openspec/changes/add-role-middleware/tasks.md +++ b/openspec/changes/add-role-middleware/tasks.md @@ -4,36 +4,36 @@ ## 1. Member 角色 middleware -- [ ] 1.1 [後端] 新增 `app/Http/Middleware/EnsureMember.php`(結構同 `EnsureAdmin`,改用 `isMember()`),並在 `bootstrap/app.php` 的 `$middleware->alias([...])` 加上 `'member' => \App\Http\Middleware\EnsureMember::class`;驗證:1.3 起的測試通過 -- [ ] 1.2 [後端] `routes/api.php` 的 member 群組改為 `Route::middleware(['auth:sanctum', 'member'])->prefix('member')`;驗證:`php artisan route:list --path=api/member` 顯示 `member` middleware,且 `register`、`login` 不受影響 -- [ ] 1.3 [整合測試] `test_member_can_access_member_routes`:member 呼叫 `GET /api/member/profile`、`GET /api/member/bookings` 回 200(對應 Scenario「member 可存取 member 路由」) -- [ ] 1.4 [整合測試] `test_provider_is_rejected_on_member_routes`:provider 呼叫 `GET /api/member/profile`、`GET /api/member/bookings`、`POST /api/member/bookings`、`POST /api/member/reviews`,皆回 403,並斷言 body 為 `{ status: false, message: "無權限存取" }`;`POST /api/member/reviews` 另斷言 `reviews` 筆數不變(對應 Scenario「provider 呼叫 member 路由被拒絕」) -- [ ] 1.5 [整合測試] `test_admin_is_rejected_on_member_routes`:admin 呼叫 `GET /api/member/profile`、`GET /api/member/bookings`、`POST /api/member/bookings`,皆回 403 與相同 body(對應 Scenario「admin 呼叫 member 路由被拒絕」) -- [ ] 1.6 [整合測試] `test_non_member_booking_attempt_creates_no_booking`:建立可預約時段,provider 帶合法 `schedule_id` 呼叫 `POST /api/member/bookings`,回 403,`bookings` 筆數與 `current_participants` 不變(對應 Scenario「非 member 建立預約不會產生資料」) -- [ ] 1.7 [整合測試] `test_unauthenticated_request_is_rejected_on_member_routes`:不帶 token 呼叫 `GET /api/member/profile`、`GET /api/member/bookings` 回 401(對應 Scenario「未登入呼叫 member 路由」) +- [x] 1.1 [後端] 新增 `app/Http/Middleware/EnsureMember.php`(結構同 `EnsureAdmin`,改用 `isMember()`),並在 `bootstrap/app.php` 的 `$middleware->alias([...])` 加上 `'member' => \App\Http\Middleware\EnsureMember::class`;驗證:1.3 起的測試通過 +- [x] 1.2 [後端] `routes/api.php` 的 member 群組改為 `Route::middleware(['auth:sanctum', 'member'])->prefix('member')`;驗證:`php artisan route:list --path=api/member` 顯示 `member` middleware,且 `register`、`login` 不受影響 +- [x] 1.3 [整合測試] `test_member_can_access_member_routes`:member 呼叫 `GET /api/member/profile`、`GET /api/member/bookings` 回 200(對應 Scenario「member 可存取 member 路由」) +- [x] 1.4 [整合測試] `test_provider_is_rejected_on_member_routes`:provider 呼叫 `GET /api/member/profile`、`GET /api/member/bookings`、`POST /api/member/bookings`、`POST /api/member/reviews`,皆回 403,並斷言 body 為 `{ status: false, message: "無權限存取" }`;`POST /api/member/reviews` 另斷言 `reviews` 筆數不變(對應 Scenario「provider 呼叫 member 路由被拒絕」) +- [x] 1.5 [整合測試] `test_admin_is_rejected_on_member_routes`:admin 呼叫 `GET /api/member/profile`、`GET /api/member/bookings`、`POST /api/member/bookings`,皆回 403 與相同 body(對應 Scenario「admin 呼叫 member 路由被拒絕」) +- [x] 1.6 [整合測試] `test_non_member_booking_attempt_creates_no_booking`:建立可預約時段,provider 帶合法 `schedule_id` 呼叫 `POST /api/member/bookings`,回 403,`bookings` 筆數與 `current_participants` 不變(對應 Scenario「非 member 建立預約不會產生資料」) +- [x] 1.7 [整合測試] `test_unauthenticated_request_is_rejected_on_member_routes`:不帶 token 呼叫 `GET /api/member/profile`、`GET /api/member/bookings` 回 401(對應 Scenario「未登入呼叫 member 路由」) ## 2. Provider 角色 middleware -- [ ] 2.1 [後端] 新增 `app/Http/Middleware/EnsureProvider.php`(結構同 `EnsureAdmin`,改用 `isProvider()`,**不檢查** `verification_status`),並在 `bootstrap/app.php` 加上 `'provider' => \App\Http\Middleware\EnsureProvider::class`;驗證:2.3 起的測試通過 -- [ ] 2.2 [後端] `routes/api.php` 的 provider 群組改為 `Route::middleware(['auth:sanctum', 'provider'])->prefix('provider')`;驗證:`php artisan route:list --path=api/provider` 顯示 `provider` middleware,且 `register`、`login` 不受影響 -- [ ] 2.3 [整合測試] `test_provider_can_access_provider_routes`:provider(含 `providerProfile`)呼叫 `GET /api/provider/profile`、`/offers`、`/schedules`、`/bookings` 回 200(對應 Scenario「provider 可存取 provider 路由」) -- [ ] 2.4 [整合測試] `test_unapproved_provider_can_access_verification`:`verification_status` 為 `unsubmitted`、`pending`、`rejected` 的 provider 呼叫 `GET /api/provider/verification` 回 200(對應 Scenario「未通過審核的 provider 仍可進入 provider 路由」) -- [ ] 2.5 [整合測試] `test_member_is_rejected_on_provider_routes`:member 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`、`POST /api/provider/offers`、`POST /api/provider/schedules`、`GET /api/provider/bookings`、`GET /api/provider/verification`,皆回 403 與 `{ status: false, message: "無權限存取" }`;`POST /api/provider/schedules` 另斷言 `course_schedules` 筆數不變(對應 Scenario「member 呼叫 provider 路由被拒絕」) -- [ ] 2.6 [整合測試] `test_admin_is_rejected_on_provider_routes`:admin 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`、`POST /api/provider/offers`、`GET /api/provider/bookings`,皆回 403 與相同 body(對應 Scenario「admin 呼叫 provider 路由被拒絕」) -- [ ] 2.7 [整合測試] `test_non_provider_offer_attempt_creates_no_offer`:member 帶通過驗證的完整課程資料呼叫 `POST /api/provider/offers`,回 403,`diving_offers` 筆數不變(對應 Scenario「非 provider 建立課程不會產生資料」) -- [ ] 2.8 [整合測試] `test_unauthenticated_request_is_rejected_on_provider_routes`:不帶 token 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers` 回 401(對應 Scenario「未登入呼叫 provider 路由」) +- [x] 2.1 [後端] 新增 `app/Http/Middleware/EnsureProvider.php`(結構同 `EnsureAdmin`,改用 `isProvider()`,**不檢查** `verification_status`),並在 `bootstrap/app.php` 加上 `'provider' => \App\Http\Middleware\EnsureProvider::class`;驗證:2.3 起的測試通過 +- [x] 2.2 [後端] `routes/api.php` 的 provider 群組改為 `Route::middleware(['auth:sanctum', 'provider'])->prefix('provider')`;驗證:`php artisan route:list --path=api/provider` 顯示 `provider` middleware,且 `register`、`login` 不受影響 +- [x] 2.3 [整合測試] `test_provider_can_access_provider_routes`:provider(含 `providerProfile`)呼叫 `GET /api/provider/profile`、`/offers`、`/schedules`、`/bookings` 回 200(對應 Scenario「provider 可存取 provider 路由」) +- [x] 2.4 [整合測試] `test_unapproved_provider_can_access_verification`:`verification_status` 為 `unsubmitted`、`pending`、`rejected` 的 provider 呼叫 `GET /api/provider/verification` 回 200(對應 Scenario「未通過審核的 provider 仍可進入 provider 路由」) +- [x] 2.5 [整合測試] `test_member_is_rejected_on_provider_routes`:member 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`、`POST /api/provider/offers`、`POST /api/provider/schedules`、`GET /api/provider/bookings`、`GET /api/provider/verification`,皆回 403 與 `{ status: false, message: "無權限存取" }`;`POST /api/provider/schedules` 另斷言 `course_schedules` 筆數不變(對應 Scenario「member 呼叫 provider 路由被拒絕」) +- [x] 2.6 [整合測試] `test_admin_is_rejected_on_provider_routes`:admin 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`、`POST /api/provider/offers`、`GET /api/provider/bookings`,皆回 403 與相同 body(對應 Scenario「admin 呼叫 provider 路由被拒絕」) +- [x] 2.7 [整合測試] `test_non_provider_offer_attempt_creates_no_offer`:member 帶通過驗證的完整課程資料呼叫 `POST /api/provider/offers`,回 403,`diving_offers` 筆數不變(對應 Scenario「非 provider 建立課程不會產生資料」) +- [x] 2.8 [整合測試] `test_unauthenticated_request_is_rejected_on_provider_routes`:不帶 token 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers` 回 401(對應 Scenario「未登入呼叫 provider 路由」) ## 3. 共用路由與既有規格的回歸 -- [ ] 3.1 [整合測試] `test_member_and_provider_can_read_notifications`:member 與 provider 各自呼叫 `GET /api/notifications` 回 200(對應 Scenario「member 與 provider 皆可讀取通知」) -- [ ] 3.2 [整合測試] `test_member_and_provider_can_read_unread_message_counts`:member 與 provider 各自呼叫 `GET /api/bookings/messages/unread-counts` 回 200(對應 Scenario「member 與 provider 皆可讀取未讀訊息數」) -- [ ] 3.3 [整合測試] 執行 `php artisan test --filter=BookingListTest`,確認 `feature-test-coverage`「預約列表端點測試覆蓋」的四個 Scenario(member 隔離、provider 隔離、admin 全部、未認證 401)在新 middleware 下仍全綠(「非 member/非 provider 於 middleware 層 403」由 1.4、1.5、2.5、2.6 覆蓋);同時更新 `tests/Feature/BookingListTest.php` 第 19–24 行 docblock 中「無 role check」「不靠 middleware role 強制」的過時描述 +- [x] 3.1 [整合測試] `test_member_and_provider_can_read_notifications`:member 與 provider 各自呼叫 `GET /api/notifications` 回 200(對應 Scenario「member 與 provider 皆可讀取通知」) +- [x] 3.2 [整合測試] `test_member_and_provider_can_read_unread_message_counts`:member 與 provider 各自呼叫 `GET /api/bookings/messages/unread-counts` 回 200(對應 Scenario「member 與 provider 皆可讀取未讀訊息數」) +- [x] 3.3 [整合測試] 執行 `php artisan test --filter=BookingListTest`,確認 `feature-test-coverage`「預約列表端點測試覆蓋」的四個 Scenario(member 隔離、provider 隔離、admin 全部、未認證 401)在新 middleware 下仍全綠(「非 member/非 provider 於 middleware 層 403」由 1.4、1.5、2.5、2.6 覆蓋);同時更新 `tests/Feature/BookingListTest.php` 第 19–24 行 docblock 中「無 role check」「不靠 middleware role 強制」的過時描述 ## 4. 全套回歸、Swagger 與格式 -- [ ] 4.1 [整合測試] 執行完整 `php artisan test`(需在容器內,因 `CourseImageTest` 需要 GD);若有既有測試因角色錯誤改回 403,判斷是測試用錯角色(修正測試並在 commit 說明)還是依賴跨角色行為(停下回報) -- [ ] 4.2 [後端] 核對 `app/Docs/MemberApiDoc.php`、`app/Docs/ProviderApiDoc.php` 每個端點的 403 回應描述涵蓋「角色不符」;缺的補上,並執行 `php artisan l5-swagger:generate` 確認無錯誤 -- [ ] 4.3 [後端] 執行 `./vendor/bin/pint --test app/Http/Middleware/EnsureMember.php app/Http/Middleware/EnsureProvider.php bootstrap/app.php routes/api.php tests/Feature/RoleAuthorizationTest.php`(以及 4.1/4.2 若有修改的檔案),確認無格式問題 +- [x] 4.1 [整合測試] 執行完整 `php artisan test`(需在容器內,因 `CourseImageTest` 需要 GD);若有既有測試因角色錯誤改回 403,判斷是測試用錯角色(修正測試並在 commit 說明)還是依賴跨角色行為(停下回報) +- [x] 4.2 [後端] 核對 `app/Docs/MemberApiDoc.php`、`app/Docs/ProviderApiDoc.php` 每個端點的 403 回應描述涵蓋「角色不符」;缺的補上,並執行 `php artisan l5-swagger:generate` 確認無錯誤 +- [x] 4.3 [後端] 執行 `./vendor/bin/pint --test app/Http/Middleware/EnsureMember.php app/Http/Middleware/EnsureProvider.php bootstrap/app.php routes/api.php tests/Feature/RoleAuthorizationTest.php`(以及 4.1/4.2 若有修改的檔案),確認無格式問題 ## 5. 手動驗證 diff --git a/routes/api.php b/routes/api.php index d1d5c62..b5fede7 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,22 +1,22 @@ ['auth:sanctum']]); @@ -24,7 +24,7 @@ Broadcast::routes(['middleware' => ['auth:sanctum']]); Route::get('/diving-offers', [DivingOfferController::class, 'index']); Route::get('/diving-offers/{id}', [DivingOfferController::class, 'show']); Route::get('/diving-offers/{id}/schedules', [ScheduleController::class, 'publicList']); -Route::get('/diving-offers/{id}/reviews', [ReviewController::class, 'publicList']); +Route::get('/diving-offers/{id}/reviews', [ReviewController::class, 'publicList']); // 會員註冊/登入 Route::post('/member/register', [AuthController::class, 'registerMember']); @@ -37,7 +37,7 @@ Route::middleware([\Illuminate\Session\Middleware\StartSession::class])->group(f }); // 會員專屬 API(需登入) -Route::middleware(['auth:sanctum'])->prefix('member')->group(function () { +Route::middleware(['auth:sanctum', 'member'])->prefix('member')->group(function () { // 會員登出 Route::post('/logout', [AuthController::class, 'logoutMember']); // Token Refresh @@ -49,14 +49,14 @@ Route::middleware(['auth:sanctum'])->prefix('member')->group(function () { // 修改密碼 Route::put('/change-password', [AuthController::class, 'changeMemberPassword']); // 預約 - Route::get('/bookings', [MemberBookingController::class, 'index']); - Route::post('/bookings', [MemberBookingController::class, 'store']); - Route::get('/bookings/{id}', [MemberBookingController::class, 'show']); - Route::delete('/bookings/{id}', [MemberBookingController::class, 'destroy']); + Route::get('/bookings', [MemberBookingController::class, 'index']); + Route::post('/bookings', [MemberBookingController::class, 'store']); + Route::get('/bookings/{id}', [MemberBookingController::class, 'show']); + Route::delete('/bookings/{id}', [MemberBookingController::class, 'destroy']); // 評價 - Route::post('/reviews', [ReviewController::class, 'store']); - Route::put('/reviews/{id}', [ReviewController::class, 'update']); - Route::delete('/reviews/{id}',[ReviewController::class, 'destroy']); + Route::post('/reviews', [ReviewController::class, 'store']); + Route::put('/reviews/{id}', [ReviewController::class, 'update']); + Route::delete('/reviews/{id}', [ReviewController::class, 'destroy']); }); // 有幫助投票(需登入,但不限 member prefix) @@ -67,7 +67,7 @@ Route::post('/provider/register', [AuthController::class, 'registerProvider']); Route::middleware('throttle:10,1')->post('/provider/login', [AuthController::class, 'loginProvider']); // 服務提供者專屬 API(需登入) -Route::middleware(['auth:sanctum'])->prefix('provider')->group(function () { +Route::middleware(['auth:sanctum', 'provider'])->prefix('provider')->group(function () { // 服務提供者登出 Route::post('/logout', [AuthController::class, 'logoutProvider']); // Token Refresh @@ -79,32 +79,32 @@ Route::middleware(['auth:sanctum'])->prefix('provider')->group(function () { // 修改密碼 Route::put('/change-password', [AuthController::class, 'changeProviderPassword']); // 教練課程管理 - Route::get('/offers', [ProviderOfferController::class, 'index']); - Route::post('/offers', [ProviderOfferController::class, 'store']); - Route::get('/offers/{id}', [ProviderOfferController::class, 'show']); - Route::put('/offers/{id}', [ProviderOfferController::class, 'update']); - Route::delete('/offers/{id}', [ProviderOfferController::class, 'destroy']); + Route::get('/offers', [ProviderOfferController::class, 'index']); + Route::post('/offers', [ProviderOfferController::class, 'store']); + Route::get('/offers/{id}', [ProviderOfferController::class, 'show']); + Route::put('/offers/{id}', [ProviderOfferController::class, 'update']); + Route::delete('/offers/{id}', [ProviderOfferController::class, 'destroy']); // 驗證申請(證照送審) - Route::get('/verification', [\App\Http\Controllers\API\ProviderVerificationController::class, 'show']); - Route::post('/verification/certifications', [\App\Http\Controllers\API\ProviderVerificationController::class, 'uploadCertification']); - Route::delete('/verification/certifications/{id}', [\App\Http\Controllers\API\ProviderVerificationController::class, 'deleteCertification']); - Route::post('/verification/submit', [\App\Http\Controllers\API\ProviderVerificationController::class, 'submit']); + Route::get('/verification', [\App\Http\Controllers\API\ProviderVerificationController::class, 'show']); + Route::post('/verification/certifications', [\App\Http\Controllers\API\ProviderVerificationController::class, 'uploadCertification']); + Route::delete('/verification/certifications/{id}', [\App\Http\Controllers\API\ProviderVerificationController::class, 'deleteCertification']); + Route::post('/verification/submit', [\App\Http\Controllers\API\ProviderVerificationController::class, 'submit']); // 課程圖片 - Route::post('/offers/{id}/cover', [CourseImageController::class, 'uploadCover']); - Route::delete('/offers/{id}/cover', [CourseImageController::class, 'deleteCover']); - Route::post('/offers/{id}/images', [CourseImageController::class, 'uploadImage']); - Route::delete('/images/{id}', [CourseImageController::class, 'deleteImage']); + Route::post('/offers/{id}/cover', [CourseImageController::class, 'uploadCover']); + Route::delete('/offers/{id}/cover', [CourseImageController::class, 'deleteCover']); + Route::post('/offers/{id}/images', [CourseImageController::class, 'uploadImage']); + Route::delete('/images/{id}', [CourseImageController::class, 'deleteImage']); // 時段管理 - Route::get('/schedules', [ScheduleController::class, 'index']); - Route::post('/schedules', [ScheduleController::class, 'store']); - Route::put('/schedules/{id}', [ScheduleController::class, 'update']); - Route::delete('/schedules/{id}', [ScheduleController::class, 'destroy']); + Route::get('/schedules', [ScheduleController::class, 'index']); + Route::post('/schedules', [ScheduleController::class, 'store']); + Route::put('/schedules/{id}', [ScheduleController::class, 'update']); + Route::delete('/schedules/{id}', [ScheduleController::class, 'destroy']); // 預約管理 - Route::get('/bookings', [ProviderBookingController::class, 'index']); - Route::put('/bookings/{id}/confirm', [ProviderBookingController::class, 'confirm']); - Route::put('/bookings/{id}/reject', [ProviderBookingController::class, 'reject']); - Route::put('/bookings/{id}/cancel', [ProviderBookingController::class, 'cancel']); - Route::put('/bookings/{id}/complete', [ProviderBookingController::class, 'complete']); + Route::get('/bookings', [ProviderBookingController::class, 'index']); + Route::put('/bookings/{id}/confirm', [ProviderBookingController::class, 'confirm']); + Route::put('/bookings/{id}/reject', [ProviderBookingController::class, 'reject']); + Route::put('/bookings/{id}/cancel', [ProviderBookingController::class, 'cancel']); + Route::put('/bookings/{id}/complete', [ProviderBookingController::class, 'complete']); }); // 管理員登入(帳號建立僅限主機端 php artisan app:create-admin,不提供公開註冊) @@ -129,42 +129,42 @@ Route::middleware(['auth:sanctum', 'admin'])->prefix('admin')->group(function () // 統計數據 Route::get('/stats', [AdminStatsController::class, 'index']); // 用戶管理 - Route::get('/members', [AdminUserController::class, 'members']); - Route::get('/members/{id}', [AdminUserController::class, 'member']); - Route::put('/members/{id}/toggle-active', [AdminUserController::class, 'toggleMemberActive']); - Route::get('/providers', [AdminUserController::class, 'providers']); - Route::get('/providers/{id}', [AdminUserController::class, 'provider']); - Route::put('/providers/{id}/toggle-active', [AdminUserController::class, 'toggleProviderActive']); + Route::get('/members', [AdminUserController::class, 'members']); + Route::get('/members/{id}', [AdminUserController::class, 'member']); + Route::put('/members/{id}/toggle-active', [AdminUserController::class, 'toggleMemberActive']); + Route::get('/providers', [AdminUserController::class, 'providers']); + Route::get('/providers/{id}', [AdminUserController::class, 'provider']); + Route::put('/providers/{id}/toggle-active', [AdminUserController::class, 'toggleProviderActive']); // 教練審核(toggle-verified 已由審核狀態機取代) - Route::get('/verifications', [\App\Http\Controllers\API\AdminVerificationController::class, 'index']); - Route::put('/verifications/{userId}/approve', [\App\Http\Controllers\API\AdminVerificationController::class, 'approve']); - Route::put('/verifications/{userId}/reject', [\App\Http\Controllers\API\AdminVerificationController::class, 'reject']); + Route::get('/verifications', [\App\Http\Controllers\API\AdminVerificationController::class, 'index']); + Route::put('/verifications/{userId}/approve', [\App\Http\Controllers\API\AdminVerificationController::class, 'approve']); + Route::put('/verifications/{userId}/reject', [\App\Http\Controllers\API\AdminVerificationController::class, 'reject']); // 課程管理 - Route::get('/offers', [AdminOfferController::class, 'index']); - Route::delete('/offers/{id}', [AdminOfferController::class, 'destroy']); + Route::get('/offers', [AdminOfferController::class, 'index']); + Route::delete('/offers/{id}', [AdminOfferController::class, 'destroy']); // 預約管理 - Route::get('/bookings', [AdminBookingController::class, 'index']); - Route::put('/bookings/{id}/complete', [AdminBookingController::class, 'complete']); + Route::get('/bookings', [AdminBookingController::class, 'index']); + Route::put('/bookings/{id}/complete', [AdminBookingController::class, 'complete']); // 評價管理 - Route::get('/reviews', [AdminReviewController::class, 'index']); - Route::delete('/reviews/{id}', [AdminReviewController::class, 'destroy']); + Route::get('/reviews', [AdminReviewController::class, 'index']); + Route::delete('/reviews/{id}', [AdminReviewController::class, 'destroy']); }); // 通知(Member + Provider 共用) Route::middleware('auth:sanctum')->prefix('notifications')->group(function () { - Route::get('/', [NotificationController::class, 'index']); + Route::get('/', [NotificationController::class, 'index']); Route::get('/unread-count', [NotificationController::class, 'unreadCount']); - Route::patch('/read-all', [NotificationController::class, 'markAllRead']); - Route::patch('/{id}/read', [NotificationController::class, 'markRead']); - Route::delete('/{id}', [NotificationController::class, 'destroy']); + Route::patch('/read-all', [NotificationController::class, 'markAllRead']); + Route::patch('/{id}/read', [NotificationController::class, 'markRead']); + Route::delete('/{id}', [NotificationController::class, 'destroy']); }); // 即時訊息(Member + Provider 共用,依 booking 參與方驗證) Route::middleware('auth:sanctum')->group(function () { // unread-counts 必須在 {booking} 之前,否則會被 route model binding 吃掉 - Route::get('/bookings/messages/unread-counts', [BookingMessageController::class, 'unreadCounts']); - Route::get('/bookings/{booking}/messages', [BookingMessageController::class, 'index']); - Route::post('/bookings/{booking}/messages', [BookingMessageController::class, 'store']); + Route::get('/bookings/messages/unread-counts', [BookingMessageController::class, 'unreadCounts']); + Route::get('/bookings/{booking}/messages', [BookingMessageController::class, 'index']); + Route::post('/bookings/{booking}/messages', [BookingMessageController::class, 'store']); Route::post('/bookings/{booking}/messages/read', [BookingMessageController::class, 'markRead']); }); @@ -172,4 +172,4 @@ Route::middleware('auth:sanctum')->group(function () { Route::middleware('auth:sanctum')->group(function () { Route::post('/logout', [AuthController::class, 'logout']); Route::get('/user', [AuthController::class, 'user']); -}); \ No newline at end of file +}); diff --git a/tests/Feature/BookingListTest.php b/tests/Feature/BookingListTest.php index e933263..f3071ae 100644 --- a/tests/Feature/BookingListTest.php +++ b/tests/Feature/BookingListTest.php @@ -16,12 +16,12 @@ 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/member/bookings → MemberBookingController::index(auth:sanctum + member middleware) + * GET /api/provider/bookings → ProviderBookingController::index(auth:sanctum + provider middleware) * GET /api/admin/bookings → AdminBookingController::index(auth:sanctum + admin middleware) * - * 資料隔離靠 controller query 條件,不靠 middleware role 強制, - * 因此測試需分別以正確角色 actingAs 驗證隔離邊界。 + * 角色 middleware 只擋跨角色呼叫(見 RoleAuthorizationTest);同角色之間的資料隔離 + * 靠 controller query 條件,因此測試需分別以正確角色 actingAs 驗證隔離邊界。 */ class BookingListTest extends TestCase { @@ -34,22 +34,22 @@ class BookingListTest extends TestCase $offer = DivingOffer::create([ 'provider_id' => $provider->id, - 'title' => 'Dive Course', - 'location' => 'Kenting', - 'price' => 3000, - 'region' => '南部', - 'rating' => 0, - 'reviews' => 0, + '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, + '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, + 'status' => ScheduleStatus::Open, ]); return [$provider, $schedule]; @@ -58,11 +58,11 @@ class BookingListTest extends TestCase private function makePendingBooking(User $member, CourseSchedule $schedule): Booking { return Booking::create([ - 'schedule_id' => $schedule->id, - 'member_id' => $member->id, + 'schedule_id' => $schedule->id, + 'member_id' => $member->id, 'participants' => 1, - 'total_price' => 3000, - 'status' => BookingStatus::Pending, + 'total_price' => 3000, + 'status' => BookingStatus::Pending, ]); } @@ -71,8 +71,8 @@ class BookingListTest extends TestCase 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']); + $memberA = User::factory()->create(['role' => 'member']); + $memberB = User::factory()->create(['role' => 'member']); $bookingA = $this->makePendingBooking($memberA, $schedule); $bookingB = $this->makePendingBooking($memberB, $schedule); @@ -90,7 +90,7 @@ class BookingListTest extends TestCase { [$providerA, $scheduleA] = $this->makeProviderWithSchedule(); [$providerB, $scheduleB] = $this->makeProviderWithSchedule(); - $member = User::factory()->create(['role' => 'member']); + $member = User::factory()->create(['role' => 'member']); $bookingA = $this->makePendingBooking($member, $scheduleA); $bookingB = $this->makePendingBooking($member, $scheduleB); @@ -106,11 +106,11 @@ class BookingListTest extends TestCase public function test_admin_sees_all_bookings(): void { - $admin = User::factory()->create(['role' => 'admin']); + $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']); + $memberA = User::factory()->create(['role' => 'member']); + $memberB = User::factory()->create(['role' => 'member']); $bookingA = $this->makePendingBooking($memberA, $scheduleA); $bookingB = $this->makePendingBooking($memberB, $scheduleB); diff --git a/tests/Feature/RoleAuthorizationTest.php b/tests/Feature/RoleAuthorizationTest.php new file mode 100644 index 0000000..065694f --- /dev/null +++ b/tests/Feature/RoleAuthorizationTest.php @@ -0,0 +1,230 @@ + false, 'message' => '無權限存取']; + + private function createProvider(string $verificationStatus = 'approved'): User + { + $provider = User::factory()->create(['role' => 'provider']); + ProviderProfile::create(['user_id' => $provider->id, 'verification_status' => $verificationStatus]); + + return $provider; + } + + private function createOfferWithOpenSchedule(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(30)->toDateString(), + 'start_time' => '09:00', + 'max_participants' => 10, + 'current_participants' => 0, + 'status' => ScheduleStatus::Open, + ]); + } + + private function validOfferPayload(): array + { + return [ + 'title' => 'Cross Role Course', + 'location' => 'Green Island', + 'price' => 2000, + 'region' => '東部', + ]; + } + + private function assertForbidden(User $user, string $method, string $uri, array $payload = []): void + { + $this->actingAs($user) + ->json($method, $uri, $payload) + ->assertStatus(403) + ->assertExactJson(self::FORBIDDEN_BODY); + } + + // ── Member 路由 ────────────────────────────────────────────── + + public function test_member_can_access_member_routes(): void + { + $member = User::factory()->create(['role' => 'member']); + + $this->actingAs($member)->getJson('/api/member/profile')->assertOk()->assertJsonPath('status', true); + $this->actingAs($member)->getJson('/api/member/bookings')->assertOk()->assertJsonPath('status', true); + } + + public function test_provider_is_rejected_on_member_routes(): void + { + $provider = $this->createProvider(); + $schedule = $this->createOfferWithOpenSchedule($provider); + + $this->assertForbidden($provider, 'GET', '/api/member/profile'); + $this->assertForbidden($provider, 'GET', '/api/member/bookings'); + $this->assertForbidden($provider, 'POST', '/api/member/bookings', [ + 'schedule_id' => $schedule->id, + 'participants' => 1, + ]); + $this->assertForbidden($provider, 'POST', '/api/member/reviews', [ + 'diving_offer_id' => $schedule->diving_offer_id, + 'rating' => 5, + 'comment' => 'self review', + ]); + + // provider 為自己的課程寫評價會灌水評分 + $this->assertDatabaseCount('reviews', 0); + } + + public function test_admin_is_rejected_on_member_routes(): void + { + $admin = User::factory()->create(['role' => 'admin']); + $schedule = $this->createOfferWithOpenSchedule($this->createProvider()); + + $this->assertForbidden($admin, 'GET', '/api/member/profile'); + $this->assertForbidden($admin, 'GET', '/api/member/bookings'); + $this->assertForbidden($admin, 'POST', '/api/member/bookings', [ + 'schedule_id' => $schedule->id, + 'participants' => 1, + ]); + } + + public function test_non_member_booking_attempt_creates_no_booking(): void + { + $provider = $this->createProvider(); + $schedule = $this->createOfferWithOpenSchedule($provider); + + $this->assertForbidden($provider, 'POST', '/api/member/bookings', [ + 'schedule_id' => $schedule->id, + 'participants' => 2, + ]); + + // 越權預約若寫入會佔用真實學員的名額 + $this->assertDatabaseCount('bookings', 0); + $this->assertSame(0, $schedule->fresh()->current_participants); + } + + public function test_unauthenticated_request_is_rejected_on_member_routes(): void + { + $this->getJson('/api/member/profile')->assertStatus(401); + $this->getJson('/api/member/bookings')->assertStatus(401); + } + + // ── Provider 路由 ──────────────────────────────────────────── + + public function test_provider_can_access_provider_routes(): void + { + $provider = $this->createProvider(); + + foreach (['/api/provider/profile', '/api/provider/offers', '/api/provider/schedules', '/api/provider/bookings'] as $uri) { + $this->actingAs($provider)->getJson($uri)->assertOk()->assertJsonPath('status', true); + } + } + + public function test_unapproved_provider_can_access_verification(): void + { + // 未通過審核的教練必須能進入 provider 路由才能送審或補件 + foreach (['unsubmitted', 'pending', 'rejected'] as $status) { + $provider = $this->createProvider($status); + + $this->actingAs($provider) + ->getJson('/api/provider/verification') + ->assertOk() + ->assertJsonPath('data.verification_status', $status); + } + } + + public function test_member_is_rejected_on_provider_routes(): void + { + $member = User::factory()->create(['role' => 'member']); + $schedule = $this->createOfferWithOpenSchedule($this->createProvider()); + + $this->assertForbidden($member, 'GET', '/api/provider/profile'); + $this->assertForbidden($member, 'GET', '/api/provider/offers'); + $this->assertForbidden($member, 'POST', '/api/provider/offers', $this->validOfferPayload()); + $this->assertForbidden($member, 'POST', '/api/provider/schedules', [ + 'diving_offer_id' => $schedule->diving_offer_id, + 'scheduled_date' => now()->addDays(10)->toDateString(), + 'start_time' => '10:00', + 'max_participants' => 5, + ]); + $this->assertForbidden($member, 'GET', '/api/provider/bookings'); + $this->assertForbidden($member, 'GET', '/api/provider/verification'); + + $this->assertDatabaseCount('course_schedules', 1); + } + + public function test_admin_is_rejected_on_provider_routes(): void + { + $admin = User::factory()->create(['role' => 'admin']); + + $this->assertForbidden($admin, 'GET', '/api/provider/profile'); + $this->assertForbidden($admin, 'GET', '/api/provider/offers'); + $this->assertForbidden($admin, 'POST', '/api/provider/offers', $this->validOfferPayload()); + $this->assertForbidden($admin, 'GET', '/api/provider/bookings'); + } + + public function test_non_provider_offer_attempt_creates_no_offer(): void + { + $member = User::factory()->create(['role' => 'member']); + + $this->assertForbidden($member, 'POST', '/api/provider/offers', $this->validOfferPayload()); + + // 越權建立的課程 provider_id 會是 member,出現在公開列表 + $this->assertDatabaseCount('diving_offers', 0); + } + + public function test_unauthenticated_request_is_rejected_on_provider_routes(): void + { + $this->getJson('/api/provider/profile')->assertStatus(401); + $this->getJson('/api/provider/offers')->assertStatus(401); + } + + // ── 跨角色共用路由 ─────────────────────────────────────────── + + public function test_member_and_provider_can_read_notifications(): void + { + $member = User::factory()->create(['role' => 'member']); + $provider = $this->createProvider(); + + $this->actingAs($member)->getJson('/api/notifications')->assertOk(); + $this->actingAs($provider)->getJson('/api/notifications')->assertOk(); + } + + public function test_member_and_provider_can_read_unread_message_counts(): void + { + $member = User::factory()->create(['role' => 'member']); + $provider = $this->createProvider(); + + $this->actingAs($member)->getJson('/api/bookings/messages/unread-counts')->assertOk(); + $this->actingAs($provider)->getJson('/api/bookings/messages/unread-counts')->assertOk(); + } +}