From 1a5aa7bb4fb3e03683f89aa3554ffe82850040e0 Mon Sep 17 00:00:00 2001 From: raintaip Date: Mon, 9 Mar 2026 17:17:13 +0800 Subject: [PATCH 01/17] =?UTF-8?q?test:=20=E6=96=B0=E5=A2=9E=20AdminPage?= =?UTF-8?q?=E3=80=81LoginPage=E3=80=81DashboardPage=20=E9=A0=81=E9=9D=A2?= =?UTF-8?q?=E7=9A=84=E5=96=AE=E5=85=83=E6=B8=AC=E8=A9=A6=EF=BC=8C=E4=B8=A6?= =?UTF-8?q?=E5=8A=A0=E5=85=A5=E7=9B=B8=E9=97=9C=E6=B8=AC=E8=A9=A6=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E8=88=87=E6=A8=A1=E6=93=AC=E6=9C=8D=E5=8B=99=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E8=80=85=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agent/workflows/gen-test-cases.md | 6 +- doc/test/AdminPage-test.md | 38 +++++ doc/test/DashboardPage-test.md | 59 ++++++++ doc/test/LoginPage-test.md | 66 +++++++++ package-lock.json | 32 ++--- src/pages/AdminPage.test.tsx | 103 ++++++++++++++ src/pages/DashboardPage.test.tsx | 179 ++++++++++++++++++++++++ src/pages/LoginPage.test.tsx | 214 +++++++++++++++++++++++++++++ 8 files changed, 671 insertions(+), 26 deletions(-) create mode 100644 doc/test/AdminPage-test.md create mode 100644 doc/test/DashboardPage-test.md create mode 100644 doc/test/LoginPage-test.md create mode 100644 src/pages/AdminPage.test.tsx create mode 100644 src/pages/DashboardPage.test.tsx create mode 100644 src/pages/LoginPage.test.tsx 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/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/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/LoginPage-test.md b/doc/test/LoginPage-test.md new file mode 100644 index 0000000..e45fbd5 --- /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`) 顯示 `帳號或密碼錯誤` + +--- + +## [ ] 【Mock API】登入失敗且後端未提供訊息時的預設錯誤 + +**範例輸入**:輸入正確格式的 Email 與密碼並提交,Mock API 回傳 500(未包含特定 message) +**期待輸出**:錯誤區塊顯示預設訊息「登入失敗,請稍後再試」 + +--- + +## [ ] 【路由導向】若已登入則自動導回控制台 + +**範例輸入**:設定 AuthContext 中的 `isAuthenticated` 為 `true`,存取登入頁 +**期待輸出**:自動導向至 `/dashboard`(使用 replace) + +--- + +## [ ] 【全域狀態】顯示登入過期的快顯訊息 + +**範例輸入**:設定 AuthContext 中的 `authExpiredMessage` 為 `您的登入已過期` +**期待輸出**:錯誤區塊顯示 `您的登入已過期`,且觸發 `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..210e797 --- /dev/null +++ b/src/pages/AdminPage.test.tsx @@ -0,0 +1,103 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter, useNavigate } 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.getByRole('button', { name: '登出' })).toBeInTheDocument(); + expect(screen.getByText('管理員專屬頁面')).toBeInTheDocument(); + }); + }); + + describe('前端邏輯', () => { + it('正確顯示管理員身分標籤', () => { + (useAuth as any).mockReturnValue({ + user: { role: 'admin' }, + logout: mockLogout, + }); + + renderComponent(); + + const badge = screen.getByText('管理員'); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveClass('role-badge', 'admin'); + }); + + it('正確顯示一般用戶身分標籤', () => { + (useAuth as any).mockReturnValue({ + user: { role: 'user' }, + logout: mockLogout, + }); + + renderComponent(); + + const badge = screen.getByText('一般用戶'); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveClass('role-badge', 'user'); + }); + + it('點擊登出按鈕', () => { + renderComponent(); + + const logoutButton = screen.getByRole('button', { name: '登出' }); + fireEvent.click(logoutButton); + + expect(mockLogout).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true, state: null }); + }); + }); + + describe('路由導向', () => { + it('點擊返回按鈕', () => { + renderComponent(); + + const backLink = screen.getByRole('link', { name: /← 返回/i }); + expect(backLink).toHaveAttribute('href', '/dashboard'); + }); + }); +}); diff --git a/src/pages/DashboardPage.test.tsx b/src/pages/DashboardPage.test.tsx new file mode 100644 index 0000000..b3dc2c5 --- /dev/null +++ b/src/pages/DashboardPage.test.tsx @@ -0,0 +1,179 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter, useNavigate } 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: 'TestUser', role: 'user' }, + logout: mockLogout, + }); + + // Default API config + (productApi.getProducts as any).mockResolvedValue([]); + }); + + const renderComponent = () => { + return render( + + + + ); + }; + + describe('前端邏輯', () => { + it('管理員看到專屬連結', async () => { + (useAuth as any).mockReturnValue({ + user: { username: 'AdminUser', role: 'admin' }, + logout: mockLogout, + }); + + renderComponent(); + + // Wait for loading to finish just so component is settled + 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(); + }); + + it('歡迎區塊正常渲染用戶資訊', async () => { + (useAuth as any).mockReturnValue({ + user: { username: 'TestUser', role: 'user' }, + logout: mockLogout, + }); + + renderComponent(); + + await waitFor(() => { + expect(screen.queryByText('載入商品中...')).not.toBeInTheDocument(); + }); + + expect(screen.getByText('Welcome, TestUser 👋')).toBeInTheDocument(); + expect(screen.getByText('T')).toBeInTheDocument(); + expect(screen.getByText('一般用戶')).toBeInTheDocument(); + }); + + 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 }); + }); + }); + + describe('Mock API', () => { + it('初始載入時顯示 Loading', () => { + // Keep the promise pending to simulate loading state indefinitely + (productApi.getProducts as any).mockImplementation(() => new Promise(() => {})); + + renderComponent(); + + expect(screen.getByText('載入商品中...')).toBeInTheDocument(); + }); + + it('成功取得商品資料並渲染', 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(); + + // wait for loading to disappear + await waitFor(() => { + expect(screen.queryByText('載入商品中...')).not.toBeInTheDocument(); + }); + + expect(screen.getByText('商品A')).toBeInTheDocument(); + expect(screen.getByText('商品B')).toBeInTheDocument(); + // check if price is correctly formatted + expect(screen.getByText('NT$ 100')).toBeInTheDocument(); + expect(screen.getByText('NT$ 200')).toBeInTheDocument(); + }); + + it('取得商品資料失敗且後端有錯誤訊息', async () => { + (productApi.getProducts as any).mockRejectedValue({ + response: { + data: { message: '伺服器發生異常' } + } + }); + + renderComponent(); + + expect(await screen.findByText('伺服器發生異常')).toBeInTheDocument(); + }); + + it('取得商品資料失敗時的預設錯誤訊息', async () => { + (productApi.getProducts as any).mockRejectedValue({ + response: { + status: 500 + // No data.message provided + } + }); + + renderComponent(); + + expect(await screen.findByText('無法載入商品資料')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/pages/LoginPage.test.tsx b/src/pages/LoginPage.test.tsx new file mode 100644 index 0000000..5be4e51 --- /dev/null +++ b/src/pages/LoginPage.test.tsx @@ -0,0 +1,214 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter, useNavigate } from 'react-router-dom'; +import { LoginPage } from './LoginPage'; +import { AuthProvider, 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' } }); + fireEvent.change(passwordInput, { target: { value: 'Valid123' } }); + fireEvent.click(submitButton); + + expect(await screen.findByText('請輸入有效的 Email 格式')).toBeInTheDocument(); + expect(mockLogin).not.toHaveBeenCalled(); + }); + + it('密碼長度不足 8 碼', async () => { + 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: 'a1' } }); + 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: '登入' }); + + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.change(passwordInput, { target: { value: '12345678' } }); + fireEvent.click(submitButton); + + expect(await screen.findByText('密碼必須包含英文字母和數字')).toBeInTheDocument(); + expect(mockLogin).not.toHaveBeenCalled(); + }); + }); + + describe('Mock API', () => { + it('登入成功時顯示載入中並導向', async () => { + // Mock login to succeed with a slight delay + mockLogin.mockImplementationOnce(() => new Promise((resolve) => setTimeout(resolve, 100))); + + 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(); + + // Wait for navigation + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }); + }); + + // Button should be restored after unmount/finish + await waitFor(() => { + expect(submitButton).toHaveTextContent('登入'); + }) + }); + + it('登入失敗時顯示後端自訂錯誤訊息', 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(); + expect(submitButton).toHaveTextContent('登入'); + expect(submitButton).not.toBeDisabled(); + }); + + it('登入失敗且後端未提供訊息時的預設錯誤', async () => { + // Simulate generic 500 error or network error without message + mockLogin.mockRejectedValueOnce(new Error('Network Error')); + + 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(); + }); + }); + + describe('路由導向', () => { + it('若已登入則自動導回控制台', () => { + (useAuth as any).mockReturnValue({ + login: mockLogin, + isAuthenticated: true, + authExpiredMessage: '', + clearAuthExpiredMessage: mockClearAuthExpiredMessage, + }); + + renderComponent(); + + expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }); + }); + }); + + describe('全域狀態', () => { + it('顯示登入過期的快顯訊息', () => { + (useAuth as any).mockReturnValue({ + login: mockLogin, + isAuthenticated: false, + authExpiredMessage: '您的登入已過期', + clearAuthExpiredMessage: mockClearAuthExpiredMessage, + }); + + renderComponent(); + + expect(screen.getByText('您的登入已過期')).toBeInTheDocument(); + expect(mockClearAuthExpiredMessage).toHaveBeenCalled(); + }); + }); +}); From 5b88c936a1e5c6315ce08923cf05f382ac77efeb Mon Sep 17 00:00:00 2001 From: raintaip Date: Tue, 10 Mar 2026 11:41:53 +0800 Subject: [PATCH 02/17] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=99=BB?= =?UTF-8?q?=E5=85=A5=E9=A0=81=E9=9D=A2=E5=8F=8A=E5=85=B6=E5=96=AE=E5=85=83?= =?UTF-8?q?=E6=B8=AC=E8=A9=A6=EF=BC=8C=E5=8C=85=E5=90=AB=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E9=A9=97=E8=AD=89=E3=80=81=E7=99=BB=E5=85=A5=E9=82=8F=E8=BC=AF?= =?UTF-8?q?=E8=88=87=E9=8C=AF=E8=AA=A4=E8=99=95=E7=90=86=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/LoginPage.test.tsx | 15 +++++++++++++-- src/pages/LoginPage.tsx | 13 +++++++------ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/pages/LoginPage.test.tsx b/src/pages/LoginPage.test.tsx index 5be4e51..ef0e607 100644 --- a/src/pages/LoginPage.test.tsx +++ b/src/pages/LoginPage.test.tsx @@ -93,18 +93,29 @@ describe('LoginPage', () => { expect(mockLogin).not.toHaveBeenCalled(); }); - it('密碼未包含英數混合', async () => { + it('密碼未包含大小寫英數混合', async () => { 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: '12345678' } }); fireEvent.click(submitButton); + expect(await screen.findByText('密碼必須包含大小寫英文字母和數字')).toBeInTheDocument(); + + // 沒有大寫字母 + fireEvent.change(passwordInput, { target: { value: 'lower123' } }); + fireEvent.click(submitButton); + expect(await screen.findByText('密碼必須包含大小寫英文字母和數字')).toBeInTheDocument(); + + // 沒有小寫字母 + fireEvent.change(passwordInput, { target: { value: 'UPPER123' } }); + fireEvent.click(submitButton); + expect(await screen.findByText('密碼必須包含大小寫英文字母和數字')).toBeInTheDocument(); - expect(await screen.findByText('密碼必須包含英文字母和數字')).toBeInTheDocument(); expect(mockLogin).not.toHaveBeenCalled(); }); }); diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index e87d241..d717b0f 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} @@ -143,7 +144,7 @@ export const LoginPage: React.FC = () => { {!import.meta.env.VITE_API_URL && (
-

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

+

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

)} From 7929fa555e82c55ede9f7854b172408180d690af Mon Sep 17 00:00:00 2001 From: raintaip Date: Tue, 10 Mar 2026 15:41:56 +0800 Subject: [PATCH 03/17] =?UTF-8?q?test:=20=E6=96=B0=E5=A2=9E=20AdminPage?= =?UTF-8?q?=E3=80=81DashboardPage=20=E5=8F=8A=20LoginPage=20=E7=9A=84?= =?UTF-8?q?=E5=96=AE=E5=85=83=E6=B8=AC=E8=A9=A6=E8=88=87=E7=9B=B8=E9=97=9C?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/test/AdminPage.md | 22 ++ doc/test/DashboardPage.md | 52 +++++ doc/test/LoginPage.md | 58 +++++ src/pages/AdminPage.test.tsx | 43 ++-- src/pages/DashboardPage.test.tsx | 151 +++++++------ src/pages/LoginPage.test.tsx | 358 +++++++++++++++---------------- 6 files changed, 405 insertions(+), 279 deletions(-) create mode 100644 doc/test/AdminPage.md create mode 100644 doc/test/DashboardPage.md create mode 100644 doc/test/LoginPage.md 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.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.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/src/pages/AdminPage.test.tsx b/src/pages/AdminPage.test.tsx index 210e797..37495e3 100644 --- a/src/pages/AdminPage.test.tsx +++ b/src/pages/AdminPage.test.tsx @@ -1,5 +1,5 @@ import { render, screen, fireEvent } from '@testing-library/react'; -import { MemoryRouter, useNavigate } from 'react-router-dom'; +import { MemoryRouter } from 'react-router-dom'; import { AdminPage } from './AdminPage'; import { useAuth } from '../context/AuthContext'; import { vi, describe, it, expect, beforeEach } from 'vitest'; @@ -43,45 +43,39 @@ describe('AdminPage', () => { ); }; - describe('前端元素', () => { - it('畫面正常渲染管理後台', () => { + describe('畫面渲染', () => { + it('應該正確渲染管理後台的元素', () => { renderComponent(); expect(screen.getByRole('heading', { name: /管理後台/i })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /← 返回/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: '登出' })).toBeInTheDocument(); expect(screen.getByText('管理員專屬頁面')).toBeInTheDocument(); }); }); - describe('前端邏輯', () => { - it('正確顯示管理員身分標籤', () => { + describe('權限顯示', () => { + it('應正確顯示使用者的角色標籤', () => { + // Test admin (useAuth as any).mockReturnValue({ user: { role: 'admin' }, logout: mockLogout, }); + const { unmount } = renderComponent(); + expect(screen.getByText('管理員')).toBeInTheDocument(); + unmount(); - renderComponent(); - - const badge = screen.getByText('管理員'); - expect(badge).toBeInTheDocument(); - expect(badge).toHaveClass('role-badge', 'admin'); - }); - - it('正確顯示一般用戶身分標籤', () => { + // Test user (useAuth as any).mockReturnValue({ user: { role: 'user' }, logout: mockLogout, }); - renderComponent(); - - const badge = screen.getByText('一般用戶'); - expect(badge).toBeInTheDocument(); - expect(badge).toHaveClass('role-badge', 'user'); + expect(screen.getByText('一般用戶')).toBeInTheDocument(); }); + }); - it('點擊登出按鈕', () => { + describe('登出邏輯', () => { + it('點擊登出按鈕應觸發登出並導向登入頁', () => { renderComponent(); const logoutButton = screen.getByRole('button', { name: '登出' }); @@ -91,13 +85,4 @@ describe('AdminPage', () => { expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true, state: null }); }); }); - - describe('路由導向', () => { - it('點擊返回按鈕', () => { - renderComponent(); - - const backLink = screen.getByRole('link', { name: /← 返回/i }); - expect(backLink).toHaveAttribute('href', '/dashboard'); - }); - }); }); diff --git a/src/pages/DashboardPage.test.tsx b/src/pages/DashboardPage.test.tsx index b3dc2c5..28d545b 100644 --- a/src/pages/DashboardPage.test.tsx +++ b/src/pages/DashboardPage.test.tsx @@ -1,5 +1,5 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { MemoryRouter, useNavigate } from 'react-router-dom'; +import { MemoryRouter } from 'react-router-dom'; import { DashboardPage } from './DashboardPage'; import { useAuth } from '../context/AuthContext'; import { productApi } from '../api/productApi'; @@ -40,7 +40,7 @@ describe('DashboardPage', () => { // Default Auth config (useAuth as any).mockReturnValue({ - user: { username: 'TestUser', role: 'user' }, + user: { username: 'Alice', role: 'admin' }, logout: mockLogout, }); @@ -56,28 +56,23 @@ describe('DashboardPage', () => { ); }; - describe('前端邏輯', () => { - it('管理員看到專屬連結', async () => { - (useAuth as any).mockReturnValue({ - user: { username: 'AdminUser', role: 'admin' }, - logout: mockLogout, - }); + describe('畫面渲染', () => { + it('初始載入時應顯示載入中狀態', () => { + // Keep the promise pending to simulate loading state indefinitely + (productApi.getProducts as any).mockImplementation(() => new Promise(() => {})); renderComponent(); - - // Wait for loading to finish just so component is settled - await waitFor(() => { - expect(screen.queryByText('載入商品中...')).not.toBeInTheDocument(); - }); - expect(screen.getByRole('link', { name: /🛠️ 管理後台/i })).toBeInTheDocument(); + expect(screen.getByText('載入商品中...')).toBeInTheDocument(); + expect(screen.getByText('載入商品中...').previousSibling).toHaveClass('loading-spinner'); }); - it('一般用戶看不到專屬連結', async () => { - (useAuth as any).mockReturnValue({ - user: { username: 'NormalUser', role: 'user' }, - logout: mockLogout, - }); + 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(); @@ -85,95 +80,113 @@ describe('DashboardPage', () => { expect(screen.queryByText('載入商品中...')).not.toBeInTheDocument(); }); - expect(screen.queryByRole('link', { name: /🛠️ 管理後台/i })).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(); }); + }); - it('歡迎區塊正常渲染用戶資訊', async () => { - (useAuth as any).mockReturnValue({ - user: { username: 'TestUser', role: 'user' }, - logout: mockLogout, + describe('錯誤處理', () => { + it('API 請求失敗時 (非 401) 應顯示錯誤訊息', async () => { + (productApi.getProducts as any).mockRejectedValue({ + response: { + status: 500, + data: { message: '伺服器發生錯誤' } + } }); renderComponent(); - await waitFor(() => { - expect(screen.queryByText('載入商品中...')).not.toBeInTheDocument(); - }); - - expect(screen.getByText('Welcome, TestUser 👋')).toBeInTheDocument(); - expect(screen.getByText('T')).toBeInTheDocument(); - expect(screen.getByText('一般用戶')).toBeInTheDocument(); + expect(await screen.findByText('伺服器發生錯誤')).toBeInTheDocument(); + expect(screen.queryByText('載入商品中...')).not.toBeInTheDocument(); }); - it('點擊登出按鈕', async () => { + it('Token 過期或無效 (401) 時不顯示預設錯誤訊息', async () => { + (productApi.getProducts as any).mockRejectedValue({ + response: { + status: 401, + data: { message: 'Token expired' } + } + }); + 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 }); + // 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('Mock API', () => { - it('初始載入時顯示 Loading', () => { - // Keep the promise pending to simulate loading state indefinitely - (productApi.getProducts as any).mockImplementation(() => new Promise(() => {})); + describe('權限顯示', () => { + it('依據使用者名稱正確顯示歡迎詞與頭像', async () => { + (useAuth as any).mockReturnValue({ + user: { username: 'Alice', role: 'user' }, + logout: mockLogout, + }); renderComponent(); - expect(screen.getByText('載入商品中...')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByText('載入商品中...')).not.toBeInTheDocument(); + }); + + expect(screen.getByText('Welcome, Alice 👋')).toBeInTheDocument(); + expect(screen.getByText('A')).toHaveClass('avatar'); }); - it('成功取得商品資料並渲染', 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); + it('為管理員時應顯示前往管理後台的連結', async () => { + (useAuth as any).mockReturnValue({ + user: { username: 'AdminUser', role: 'admin' }, + logout: mockLogout, + }); renderComponent(); - - // wait for loading to disappear + await waitFor(() => { expect(screen.queryByText('載入商品中...')).not.toBeInTheDocument(); }); - expect(screen.getByText('商品A')).toBeInTheDocument(); - expect(screen.getByText('商品B')).toBeInTheDocument(); - // check if price is correctly formatted - expect(screen.getByText('NT$ 100')).toBeInTheDocument(); - expect(screen.getByText('NT$ 200')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /🛠️ 管理後台/i })).toBeInTheDocument(); }); - it('取得商品資料失敗且後端有錯誤訊息', async () => { - (productApi.getProducts as any).mockRejectedValue({ - response: { - data: { message: '伺服器發生異常' } - } + it('非管理員時不應顯示管理後台連結', async () => { + (useAuth as any).mockReturnValue({ + user: { username: 'NormalUser', role: 'user' }, + logout: mockLogout, }); renderComponent(); - expect(await screen.findByText('伺服器發生異常')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByText('載入商品中...')).not.toBeInTheDocument(); + }); + + expect(screen.queryByRole('link', { name: /🛠️ 管理後台/i })).not.toBeInTheDocument(); }); + }); - it('取得商品資料失敗時的預設錯誤訊息', async () => { - (productApi.getProducts as any).mockRejectedValue({ - response: { - status: 500 - // No data.message provided - } + describe('登出邏輯', () => { + it('點擊登出按鈕應觸發登出並導向登入頁', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.queryByText('載入商品中...')).not.toBeInTheDocument(); }); - renderComponent(); + const logoutButton = screen.getByRole('button', { name: '登出' }); + fireEvent.click(logoutButton); - expect(await screen.findByText('無法載入商品資料')).toBeInTheDocument(); + 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 index ef0e607..332b472 100644 --- a/src/pages/LoginPage.test.tsx +++ b/src/pages/LoginPage.test.tsx @@ -1,17 +1,17 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { MemoryRouter, useNavigate } from 'react-router-dom'; import { LoginPage } from './LoginPage'; -import { AuthProvider, useAuth } from '../context/AuthContext'; +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, - }; + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; }); // Mock AuthContext @@ -19,207 +19,203 @@ 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, - })), - }; + 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, + 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' } }); - fireEvent.change(passwordInput, { target: { value: 'Valid123' } }); - fireEvent.click(submitButton); - - expect(await screen.findByText('請輸入有效的 Email 格式')).toBeInTheDocument(); - expect(mockLogin).not.toHaveBeenCalled(); + + 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(); + }); }); - it('密碼長度不足 8 碼', async () => { - renderComponent(); - - const emailInput = screen.getByLabelText('電子郵件'); - const passwordInput = screen.getByLabelText('密碼'); - const submitButton = screen.getByRole('button', { name: '登入' }); + describe('表單驗證', () => { + it('輸入無效的 Email 格式時應顯示錯誤訊息', async () => { + renderComponent(); - fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); - fireEvent.change(passwordInput, { target: { value: 'a1' } }); - fireEvent.click(submitButton); + const emailInput = screen.getByLabelText('電子郵件'); + const passwordInput = screen.getByLabelText('密碼'); + const submitButton = screen.getByRole('button', { name: '登入' }); - expect(await screen.findByText('密碼必須至少 8 個字元')).toBeInTheDocument(); - expect(mockLogin).not.toHaveBeenCalled(); - }); + fireEvent.change(emailInput, { target: { value: 'invalid-email' } }); + fireEvent.change(passwordInput, { target: { value: 'Valid123' } }); + fireEvent.click(submitButton); - it('密碼未包含大小寫英數混合', async () => { - 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: '12345678' } }); - fireEvent.click(submitButton); - expect(await screen.findByText('密碼必須包含大小寫英文字母和數字')).toBeInTheDocument(); - - // 沒有大寫字母 - fireEvent.change(passwordInput, { target: { value: 'lower123' } }); - fireEvent.click(submitButton); - expect(await screen.findByText('密碼必須包含大小寫英文字母和數字')).toBeInTheDocument(); - - // 沒有小寫字母 - fireEvent.change(passwordInput, { target: { value: 'UPPER123' } }); - fireEvent.click(submitButton); - expect(await screen.findByText('密碼必須包含大小寫英文字母和數字')).toBeInTheDocument(); - - expect(mockLogin).not.toHaveBeenCalled(); - }); - }); - - describe('Mock API', () => { - it('登入成功時顯示載入中並導向', async () => { - // Mock login to succeed with a slight delay - mockLogin.mockImplementationOnce(() => new Promise((resolve) => setTimeout(resolve, 100))); - - 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(); - - // Wait for navigation - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }); - }); - - // Button should be restored after unmount/finish - await waitFor(() => { - expect(submitButton).toHaveTextContent('登入'); - }) - }); + 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: '登入' }); - it('登入失敗時顯示後端自訂錯誤訊息', async () => { - mockLogin.mockRejectedValueOnce({ - response: { - data: { - message: '帳號或密碼錯誤', - }, - }, - }); + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'Short1!' } }); + fireEvent.click(submitButton); - renderComponent(); + expect(await screen.findByText('密碼必須至少 8 個字元')).toBeInTheDocument(); + expect(mockLogin).not.toHaveBeenCalled(); + }); - const emailInput = screen.getByLabelText('電子郵件'); - const passwordInput = screen.getByLabelText('密碼'); - const submitButton = screen.getByRole('button', { name: '登入' }); + it('輸入未包含大小寫與數字的密碼時應顯示錯誤訊息', async () => { + renderComponent(); - fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); - fireEvent.change(passwordInput, { target: { value: 'Valid123' } }); - fireEvent.click(submitButton); + const emailInput = screen.getByLabelText('電子郵件'); + const passwordInput = screen.getByLabelText('密碼'); + const submitButton = screen.getByRole('button', { name: '登入' }); - expect(await screen.findByText('帳號或密碼錯誤')).toBeInTheDocument(); - expect(submitButton).toHaveTextContent('登入'); - expect(submitButton).not.toBeDisabled(); + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + + // Only lowercase + fireEvent.change(passwordInput, { target: { value: 'alllowercase123' } }); + fireEvent.click(submitButton); + expect(await screen.findByText('密碼必須包含大小寫英文字母和數字')).toBeInTheDocument(); + + // Only uppercase + fireEvent.change(passwordInput, { target: { value: 'ALLUPPERCASE123' } }); + fireEvent.click(submitButton); + expect(await screen.findByText('密碼必須包含大小寫英文字母和數字')).toBeInTheDocument(); + + expect(mockLogin).not.toHaveBeenCalled(); + }); }); - it('登入失敗且後端未提供訊息時的預設錯誤', async () => { - // Simulate generic 500 error or network error without message - mockLogin.mockRejectedValueOnce(new Error('Network Error')); + describe('登入邏輯', () => { + it('API 回傳錯誤時應顯示錯誤提示', async () => { + mockLogin.mockRejectedValueOnce({ + response: { + data: { + message: '帳號或密碼錯誤', + }, + }, + }); - renderComponent(); + renderComponent(); - const emailInput = screen.getByLabelText('電子郵件'); - const passwordInput = screen.getByLabelText('密碼'); - const submitButton = screen.getByRole('button', { name: '登入' }); + 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); + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'Valid123' } }); + fireEvent.click(submitButton); - expect(await screen.findByText('登入失敗,請稍後再試')).toBeInTheDocument(); - }); - }); + expect(await screen.findByText('帳號或密碼錯誤')).toBeInTheDocument(); + }); + + it('登入成功後應導向至 Dashboard', async () => { + mockLogin.mockResolvedValueOnce(undefined); - describe('路由導向', () => { - it('若已登入則自動導回控制台', () => { - (useAuth as any).mockReturnValue({ - login: mockLogin, - isAuthenticated: true, - authExpiredMessage: '', - clearAuthExpiredMessage: mockClearAuthExpiredMessage, - }); + renderComponent(); - 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 }); + }); + }); + }); - 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('顯示登入過期的快顯訊息', () => { - (useAuth as any).mockReturnValue({ - login: mockLogin, - isAuthenticated: false, - authExpiredMessage: '您的登入已過期', - clearAuthExpiredMessage: mockClearAuthExpiredMessage, - }); + describe('已登入狀態', () => { + it('若使用者已認證應自動導向至 Dashboard', () => { + (useAuth as any).mockReturnValue({ + login: mockLogin, + isAuthenticated: true, + authExpiredMessage: '', + clearAuthExpiredMessage: mockClearAuthExpiredMessage, + }); - renderComponent(); + renderComponent(); - expect(screen.getByText('您的登入已過期')).toBeInTheDocument(); - expect(mockClearAuthExpiredMessage).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }); + }); }); - }); }); From 544938ee07052af85461874a3535a37e74eeff2b Mon Sep 17 00:00:00 2001 From: raintaip Date: Tue, 10 Mar 2026 16:19:32 +0800 Subject: [PATCH 04/17] =?UTF-8?q?ci:=20=E6=96=B0=E5=A2=9E=20GitHub=20Actio?= =?UTF-8?q?ns=20=E5=B7=A5=E4=BD=9C=E6=B5=81=E7=A8=8B=EF=BC=8C=E7=94=A8?= =?UTF-8?q?=E6=96=BC=E5=9F=B7=E8=A1=8C=E6=B8=AC=E8=A9=A6=E4=B8=A6=E4=B8=8A?= =?UTF-8?q?=E5=82=B3=E8=A6=86=E8=93=8B=E7=8E=87=E8=88=87=E6=B8=AC=E8=A9=A6?= =?UTF-8?q?=E5=A0=B1=E5=91=8A=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 46 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..af5aaeb --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,46 @@ +name: CI - Run Tests + +on: + push: + branches: + - "**" + pull_request: + branches: + - "**" + +jobs: + test: + name: Run Tests & Coverage + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup 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-${{ github.ref_name }}-${{ github.run_number }} + path: coverage/ + retention-days: 30 + + - name: Upload HTML test report + uses: actions/upload-artifact@v4 + if: always() + with: + name: html-test-report-${{ github.ref_name }}-${{ github.run_number }}- + path: html-report/ + retention-days: 30 From a44259aa62fd0560796bd94a1a6aa9b035926864 Mon Sep 17 00:00:00 2001 From: raintaip Date: Wed, 11 Mar 2026 15:56:34 +0800 Subject: [PATCH 05/17] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=99=BB?= =?UTF-8?q?=E5=85=A5=E9=A0=81=E9=9D=A2=E5=85=83=E4=BB=B6=E3=80=81=E5=85=B6?= =?UTF-8?q?=E5=96=AE=E5=85=83=E6=B8=AC=E8=A9=A6=E8=88=87=E6=B8=AC=E8=A9=A6?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/test/LoginPage-test.md | 6 +++--- src/pages/LoginPage.test.tsx | 26 ++++++++++++++++---------- src/pages/LoginPage.tsx | 2 +- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/doc/test/LoginPage-test.md b/doc/test/LoginPage-test.md index e45fbd5..7697e82 100644 --- a/doc/test/LoginPage-test.md +++ b/doc/test/LoginPage-test.md @@ -46,21 +46,21 @@ --- -## [ ] 【Mock API】登入失敗且後端未提供訊息時的預設錯誤 +## [x] 【Mock API】登入失敗且後端未提供訊息時的預設錯誤 **範例輸入**:輸入正確格式的 Email 與密碼並提交,Mock API 回傳 500(未包含特定 message) **期待輸出**:錯誤區塊顯示預設訊息「登入失敗,請稍後再試」 --- -## [ ] 【路由導向】若已登入則自動導回控制台 +## [x] 【路由導向】若已登入則自動導回控制台 **範例輸入**:設定 AuthContext 中的 `isAuthenticated` 為 `true`,存取登入頁 **期待輸出**:自動導向至 `/dashboard`(使用 replace) --- -## [ ] 【全域狀態】顯示登入過期的快顯訊息 +## [x] 【全域狀態】顯示登入過期的快顯訊息 **範例輸入**:設定 AuthContext 中的 `authExpiredMessage` 為 `您的登入已過期` **期待輸出**:錯誤區塊顯示 `您的登入已過期`,且觸發 `clearAuthExpiredMessage` 清除原訊息 diff --git a/src/pages/LoginPage.test.tsx b/src/pages/LoginPage.test.tsx index 332b472..05b0771 100644 --- a/src/pages/LoginPage.test.tsx +++ b/src/pages/LoginPage.test.tsx @@ -1,5 +1,5 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { MemoryRouter, useNavigate } from 'react-router-dom'; +import { MemoryRouter } from 'react-router-dom'; import { LoginPage } from './LoginPage'; import { useAuth } from '../context/AuthContext'; import { vi, describe, it, expect, beforeEach } from 'vitest'; @@ -93,23 +93,29 @@ describe('LoginPage', () => { expect(mockLogin).not.toHaveBeenCalled(); }); - it('輸入未包含大小寫與數字的密碼時應顯示錯誤訊息', async () => { + it('輸入未包含大小寫或數字的密碼時應顯示錯誤訊息', async () => { renderComponent(); const emailInput = screen.getByLabelText('電子郵件'); const passwordInput = screen.getByLabelText('密碼'); const submitButton = screen.getByRole('button', { name: '登入' }); - fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); - - // Only lowercase - fireEvent.change(passwordInput, { target: { value: 'alllowercase123' } }); - fireEvent.click(submitButton); + 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 - fireEvent.change(passwordInput, { target: { value: 'ALLUPPERCASE123' } }); - fireEvent.click(submitButton); + // 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(); diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index d717b0f..2652762 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -144,7 +144,7 @@ export const LoginPage: React.FC = () => { {!import.meta.env.VITE_API_URL && (
-

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

+

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

)} From 59690462d376dfc83226696f78927bd880b5306b Mon Sep 17 00:00:00 2001 From: raintaip Date: Thu, 12 Mar 2026 16:07:50 +0800 Subject: [PATCH 06/17] feat: Add GitHub Actions workflow for automated testing with coverage reporting. --- .github/workflows/test.yml | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index af5aaeb..eef5b4d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,27 +1,23 @@ -name: CI - Run Tests +name: Automated Testing on: push: branches: - - "**" - pull_request: - branches: - - "**" + - '**' jobs: - test: - name: Run Tests & Coverage + test: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - - name: Setup Node.js + - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: "20" - cache: "npm" + node-version: '20' + cache: 'npm' - name: Install dependencies run: npm ci @@ -33,14 +29,6 @@ jobs: uses: actions/upload-artifact@v4 if: always() with: - name: coverage-report-${{ github.ref_name }}-${{ github.run_number }} + name: coverage-report path: coverage/ - retention-days: 30 - - - name: Upload HTML test report - uses: actions/upload-artifact@v4 - if: always() - with: - name: html-test-report-${{ github.ref_name }}-${{ github.run_number }}- - path: html-report/ - retention-days: 30 + retention-days: 7 From 25f72f23a7a4a0a2bdc9ebf9dd5f4b8d7ccd66f3 Mon Sep 17 00:00:00 2001 From: raintaip Date: Thu, 12 Mar 2026 16:14:51 +0800 Subject: [PATCH 07/17] feat: Add GitHub Actions workflow for automated testing with coverage reporting. --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eef5b4d..28d4e62 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,7 @@ name: Automated Testing on: push: - branches: + branches: - '**' jobs: From 83d843b0285020a691c6100eb261f7fb7fd8db04 Mon Sep 17 00:00:00 2001 From: raintaip Date: Thu, 12 Mar 2026 16:26:57 +0800 Subject: [PATCH 08/17] ci: Add GitHub Actions workflow for automated testing and coverage reporting. --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 28d4e62..523c964 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,10 @@ on: push: branches: - '**' + pull_request: + branches: + - '**' + workflow_dispatch: jobs: test: From 7603c37b50f9fb205a2ab3a8d9f8d040d5b2042e Mon Sep 17 00:00:00 2001 From: raintaip Date: Thu, 12 Mar 2026 16:56:36 +0800 Subject: [PATCH 09/17] =?UTF-8?q?=E6=B8=AC=E8=A9=A6=E7=A8=8B=E5=BC=8F?= =?UTF-8?q?=E9=8C=AF=E8=AA=A4=E7=8B=80=E6=85=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/LoginPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 2652762..44de964 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -52,7 +52,7 @@ export const LoginPage: React.FC = () => { return false; } if (!hasUpperCase || !hasLowerCase || !hasNumber) { - setPasswordError('密碼必須包含大小寫英文字母和數字'); + setPasswordError('密碼必須包含大小寫英文字母ssd 123djfgkjdjasdf 和數字'); return false; } setPasswordError(''); From 73b14c0f6f1c241c8f881a9ee03cc375062ef56e Mon Sep 17 00:00:00 2001 From: raintaip Date: Mon, 16 Mar 2026 11:28:02 +0800 Subject: [PATCH 10/17] =?UTF-8?q?=E6=B8=AC=E8=A9=A6=E7=A8=8B=E5=BC=8F?= =?UTF-8?q?=E9=8C=AF=E8=AA=A4=E4=B8=8A=E5=82=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/LoginPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 44de964..4a3a69a 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -52,7 +52,7 @@ export const LoginPage: React.FC = () => { return false; } if (!hasUpperCase || !hasLowerCase || !hasNumber) { - setPasswordError('密碼必須包含大小寫英文字母ssd 123djfgkjdjasdf 和數字'); + setPasswordError('密碼必須包含大小寫英文字母askdjfjl;lkjf和數字'); return false; } setPasswordError(''); From 0b9e11321915c57a9605802418d9a884f78be958 Mon Sep 17 00:00:00 2001 From: raintaip Date: Mon, 16 Mar 2026 11:44:00 +0800 Subject: [PATCH 11/17] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=AD=A3=E7=A2=BA?= =?UTF-8?q?=E5=BE=8C=E5=86=8D=E4=B8=8A=E5=82=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/LoginPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 4a3a69a..2652762 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -52,7 +52,7 @@ export const LoginPage: React.FC = () => { return false; } if (!hasUpperCase || !hasLowerCase || !hasNumber) { - setPasswordError('密碼必須包含大小寫英文字母askdjfjl;lkjf和數字'); + setPasswordError('密碼必須包含大小寫英文字母和數字'); return false; } setPasswordError(''); From fb58a8014228adf94a10c64b6ade902292802478 Mon Sep 17 00:00:00 2001 From: raintaip Date: Mon, 16 Mar 2026 13:27:58 +0800 Subject: [PATCH 12/17] =?UTF-8?q?=E9=8C=AF=E8=AA=A4=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/LoginPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 2652762..339424d 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -52,7 +52,7 @@ export const LoginPage: React.FC = () => { return false; } if (!hasUpperCase || !hasLowerCase || !hasNumber) { - setPasswordError('密碼必須包含大小寫英文字母和數字'); + setPasswordError('密碼必須包含大小寫英skdjflkldsf89898 文字母和數字'); return false; } setPasswordError(''); From 88f6ae36764a203535f0ba16fb476d59774b02ea Mon Sep 17 00:00:00 2001 From: raintaip Date: Mon, 16 Mar 2026 13:32:02 +0800 Subject: [PATCH 13/17] =?UTF-8?q?=E7=A8=8B=E5=BC=8F=E9=8C=AF=E8=AA=A4?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/LoginPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 339424d..2652762 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -52,7 +52,7 @@ export const LoginPage: React.FC = () => { return false; } if (!hasUpperCase || !hasLowerCase || !hasNumber) { - setPasswordError('密碼必須包含大小寫英skdjflkldsf89898 文字母和數字'); + setPasswordError('密碼必須包含大小寫英文字母和數字'); return false; } setPasswordError(''); From cf66b86bd4547cb55fdb3d81db2d637a8ddaefc7 Mon Sep 17 00:00:00 2001 From: raintaip Date: Mon, 16 Mar 2026 13:40:58 +0800 Subject: [PATCH 14/17] =?UTF-8?q?=E6=B8=AC=E8=A9=A6=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/LoginPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 2652762..c3f9e68 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -52,7 +52,7 @@ export const LoginPage: React.FC = () => { return false; } if (!hasUpperCase || !hasLowerCase || !hasNumber) { - setPasswordError('密碼必須包含大小寫英文字母和數字'); + setPasswordError('密碼必須要包含大小寫英文字母和數字'); return false; } setPasswordError(''); From cf1421f65cede7e6bd9c0d00d847b0224ab31a56 Mon Sep 17 00:00:00 2001 From: raintaip Date: Mon, 16 Mar 2026 13:45:12 +0800 Subject: [PATCH 15/17] =?UTF-8?q?=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/LoginPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index c3f9e68..2652762 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -52,7 +52,7 @@ export const LoginPage: React.FC = () => { return false; } if (!hasUpperCase || !hasLowerCase || !hasNumber) { - setPasswordError('密碼必須要包含大小寫英文字母和數字'); + setPasswordError('密碼必須包含大小寫英文字母和數字'); return false; } setPasswordError(''); From b96a1af72c3cea2e6d6a8cbadb981c6b1d1f6b48 Mon Sep 17 00:00:00 2001 From: raintaip Date: Mon, 16 Mar 2026 14:03:03 +0800 Subject: [PATCH 16/17] test1 --- src/pages/LoginPage.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/LoginPage.test.tsx b/src/pages/LoginPage.test.tsx index 05b0771..9d07445 100644 --- a/src/pages/LoginPage.test.tsx +++ b/src/pages/LoginPage.test.tsx @@ -85,7 +85,7 @@ describe('LoginPage', () => { const passwordInput = screen.getByLabelText('密碼'); const submitButton = screen.getByRole('button', { name: '登入' }); - fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.change(emailInput, { target: { value: 'test1@example.com' } }); fireEvent.change(passwordInput, { target: { value: 'Short1!' } }); fireEvent.click(submitButton); From 3622b63c496153f55b3c2f2595d339a086bde959 Mon Sep 17 00:00:00 2001 From: raintaip Date: Mon, 16 Mar 2026 14:12:14 +0800 Subject: [PATCH 17/17] test2 --- src/pages/LoginPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 2652762..e24ea39 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -134,7 +134,7 @@ export const LoginPage: React.FC = () => { {isLoading ? ( <> - 登入中... + 登入中..... ) : ( '登入'