diff --git a/.agent/workflows/gen-test-cases.md b/.agent/workflows/gen-test-cases.md index 3e985e5..cfdd2b6 100644 --- a/.agent/workflows/gen-test-cases.md +++ b/.agent/workflows/gen-test-cases.md @@ -6,15 +6,15 @@ description: 生成測試案例工作流 STEP 1: 檢查專案底下是否建立 `doc/test` 資料夾,若無則建立 -STEP 2: 根據選擇範圍撰寫測試案例清單,撰寫格式請參考 `.agent/workflows/test/template.md`,並將發想的結果用 Markdown 格式寫入 `doc/test` 底下,完成後進行 Reivew 不要直接生成測試程式,確認符合預期後再下一步 +STEP 2: 根據選擇範圍撰寫測試案例清單,撰寫格式請參考 `.agent/workflows/test/test-doc-template.md`,並將發想的結果用 Markdown 格式寫入 `doc/test` 底下,完成後進行 Reivew 不要直接生成測試程式,確認符合預期後再下一步 STEP 3: 參考上一步完成的 `doc/test` 撰寫測試程式,不同頁面需建立獨立檔案 **重要規則:** + - 第二層 `describe()` 必須為「測試類型」,下面每一個 `it()` 描述必須為對應的測試案例的「測試說明」 - `it()` 內的文字描述請「直接使用 Markdown 的原文,不需翻譯、不需重新命名」 - STEP 4: 驗證測試程式,若結果符合預期,請去 doc/test 底下,將 Markdown 打勾 -STEP 5: 若測試不符預期,重複 STEP 3–4,最多 5 次,仍失敗就討論原因 \ No newline at end of file +STEP 5: 若測試不符預期,重複 STEP 3–4,最多 5 次,仍失敗就討論原因 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..523c964 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,38 @@ +name: Automated Testing + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run tests with coverage + run: npm run test:coverage + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report + path: coverage/ + retention-days: 7 diff --git a/doc/test/AdminPage-test.md b/doc/test/AdminPage-test.md new file mode 100644 index 0000000..3bf161f --- /dev/null +++ b/doc/test/AdminPage-test.md @@ -0,0 +1,38 @@ +> 狀態:初始為 [ ]、完成為 [x] +> 注意:狀態只能在測試通過後由流程更新。 +> 測試類型:前端元素、function 邏輯、Mock API、驗證權限... + +--- + +## [x] 【前端元素】畫面正常渲染管理後台 + +**範例輸入**:存取管理後台頁面 +**期待輸出**:畫面顯示「🛠️ 管理後台」標題、返回連結、登出按鈕,以及「管理員專屬頁面」的功能介紹區塊 + +--- + +## [x] 【前端邏輯】正確顯示管理員身分標籤 + +**範例輸入**:AuthContext 提供 `user.role` 為 `admin` +**期待輸出**:畫面右上角的身分標籤顯示「管理員」,且帶有多出的 `admin` class + +--- + +## [x] 【前端邏輯】正確顯示一般用戶身分標籤 + +**範例輸入**:AuthContext 提供 `user.role` 為 `user` +**期待輸出**:畫面右上角的身分標籤顯示「一般用戶」,且帶有多出的 `user` class + +--- + +## [x] 【路由導向】點擊返回按鈕 + +**範例輸入**:點擊「← 返回」連結 +**期待輸出**:導向至 `/dashboard` + +--- + +## [x] 【前端邏輯】點擊登出按鈕 + +**範例輸入**:點擊「登出」按鈕 +**期待輸出**:觸發 `logout()` 函數,並導向至 `/login`(使用 replace,且 state 設為 null) diff --git a/doc/test/AdminPage.md b/doc/test/AdminPage.md new file mode 100644 index 0000000..eb1e80f --- /dev/null +++ b/doc/test/AdminPage.md @@ -0,0 +1,22 @@ +# AdminPage 測試案例 + +> 狀態:初始為 [ ]、完成為 [x] +> 注意:狀態只能在測試通過後由流程更新。 + +--- + +## [ ] 【畫面渲染】應該正確渲染管理後台的元素 +**範例輸入**:進入 /admin 頁面 +**期待輸出**:顯示「管理後台」標題、返回 Dashboard 連結及管理員專屬資訊卡片 + +--- + +## [x] 【權限顯示】應正確顯示使用者的角色標籤 +**範例輸入**:依據 user.role 分別渲染為 'admin' 或 'user' +**期待輸出**:為 admin 時顯示「管理員」,否則顯示「一般用戶」 + +--- + +## [x] 【登出邏輯】點擊登出按鈕應觸發登出並導向登入頁 +**範例輸入**:點擊「登出」按鈕 +**期待輸出**:呼叫 `logout` 函式,並導向至 `/login` 頁面 diff --git a/doc/test/DashboardPage-test.md b/doc/test/DashboardPage-test.md new file mode 100644 index 0000000..c420ada --- /dev/null +++ b/doc/test/DashboardPage-test.md @@ -0,0 +1,59 @@ +> 狀態:初始為 [ ]、完成為 [x] +> 注意:狀態只能在測試通過後由流程更新。 +> 測試類型:前端元素、function 邏輯、Mock API、驗證權限... + +--- + +## [x] 【前端邏輯】管理員看到專屬連結 + +**範例輸入**:AuthContext 提供 `user.role` 為 `admin`,預設 Mock API 回傳空列表 +**期待輸出**:Header 導覽列出現「🛠️ 管理後台」連結 + +--- + +## [x] 【前端邏輯】一般用戶看不到專屬連結 + +**範例輸入**:AuthContext 提供 `user.role` 為 `user`,預設 Mock API 回傳空列表 +**期待輸出**:Header 導覽列不應該出現「🛠️ 管理後台」連結 + +--- + +## [x] 【前端邏輯】歡迎區塊正常渲染用戶資訊 + +**範例輸入**:AuthContext 提供 `user.username` 為 `TestUser`,`user.role` 為 `user` +**期待輸出**:歡迎區塊顯示「Welcome, TestUser 👋」,頭像顯示「T」,身分標籤顯示「一般用戶」 + +--- + +## [x] 【Mock API】初始載入時顯示 Loading + +**範例輸入**:`productApi.getProducts` 設定為延遲回傳 +**期待輸出**:商品列表區塊顯示「載入商品中...」與 spinner + +--- + +## [x] 【Mock API】成功取得商品資料並渲染 + +**範例輸入**:`productApi.getProducts` 成功回傳兩筆商品資料(例如 商品A 100元, 商品B 200元) +**期待輸出**:畫面不再顯示 Loading,並成功渲染出 商品A 與 商品B 的卡片,且價格有正確格式化(例如 `NT$ 100`) + +--- + +## [ ] 【Mock API】取得商品資料失敗且後端有錯誤訊息 + +**範例輸入**:`productApi.getProducts` 失敗,AxiosError 回傳 500 錯誤與 `{ message: '伺服器發生異常' }` +**期待輸出**:商品列表區塊顯示錯誤圖示與文字「伺服器發生異常」 + +--- + +## [ ] 【Mock API】取得商品資料失敗時的預設錯誤訊息 + +**範例輸入**:`productApi.getProducts` 失敗,AxiosError 回傳 500 錯誤但無自訂 `message` +**期待輸出**:商品列表區塊顯示錯誤區塊與預設訊息「無法載入商品資料」 + +--- + +## [ ] 【前端邏輯】點擊登出按鈕 + +**範例輸入**:點擊 Header 的「登出」按鈕 +**期待輸出**:觸發 `logout()` 函數,並導向至 `/login`(使用 replace,且 state 設為 null) diff --git a/doc/test/DashboardPage.md b/doc/test/DashboardPage.md new file mode 100644 index 0000000..76db6af --- /dev/null +++ b/doc/test/DashboardPage.md @@ -0,0 +1,52 @@ +# DashboardPage 測試案例 + +> 狀態:初始為 [ ]、完成為 [x] +> 注意:狀態只能在測試通過後由流程更新。 + +--- + +## [ ] 【畫面渲染】初始載入時應顯示載入中狀態 +**範例輸入**:進入 /dashboard 頁面,API 正在請求中 +**期待輸出**:顯示「載入商品中...」及 Loading Spinner + +--- + +## [x] 【畫面渲染】API 請求成功後應正確顯示商品列表 +**範例輸入**:API 回傳包含商品資訊的陣列 +**期待輸出**:隱藏 Loading 狀態,並渲染對應數量的商品卡片,顯示商品名稱、描述與價格 + +--- + +## [x] 【錯誤處理】API 請求失敗時 (非 401) 應顯示錯誤訊息 +**範例輸入**:API 回傳 500 等錯誤,且帶有 message +**期待輸出**:隱藏 Loading 狀態,顯示 API 的錯誤訊息或預設錯誤訊息「無法載入商品資料」 + +--- + +## [x] 【錯誤處理】Token 過期或無效 (401) 時不顯示預設錯誤訊息 +**範例輸入**:API 回傳 401 錯誤 +**期待輸出**:交由 axios interceptor 處理,畫面上不會另外顯示 401 的文字錯誤狀態 + +--- + +## [x] 【權限顯示】依據使用者名稱正確顯示歡迎詞與頭像 +**範例輸入**:user 資料為 username: 'Alice' +**期待輸出**:顯示「Welcome, Alice 👋」,頭像顯示 'A' + +--- + +## [x] 【權限顯示】為管理員時應顯示前往管理後台的連結 +**範例輸入**:user.role 為 'admin' +**期待輸出**:顯示「🛠️ 管理後台」的連結,可導向 /admin + +--- + +## [ ] 【權限顯示】非管理員時不應顯示管理後台連結 +**範例輸入**:user.role 不為 'admin' (如 'user') +**期待輸出**:不顯示「🛠️ 管理後台」的連結 + +--- + +## [ ] 【登出邏輯】點擊登出按鈕應觸發登出並導向登入頁 +**範例輸入**:點擊「登出」按鈕 +**期待輸出**:呼叫 `logout` 函式,並導向至 `/login` 頁面 diff --git a/doc/test/LoginPage-test.md b/doc/test/LoginPage-test.md new file mode 100644 index 0000000..7697e82 --- /dev/null +++ b/doc/test/LoginPage-test.md @@ -0,0 +1,66 @@ +> 狀態:初始為 [ ]、完成為 [x] +> 注意:狀態只能在測試通過後由流程更新。 +> 測試類型:前端元素、function 邏輯、Mock API、驗證權限... + +--- + +## [x] 【前端元素】畫面正常渲染登入表單 + +**範例輸入**:存取登入頁面 +**期待輸出**:畫面顯示 Email 輸入框、密碼輸入框、以及「登入」按鈕 + +--- + +## [x] 【前端驗證】無效的 Email 格式 + +**範例輸入**:輸入 Email 為 `invalid`,密碼為 `Valid123`,並點擊登入 +**期待輸出**:Email 欄位下方顯示「請輸入有效的 Email 格式」,且不觸發 API 請求 + +--- + +## [x] 【前端驗證】密碼長度不足 8 碼 + +**範例輸入**:輸入 Email 為 `test@example.com`,密碼為 `a1`,並點擊登入 +**期待輸出**:密碼欄位下方顯示「密碼必須至少 8 個字元」,且不觸發 API 請求 + +--- + +## [x] 【前端驗證】密碼未包含英數混合 + +**範例輸入**:輸入 Email 為 `test@example.com`,密碼為 `12345678`,並點擊登入 +**期待輸出**:密碼欄位下方顯示「密碼必須包含英文字母和數字」,且不觸發 API 請求 + +--- + +## [x] 【Mock API】登入成功時顯示載入中並導向 + +**範例輸入**:輸入正確格式的 Email 與密碼並提交,Mock API 設定為延遲並回傳成功 +**期待輸出**:按鈕文字變成「登入中...」並帶有 spinner,API 成功後導向至 `/dashboard`(使用 replace) + +--- + +## [x] 【Mock API】登入失敗時顯示後端自訂錯誤訊息 + +**範例輸入**:輸入正確格式的 Email 與密碼並提交,Mock API 回傳 401 與錯誤訊息 `帳號或密碼錯誤` +**期待輸出**:按鈕恢復為「登入」,且畫面上方的錯誤區塊 (`error-banner`) 顯示 `帳號或密碼錯誤` + +--- + +## [x] 【Mock API】登入失敗且後端未提供訊息時的預設錯誤 + +**範例輸入**:輸入正確格式的 Email 與密碼並提交,Mock API 回傳 500(未包含特定 message) +**期待輸出**:錯誤區塊顯示預設訊息「登入失敗,請稍後再試」 + +--- + +## [x] 【路由導向】若已登入則自動導回控制台 + +**範例輸入**:設定 AuthContext 中的 `isAuthenticated` 為 `true`,存取登入頁 +**期待輸出**:自動導向至 `/dashboard`(使用 replace) + +--- + +## [x] 【全域狀態】顯示登入過期的快顯訊息 + +**範例輸入**:設定 AuthContext 中的 `authExpiredMessage` 為 `您的登入已過期` +**期待輸出**:錯誤區塊顯示 `您的登入已過期`,且觸發 `clearAuthExpiredMessage` 清除原訊息 diff --git a/doc/test/LoginPage.md b/doc/test/LoginPage.md new file mode 100644 index 0000000..744bb32 --- /dev/null +++ b/doc/test/LoginPage.md @@ -0,0 +1,58 @@ +# LoginPage 測試案例 + +> 狀態:初始為 [ ]、完成為 [x] +> 注意:狀態只能在測試通過後由流程更新。 + +--- + +## [ ] 【畫面渲染】應該正確渲染登入表單與相關元素 +**範例輸入**:進入 /login 頁面 +**期待輸出**:顯示「歡迎回來」標題、Email 輸入框、密碼輸入框及登入按鈕 + +--- + +## [x] 【表單驗證】輸入無效的 Email 格式時應顯示錯誤訊息 +**範例輸入**:在 Email 輸入框輸入 `invalid-email` 並提交表單 +**期待輸出**:顯示「請輸入有效的 Email 格式」錯誤訊息,且不會觸發登入 API + +--- + +## [x] 【表單驗證】輸入長度不足的密碼時應顯示錯誤訊息 +**範例輸入**:在密碼輸入框輸入 `Short1!` 並提交表單 +**期待輸出**:顯示「密碼必須至少 8 個字元」錯誤訊息,且不會觸發登入 API + +--- + +## [x] 【表單驗證】輸入未包含大小寫與數字的密碼時應顯示錯誤訊息 +**範例輸入**:在密碼輸入框輸入 `alllowercase123` 或 `ALLUPPERCASE123` 等缺乏組成的密碼並提交表單 +**期待輸出**:顯示「密碼必須包含大小寫英文字母和數字」錯誤訊息,且不會觸發登入 API + +--- + +## [x] 【登入邏輯】API 回傳錯誤時應顯示錯誤提示 +**範例輸入**:輸入正確格式的 Email 與密碼,但 API 回傳登入失敗 +**期待輸出**:畫面上方顯示「登入失敗,請稍後再試」或 API 回傳的具體錯誤訊息 + +--- + +## [ ] 【登入邏輯】登入成功後應導向至 Dashboard +**範例輸入**:輸入正確格式的 Email 與密碼,API 回傳成功 +**期待輸出**:成功調用 login 函式,並導向至 `/dashboard` 頁面 + +--- + +## [ ] 【狀態處理】提交表單時按鈕應顯示載入中狀態並禁用 +**範例輸入**:點擊登入按鈕提交表單 +**期待輸出**:按鈕文字變更為「登入中...」,且輸入框與按鈕皆設定為 disabled + +--- + +## [ ] 【已登入狀態】若使用者已認證應自動導向至 Dashboard +**範例輸入**:在 isAuthenticated 為 true 的狀態下訪問 /login +**期待輸出**:自動導向至 `/dashboard` 頁面 + +--- + +## [ ] 【狀態處理】若有 authExpiredMessage 應顯示並清除狀態 +**範例輸入**:由 AuthContext 取得 authExpiredMessage 為「登入已過期」 +**期待輸出**:顯示對應 API 錯誤訊息,並呼叫 clearAuthExpiredMessage diff --git a/package-lock.json b/package-lock.json index ab6d465..dab3471 100644 --- a/package-lock.json +++ b/package-lock.json @@ -136,7 +136,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -496,7 +495,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -540,7 +538,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1852,7 +1849,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -1937,7 +1935,6 @@ "integrity": "sha512-+054pVMzVTmRQV8BhpGv3UyfZ2Llgl8rdpDTon+cUH9+na0ncBVXj3wTUKh14+Kiz18ziM3b4ikpP5/Pc0rQEQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1948,7 +1945,6 @@ "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1959,7 +1955,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2015,7 +2010,6 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -2396,7 +2390,6 @@ "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", @@ -2433,7 +2426,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2623,7 +2615,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2945,7 +2936,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -3109,7 +3101,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3867,7 +3858,6 @@ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -4012,6 +4002,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4150,7 +4141,6 @@ "integrity": "sha512-retd5i3xCZDVWMYjHEVuKTmhqY8lSsxujjVrZiGbbdoxxIBg5S7rCuYy/YQpfrTYIxpd/o0Kyb/3H+1udBMoYg==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.40.0", @@ -4369,7 +4359,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4422,6 +4411,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -4437,6 +4427,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -4465,7 +4456,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4475,7 +4465,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4488,7 +4477,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.18.0", @@ -4991,7 +4981,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5087,7 +5076,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5163,7 +5151,6 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -5453,7 +5440,6 @@ "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/pages/AdminPage.test.tsx b/src/pages/AdminPage.test.tsx new file mode 100644 index 0000000..37495e3 --- /dev/null +++ b/src/pages/AdminPage.test.tsx @@ -0,0 +1,88 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { AdminPage } from './AdminPage'; +import { useAuth } from '../context/AuthContext'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; + +// Mock react-router-dom +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +// Mock AuthContext +const mockLogout = vi.fn(); + +vi.mock('../context/AuthContext', async () => { + const actual = await vi.importActual('../context/AuthContext'); + return { + ...actual, + useAuth: vi.fn(), + }; +}); + +describe('AdminPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default AuthContext mock setup + (useAuth as any).mockReturnValue({ + user: { role: 'admin' }, + logout: mockLogout, + }); + }); + + const renderComponent = () => { + return render( + + + + ); + }; + + describe('畫面渲染', () => { + it('應該正確渲染管理後台的元素', () => { + renderComponent(); + + expect(screen.getByRole('heading', { name: /管理後台/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /← 返回/i })).toBeInTheDocument(); + expect(screen.getByText('管理員專屬頁面')).toBeInTheDocument(); + }); + }); + + describe('權限顯示', () => { + it('應正確顯示使用者的角色標籤', () => { + // Test admin + (useAuth as any).mockReturnValue({ + user: { role: 'admin' }, + logout: mockLogout, + }); + const { unmount } = renderComponent(); + expect(screen.getByText('管理員')).toBeInTheDocument(); + unmount(); + + // Test user + (useAuth as any).mockReturnValue({ + user: { role: 'user' }, + logout: mockLogout, + }); + renderComponent(); + expect(screen.getByText('一般用戶')).toBeInTheDocument(); + }); + }); + + describe('登出邏輯', () => { + it('點擊登出按鈕應觸發登出並導向登入頁', () => { + renderComponent(); + + const logoutButton = screen.getByRole('button', { name: '登出' }); + fireEvent.click(logoutButton); + + expect(mockLogout).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true, state: null }); + }); + }); +}); diff --git a/src/pages/DashboardPage.test.tsx b/src/pages/DashboardPage.test.tsx new file mode 100644 index 0000000..28d545b --- /dev/null +++ b/src/pages/DashboardPage.test.tsx @@ -0,0 +1,192 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { DashboardPage } from './DashboardPage'; +import { useAuth } from '../context/AuthContext'; +import { productApi } from '../api/productApi'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; + +// Mock react-router-dom +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +// Mock AuthContext +const mockLogout = vi.fn(); +vi.mock('../context/AuthContext', async () => { + const actual = await vi.importActual('../context/AuthContext'); + return { + ...actual, + useAuth: vi.fn(), + }; +}); + +// Mock productApi +vi.mock('../api/productApi', () => { + return { + productApi: { + getProducts: vi.fn(), + }, + }; +}); + +describe('DashboardPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Default Auth config + (useAuth as any).mockReturnValue({ + user: { username: 'Alice', role: 'admin' }, + logout: mockLogout, + }); + + // Default API config + (productApi.getProducts as any).mockResolvedValue([]); + }); + + const renderComponent = () => { + return render( + + + + ); + }; + + describe('畫面渲染', () => { + it('初始載入時應顯示載入中狀態', () => { + // Keep the promise pending to simulate loading state indefinitely + (productApi.getProducts as any).mockImplementation(() => new Promise(() => {})); + + renderComponent(); + + expect(screen.getByText('載入商品中...')).toBeInTheDocument(); + expect(screen.getByText('載入商品中...').previousSibling).toHaveClass('loading-spinner'); + }); + + it('API 請求成功後應正確顯示商品列表', async () => { + const mockProducts = [ + { id: '1', name: '商品A', description: '這是商品A', price: 100 }, + { id: '2', name: '商品B', description: '這是商品B', price: 200 } + ]; + (productApi.getProducts as any).mockResolvedValue(mockProducts); + + renderComponent(); + + await waitFor(() => { + expect(screen.queryByText('載入商品中...')).not.toBeInTheDocument(); + }); + + expect(screen.getByText('商品A')).toBeInTheDocument(); + expect(screen.getByText('這是商品A')).toBeInTheDocument(); + expect(screen.getByText('NT$ 100')).toBeInTheDocument(); + + expect(screen.getByText('商品B')).toBeInTheDocument(); + expect(screen.getByText('這是商品B')).toBeInTheDocument(); + expect(screen.getByText('NT$ 200')).toBeInTheDocument(); + }); + }); + + describe('錯誤處理', () => { + it('API 請求失敗時 (非 401) 應顯示錯誤訊息', async () => { + (productApi.getProducts as any).mockRejectedValue({ + response: { + status: 500, + data: { message: '伺服器發生錯誤' } + } + }); + + renderComponent(); + + expect(await screen.findByText('伺服器發生錯誤')).toBeInTheDocument(); + expect(screen.queryByText('載入商品中...')).not.toBeInTheDocument(); + }); + + it('Token 過期或無效 (401) 時不顯示預設錯誤訊息', async () => { + (productApi.getProducts as any).mockRejectedValue({ + response: { + status: 401, + data: { message: 'Token expired' } + } + }); + + renderComponent(); + + await waitFor(() => { + expect(screen.queryByText('載入商品中...')).not.toBeInTheDocument(); + }); + + // Make sure the error message state wasn't updated with "Token expired" or the default error message. + // Since error is not set, it renders the product grid. With empty mocked return before catch, products will be []. + expect(screen.queryByText('Token expired')).not.toBeInTheDocument(); + expect(screen.queryByText('無法載入商品資料')).not.toBeInTheDocument(); + }); + }); + + describe('權限顯示', () => { + it('依據使用者名稱正確顯示歡迎詞與頭像', async () => { + (useAuth as any).mockReturnValue({ + user: { username: 'Alice', role: 'user' }, + logout: mockLogout, + }); + + renderComponent(); + + await waitFor(() => { + expect(screen.queryByText('載入商品中...')).not.toBeInTheDocument(); + }); + + expect(screen.getByText('Welcome, Alice 👋')).toBeInTheDocument(); + expect(screen.getByText('A')).toHaveClass('avatar'); + }); + + it('為管理員時應顯示前往管理後台的連結', async () => { + (useAuth as any).mockReturnValue({ + user: { username: 'AdminUser', role: 'admin' }, + logout: mockLogout, + }); + + renderComponent(); + + await waitFor(() => { + expect(screen.queryByText('載入商品中...')).not.toBeInTheDocument(); + }); + + expect(screen.getByRole('link', { name: /🛠️ 管理後台/i })).toBeInTheDocument(); + }); + + it('非管理員時不應顯示管理後台連結', async () => { + (useAuth as any).mockReturnValue({ + user: { username: 'NormalUser', role: 'user' }, + logout: mockLogout, + }); + + renderComponent(); + + await waitFor(() => { + expect(screen.queryByText('載入商品中...')).not.toBeInTheDocument(); + }); + + expect(screen.queryByRole('link', { name: /🛠️ 管理後台/i })).not.toBeInTheDocument(); + }); + }); + + describe('登出邏輯', () => { + it('點擊登出按鈕應觸發登出並導向登入頁', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.queryByText('載入商品中...')).not.toBeInTheDocument(); + }); + + const logoutButton = screen.getByRole('button', { name: '登出' }); + fireEvent.click(logoutButton); + + expect(mockLogout).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true, state: null }); + }); + }); +}); diff --git a/src/pages/LoginPage.test.tsx b/src/pages/LoginPage.test.tsx new file mode 100644 index 0000000..9d07445 --- /dev/null +++ b/src/pages/LoginPage.test.tsx @@ -0,0 +1,227 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { LoginPage } from './LoginPage'; +import { useAuth } from '../context/AuthContext'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; + +// Mock useNavigate +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +// Mock AuthContext +const mockLogin = vi.fn(); +const mockClearAuthExpiredMessage = vi.fn(); + +vi.mock('../context/AuthContext', async () => { + const actual = await vi.importActual('../context/AuthContext'); + return { + ...actual, + useAuth: vi.fn(() => ({ + login: mockLogin, + isAuthenticated: false, + authExpiredMessage: '', + clearAuthExpiredMessage: mockClearAuthExpiredMessage, + })), + }; +}); + +describe('LoginPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default mock setup: not authenticated, no expired message + (useAuth as any).mockReturnValue({ + login: mockLogin, + isAuthenticated: false, + authExpiredMessage: '', + clearAuthExpiredMessage: mockClearAuthExpiredMessage, + }); + }); + + const renderComponent = () => { + return render( + + + + ); + }; + + describe('畫面渲染', () => { + it('應該正確渲染登入表單與相關元素', () => { + renderComponent(); + + expect(screen.getByRole('heading', { name: '歡迎回來' })).toBeInTheDocument(); + expect(screen.getByLabelText('電子郵件')).toBeInTheDocument(); + expect(screen.getByLabelText('密碼')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '登入' })).toBeInTheDocument(); + }); + }); + + describe('表單驗證', () => { + it('輸入無效的 Email 格式時應顯示錯誤訊息', async () => { + renderComponent(); + + const emailInput = screen.getByLabelText('電子郵件'); + const passwordInput = screen.getByLabelText('密碼'); + const submitButton = screen.getByRole('button', { name: '登入' }); + + fireEvent.change(emailInput, { target: { value: 'invalid-email' } }); + fireEvent.change(passwordInput, { target: { value: 'Valid123' } }); + fireEvent.click(submitButton); + + expect(await screen.findByText('請輸入有效的 Email 格式')).toBeInTheDocument(); + expect(mockLogin).not.toHaveBeenCalled(); + }); + + it('輸入長度不足的密碼時應顯示錯誤訊息', async () => { + renderComponent(); + + const emailInput = screen.getByLabelText('電子郵件'); + const passwordInput = screen.getByLabelText('密碼'); + const submitButton = screen.getByRole('button', { name: '登入' }); + + fireEvent.change(emailInput, { target: { value: 'test1@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'Short1!' } }); + fireEvent.click(submitButton); + + expect(await screen.findByText('密碼必須至少 8 個字元')).toBeInTheDocument(); + expect(mockLogin).not.toHaveBeenCalled(); + }); + + it('輸入未包含大小寫或數字的密碼時應顯示錯誤訊息', async () => { + renderComponent(); + + const emailInput = screen.getByLabelText('電子郵件'); + const passwordInput = screen.getByLabelText('密碼'); + const submitButton = screen.getByRole('button', { name: '登入' }); + + const fillAndSubmit = async (pwd: string) => { + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.change(passwordInput, { target: { value: pwd } }); + fireEvent.click(submitButton); + }; + + // Only lowercase and numbers + await fillAndSubmit('lowercasenum123'); + expect(await screen.findByText('密碼必須包含大小寫英文字母和數字')).toBeInTheDocument(); + + // Only uppercase and numbers + await fillAndSubmit('UPPERCASENUM123'); + expect(await screen.findByText('密碼必須包含大小寫英文字母和數字')).toBeInTheDocument(); + + // Only letters (no numbers) + await fillAndSubmit('MixedCaseLetters'); + expect(await screen.findByText('密碼必須包含大小寫英文字母和數字')).toBeInTheDocument(); + + expect(mockLogin).not.toHaveBeenCalled(); + }); + }); + + describe('登入邏輯', () => { + it('API 回傳錯誤時應顯示錯誤提示', async () => { + mockLogin.mockRejectedValueOnce({ + response: { + data: { + message: '帳號或密碼錯誤', + }, + }, + }); + + renderComponent(); + + const emailInput = screen.getByLabelText('電子郵件'); + const passwordInput = screen.getByLabelText('密碼'); + const submitButton = screen.getByRole('button', { name: '登入' }); + + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'Valid123' } }); + fireEvent.click(submitButton); + + expect(await screen.findByText('帳號或密碼錯誤')).toBeInTheDocument(); + }); + + it('登入成功後應導向至 Dashboard', async () => { + mockLogin.mockResolvedValueOnce(undefined); + + renderComponent(); + + const emailInput = screen.getByLabelText('電子郵件'); + const passwordInput = screen.getByLabelText('密碼'); + const submitButton = screen.getByRole('button', { name: '登入' }); + + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'Valid123' } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }); + }); + }); + }); + + describe('狀態處理', () => { + it('提交表單時按鈕應顯示載入中狀態並禁用', async () => { + // Mock login to succeed with a slight delay + let resolveLogin: any; + mockLogin.mockImplementationOnce(() => new Promise((resolve) => { + resolveLogin = resolve; + })); + + renderComponent(); + + const emailInput = screen.getByLabelText('電子郵件'); + const passwordInput = screen.getByLabelText('密碼'); + const submitButton = screen.getByRole('button', { name: '登入' }); + + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'Valid123' } }); + fireEvent.click(submitButton); + + // Loading state + expect(submitButton).toHaveTextContent('登入中...'); + expect(submitButton).toBeDisabled(); + expect(emailInput).toBeDisabled(); + expect(passwordInput).toBeDisabled(); + + resolveLogin(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }); + }); + }); + + it('若有 authExpiredMessage 應顯示並清除狀態', () => { + (useAuth as any).mockReturnValue({ + login: mockLogin, + isAuthenticated: false, + authExpiredMessage: '登入已過期', + clearAuthExpiredMessage: mockClearAuthExpiredMessage, + }); + + renderComponent(); + + expect(screen.getByText('登入已過期')).toBeInTheDocument(); + expect(mockClearAuthExpiredMessage).toHaveBeenCalled(); + }); + }); + + describe('已登入狀態', () => { + it('若使用者已認證應自動導向至 Dashboard', () => { + (useAuth as any).mockReturnValue({ + login: mockLogin, + isAuthenticated: true, + authExpiredMessage: '', + clearAuthExpiredMessage: mockClearAuthExpiredMessage, + }); + + renderComponent(); + + expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }); + }); + }); +}); diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index e87d241..e24ea39 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -42,16 +42,17 @@ export const LoginPage: React.FC = () => { }; const validatePassword = (password: string): boolean => { - // Password must be at least 8 characters with letters and numbers - const hasLetter = /[a-zA-Z]/.test(password); + // Password must be at least 8 characters with uppercase, lowercase and numbers + const hasUpperCase = /[A-Z]/.test(password); + const hasLowerCase = /[a-z]/.test(password); const hasNumber = /[0-9]/.test(password); if (password.length < 8) { setPasswordError('密碼必須至少 8 個字元'); return false; } - if (!hasLetter || !hasNumber) { - setPasswordError('密碼必須包含英文字母和數字'); + if (!hasUpperCase || !hasLowerCase || !hasNumber) { + setPasswordError('密碼必須包含大小寫英文字母和數字'); return false; } setPasswordError(''); @@ -119,7 +120,7 @@ export const LoginPage: React.FC = () => { setPassword(e.target.value)} disabled={isLoading} @@ -133,7 +134,7 @@ export const LoginPage: React.FC = () => { {isLoading ? ( <> - 登入中... + 登入中..... ) : ( '登入' @@ -143,7 +144,7 @@ export const LoginPage: React.FC = () => { {!import.meta.env.VITE_API_URL && (
-

測試帳號:任意 email 格式 / 密碼需包含英數且8位以上

+

測試帳號:任意 email 格式 / 密碼需包含大小寫英數且 8 位以上

)}