From 7e1a59a87152a5127a15ea9e879157a59c976d7b Mon Sep 17 00:00:00 2001 From: Xiang Date: Thu, 22 Jan 2026 11:45:15 +0800 Subject: [PATCH 1/2] =?UTF-8?q?1.=20=E6=92=B0=E5=AF=ABpages=E9=A0=81?= =?UTF-8?q?=E9=9D=A2=E4=B8=8B=E7=9A=84=E6=B8=AC=E8=A9=A6=E6=A1=88=E4=BE=8B?= =?UTF-8?q?=202.=20=E5=9C=A8=20Github=20Action=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=87=AA=E5=8B=95=E6=B8=AC=E8=A9=A6=E7=9A=84=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/test/admin_page_test.md | 39 ++++++++ doc/test/dashboard_page_test.md | 65 +++++++++++++ doc/test/login_page_test.md | 92 ++++++++++++++++++ package-lock.json | 32 ++----- src/pages/AdminPage.test.tsx | 91 ++++++++++++++++++ src/pages/DashboardPage.test.tsx | 150 ++++++++++++++++++++++++++++++ src/pages/LoginPage.test.tsx | 154 +++++++++++++++++++++++++++++++ 7 files changed, 600 insertions(+), 23 deletions(-) create mode 100644 doc/test/admin_page_test.md create mode 100644 doc/test/dashboard_page_test.md create mode 100644 doc/test/login_page_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/doc/test/admin_page_test.md b/doc/test/admin_page_test.md new file mode 100644 index 0000000..bcb7c4f --- /dev/null +++ b/doc/test/admin_page_test.md @@ -0,0 +1,39 @@ +--- +description: AdminPage 測試案例 +--- + +> 狀態:初始為 [ ]、完成為 [x] +> 注意:狀態只能在測試通過後由流程更新。 +> 測試類型:前端元素、function 邏輯、Mock API、驗證權限... + +--- + +## [x] 【前端元素】檢查頁面基本元素 +**範例輸入**: +1. User role 為 'admin' +2. 進入管理頁面 +**期待輸出**: +1. 顯示標題 "管理後台" +2. 顯示 "管理員" 角色標籤 +3. 顯示 "登出" 按鈕 +4. 顯示 "管理員專屬頁面" 內容卡片 +5. 顯示 "返回" 連結 + +--- + +## [x] 【Router 導航】返回 Dashboard +**範例輸入**: +1. 點擊 "返回" 連結 +**期待輸出**: +1. 導向至 "/dashboard" + +--- + +## [x] 【function 邏輯】登出功能 +**範例輸入**: +1. 點擊 "登出" 按鈕 +**期待輸出**: +1. 呼叫 logout method +2. 導向至 "/login" + +--- diff --git a/doc/test/dashboard_page_test.md b/doc/test/dashboard_page_test.md new file mode 100644 index 0000000..5d2872b --- /dev/null +++ b/doc/test/dashboard_page_test.md @@ -0,0 +1,65 @@ +--- +description: DashboardPage 測試案例 +--- + +> 狀態:初始為 [ ]、完成為 [x] +> 注意:狀態只能在測試通過後由流程更新。 +> 測試類型:前端元素、function 邏輯、Mock API、驗證權限... + +--- + +## [x] 【前端元素】檢查頁面基本元素 (Admin) +**範例輸入**: +1. User role 為 'admin' +2. User name 為 'AdminUser' +3. 進入儀表板頁面 +**期待輸出**: +1. 顯示標題 "儀表板" +2. 顯示歡迎訊息 "Welcome, AdminUser" +3. 顯示 "管理員" 角色標籤 +4. 顯示 "管理後台" 連結 +5. 顯示 "登出" 按鈕 + +--- + +## [x] 【前端元素】檢查頁面基本元素 (General User) +**範例輸入**: +1. User role 為 'user' +2. User name 為 'NormalUser' +3. 進入儀表板頁面 +**期待輸出**: +1. 顯示 "一般用戶" 角色標籤 +2. **不顯示** "管理後台" 連結 + +--- + +## [x] 【Mock API】載入商品列表 - 成功 +**範例輸入**: +1. 進入頁面 +2. Mock API 回傳商品列表 (e.g., Apple, Banana) +**期待輸出**: +1. 初始顯示 "載入商品中..." +2. 載入完成後顯示商品列表 +3. 顯示商品名稱 "Apple", "Banana" +4. 顯示商品價格 + +--- + +## [x] 【Mock API】載入商品列表 - 失敗 +**範例輸入**: +1. 進入頁面 +2. Mock API 回傳 500 Error +**期待輸出**: +1. 顯示錯誤訊息 "無法載入商品資料" +2. 不顯示商品列表 + +--- + +## [x] 【function 邏輯】登出功能 +**範例輸入**: +1. 點擊 "登出" 按鈕 +**期待輸出**: +1. 呼叫 logout method +2. 導向至 "/login" + +--- diff --git a/doc/test/login_page_test.md b/doc/test/login_page_test.md new file mode 100644 index 0000000..4c1eafc --- /dev/null +++ b/doc/test/login_page_test.md @@ -0,0 +1,92 @@ +--- +description: LoginPage 測試案例 +--- + +> 狀態:初始為 [ ]、完成為 [x] +> 注意:狀態只能在測試通過後由流程更新。 +> 測試類型:前端元素、function 邏輯、Mock API、驗證權限... + +--- + +## [x] 【前端元素】檢查頁面基本元素 +**範例輸入**:進入登入頁面 +**期待輸出**: +1. 顯示標題 "歡迎回來" +2. 顯示 Email 輸入框 +3. 顯示密碼輸入框 +4. 顯示登入按鈕 + +--- + +## [x] 【function 邏輯】Email 格式驗證 - 無效格式 +**範例輸入**: +1. Email 輸入 "invalid-email" +2. 點擊登入按鈕 +**期待輸出**: +1. Email 輸入框下方顯示錯訊訊息 "請輸入有效的 Email 格式" +2. 不會觸發登入 API + +--- + +## [x] 【function 邏輯】密碼格式驗證 - 長度不足 +**範例輸入**: +1. 密碼輸入 "123" +2. 點擊登入按鈕 +**期待輸出**: +1. 密碼輸入框下方顯示錯誤訊息 "密碼必須至少 8 個字元" +2. 不會觸發登入 API + +--- + +## [x] 【function 邏輯】密碼格式驗證 - 缺少英文字母或數字 +**範例輸入**: +1. 密碼輸入 "12345678" (只有數字) +2. 點擊登入按鈕 +**期待輸出**: +1. 密碼輸入框下方顯示錯誤訊息 "密碼必須包含英文字母和數字" +2. 不會觸發登入 API + +--- + +## [x] 【Mock API】登入成功 +**範例輸入**: +1. Email 輸入 "test@example.com" +2. 密碼 輸入 "password123" +3. Mock API login 回傳失敗 +4. 點擊登入按鈕 +**期待輸出**: +1. 呼叫 login API +2. 導向至 "/dashboard" + +--- + +## [x] 【Mock API】登入失敗 +**範例輸入**: +1. Email 輸入 "test@example.com" +2. 密碼 輸入 "password123" +3. Mock API login 回傳失敗 (401 Unauthorized) +4. 點擊登入按鈕 +**期待輸出**: +1. 顯示錯誤 Banner "登入失敗,請稍後再試" (或 API 回傳的訊息) +2. 停留在登入頁面 + +--- + +## [x] 【前端元素】Loading 狀態 +**範例輸入**: +1. 輸入有效帳密 +2. 點擊登入按鈕 (API 請求中) +**期待輸出**: +1. 登入按鈕顯示 "登入中..." +2. 登入按鈕為 disabled 狀態 + +--- + +## [x] 【驗證權限】已登入自動導向 +**範例輸入**: +1. 設定 AuthContext 狀態為 isAuthenticated = true +2. 進入登入頁面 +**期待輸出**: +1. 自動導向至 "/dashboard" + +--- 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..089414d --- /dev/null +++ b/src/pages/AdminPage.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { AdminPage } from './AdminPage'; +import * as AuthContext from '../context/AuthContext'; +import { MemoryRouter } from 'react-router-dom'; + +// Mock imports +vi.mock('../context/AuthContext'); + +// We need to mock useNavigate but keep other router functionality (like Link) working if possible, +// or we can wrap with MemoryRouter. +// However, to spy on useNavigate, we often mock it. +// To handle Link properly with mocked router, usually we can use MemoryRouter and spy on the history, +// OR we can mock react-router-dom partially. + +const mockNavigate = vi.fn(); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +describe('AdminPage', () => { + const mockLogout = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + + // Default mock implementation + vi.mocked(AuthContext.useAuth).mockReturnValue({ + user: { username: 'AdminUser', role: 'admin' }, + token: 'valid-token', + isLoading: false, + isAuthenticated: true, + authExpiredMessage: null, + login: vi.fn(), + logout: mockLogout, + checkAuth: vi.fn(), + clearAuthExpiredMessage: vi.fn(), + }); + }); + + // [] 【前端元素】檢查頁面基本元素 + it('should display basic elements for admin user', () => { + render( + + + + ); + + expect(screen.getByText('🛠️ 管理後台')).toBeInTheDocument(); + expect(screen.getByText('管理員')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '登出' })).toBeInTheDocument(); + expect(screen.getByText('管理員專屬頁面')).toBeInTheDocument(); + expect(screen.getByText('← 返回')).toBeInTheDocument(); + }); + + // [] 【Router 導航】返回 Dashboard + it('should navigate to dashboard when back link is clicked', () => { + render( + + + + ); + + const backLink = screen.getByText('← 返回'); + expect(backLink).toHaveAttribute('href', '/dashboard'); + + // Note: verifying actual navigation with MemoryRouter and Link + // usually involves inspecting the router state or using userEvent to click and checking location. + // Since we didn't mock Link, it renders an anchor tag with href. + // The simple check verify the 'to' prop is passed correctly to href. + }); + + // [] 【function 邏輯】登出功能 + it('should call logout and navigate to login', () => { + render( + + + + ); + + fireEvent.click(screen.getByRole('button', { name: '登出' })); + + 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..624abe0 --- /dev/null +++ b/src/pages/DashboardPage.test.tsx @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { DashboardPage } from './DashboardPage'; +import * as AuthContext from '../context/AuthContext'; +import { productApi } from '../api/productApi'; +import { MemoryRouter } from 'react-router-dom'; + +// Mock imports +vi.mock('../context/AuthContext'); +vi.mock('../api/productApi'); + +const mockNavigate = vi.fn(); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +describe('DashboardPage', () => { + const mockLogout = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + + // Default auth mock + vi.mocked(AuthContext.useAuth).mockReturnValue({ + user: { username: 'AdminUser', role: 'admin' }, + token: 'valid-token', + isLoading: false, + isAuthenticated: true, + authExpiredMessage: null, + login: vi.fn(), + logout: mockLogout, + checkAuth: vi.fn(), + clearAuthExpiredMessage: vi.fn(), + }); + + // Default product mock + vi.mocked(productApi.getProducts).mockResolvedValue([ + { id: 1, name: 'Apple', price: 100, description: 'Fresh Apple' }, + { id: 2, name: 'Banana', price: 50, description: 'Yellow Banana' }, + ]); + }); + + // [] 【前端元素】檢查頁面基本元素 (Admin) + it('should display elements for admin user', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('儀表板')).toBeInTheDocument(); + expect(screen.getByText('Welcome, AdminUser 👋')).toBeInTheDocument(); + expect(screen.getByText('管理員')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '登出' })).toBeInTheDocument(); + expect(screen.getByText('🛠️ 管理後台')).toBeInTheDocument(); + }); + }); + + // [] 【前端元素】檢查頁面基本元素 (General User) + it('should display elements for general user', async () => { + vi.mocked(AuthContext.useAuth).mockReturnValue({ + user: { username: 'NormalUser', role: 'user' }, + token: 'valid-token', + isLoading: false, + isAuthenticated: true, + authExpiredMessage: null, + login: vi.fn(), + logout: mockLogout, + checkAuth: vi.fn(), + clearAuthExpiredMessage: vi.fn(), + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Welcome, NormalUser 👋')).toBeInTheDocument(); + expect(screen.getByText('一般用戶')).toBeInTheDocument(); + expect(screen.queryByText('🛠️ 管理後台')).not.toBeInTheDocument(); + }); + }); + + // [] 【Mock API】載入商品列表 - 成功 + it('should load and display products successfully', async () => { + render( + + + + ); + + // Intially loading + expect(screen.getByText('載入商品中...')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.queryByText('載入商品中...')).not.toBeInTheDocument(); + expect(screen.getByText('Apple')).toBeInTheDocument(); + expect(screen.getByText('Banana')).toBeInTheDocument(); + expect(screen.getByText('NT$ 100')).toBeInTheDocument(); + }); + }); + + // [] 【Mock API】載入商品列表 - 失敗 + it('should show error message when product load fails', async () => { + const errorMessage = '無法載入商品資料'; + vi.mocked(productApi.getProducts).mockRejectedValue({ + response: { + data: { message: errorMessage } + } + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + expect(screen.queryByText('載入商品中...')).not.toBeInTheDocument(); + expect(screen.queryByText('Apple')).not.toBeInTheDocument(); + }); + }); + + // [] 【function 邏輯】登出功能 + it('should call logout and navigate to login', async () => { + render( + + + + ); + + // Wait for loading to finish first to ensure everything is settled if needed, + // though button is likely available immediately. + await waitFor(() => expect(screen.queryByText('載入商品中...')).not.toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: '登出' })); + + 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..87c27b3 --- /dev/null +++ b/src/pages/LoginPage.test.tsx @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { LoginPage } from './LoginPage'; +import * as AuthContext from '../context/AuthContext'; +import * as RouterModule from 'react-router-dom'; + +// Mock imports +vi.mock('../context/AuthContext'); +vi.mock('react-router-dom'); + +describe('LoginPage', () => { + const mockLogin = vi.fn(); + const mockNavigate = vi.fn(); + const mockClearAuthExpiredMessage = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + + // Default mock implementation + vi.mocked(AuthContext.useAuth).mockReturnValue({ + login: mockLogin, + isAuthenticated: false, + authExpiredMessage: null, + clearAuthExpiredMessage: mockClearAuthExpiredMessage, + user: null, + token: null, + isLoading: false, + logout: vi.fn(), + checkAuth: vi.fn(), + }); + + vi.mocked(RouterModule.useNavigate).mockReturnValue(mockNavigate); + }); + + // [] 【前端元素】檢查頁面基本元素 + it('should display basic elements', () => { + render(); + + expect(screen.getByText('歡迎回來')).toBeInTheDocument(); + expect(screen.getByLabelText('電子郵件')).toBeInTheDocument(); + expect(screen.getByLabelText('密碼')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '登入' })).toBeInTheDocument(); + }); + + // [] 【function 邏輯】Email 格式驗證 - 無效格式 + it('should show error for invalid email format', () => { + render(); + + const emailInput = screen.getByLabelText('電子郵件'); + const loginButton = screen.getByRole('button', { name: '登入' }); + + fireEvent.change(emailInput, { target: { value: 'invalid-email' } }); + fireEvent.click(loginButton); + + expect(screen.getByText('請輸入有效的 Email 格式')).toBeInTheDocument(); + expect(mockLogin).not.toHaveBeenCalled(); + }); + + // [] 【function 邏輯】密碼格式驗證 - 長度不足 + it('should show error for short password', () => { + render(); + + const passwordInput = screen.getByLabelText('密碼'); + const loginButton = screen.getByRole('button', { name: '登入' }); + + fireEvent.change(passwordInput, { target: { value: '123' } }); + fireEvent.click(loginButton); + + expect(screen.getByText('密碼必須至少 8 個字元')).toBeInTheDocument(); + expect(mockLogin).not.toHaveBeenCalled(); + }); + + // [] 【function 邏輯】密碼格式驗證 - 缺少英文字母或數字 + it('should show error for password missing letters or numbers', () => { + render(); + + const passwordInput = screen.getByLabelText('密碼'); + const loginButton = screen.getByRole('button', { name: '登入' }); + + fireEvent.change(passwordInput, { target: { value: '12345678' } }); + fireEvent.click(loginButton); + + expect(screen.getByText('密碼必須包含英文字母和數字')).toBeInTheDocument(); + expect(mockLogin).not.toHaveBeenCalled(); + }); + + // [] 【Mock API】登入成功 + it('should call login API and navigate to dashboard on success', async () => { + mockLogin.mockResolvedValueOnce(undefined); + render(); + + fireEvent.change(screen.getByLabelText('電子郵件'), { target: { value: 'test@example.com' } }); + fireEvent.change(screen.getByLabelText('密碼'), { target: { value: 'password123' } }); + fireEvent.click(screen.getByRole('button', { name: '登入' })); + + await waitFor(() => { + expect(mockLogin).toHaveBeenCalledWith('test@example.com', 'password123'); + expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }); + }); + }); + + // [] 【Mock API】登入失敗 + it('should show error message on login failure', async () => { + const errorMessage = '帳號或密碼錯誤'; + mockLogin.mockRejectedValueOnce({ + response: { + data: { message: errorMessage } + } + }); + render(); + + fireEvent.change(screen.getByLabelText('電子郵件'), { target: { value: 'test@example.com' } }); + fireEvent.change(screen.getByLabelText('密碼'), { target: { value: 'password123' } }); + fireEvent.click(screen.getByRole('button', { name: '登入' })); + + await waitFor(() => { + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); + + // [] 【前端元素】Loading 狀態 + it('should show loading state during login', async () => { + // Mock a promise that doesn't resolve immediately + mockLogin.mockReturnValue(new Promise(() => { })); + render(); + + fireEvent.change(screen.getByLabelText('電子郵件'), { target: { value: 'test@example.com' } }); + fireEvent.change(screen.getByLabelText('密碼'), { target: { value: 'password123' } }); + fireEvent.click(screen.getByRole('button', { name: '登入' })); + + expect(screen.getByRole('button')).toBeDisabled(); + expect(screen.getByText('登入中...')).toBeInTheDocument(); + }); + + // [] 【驗證權限】已登入自動導向 + it('should redirect to dashboard if already authenticated', () => { + vi.mocked(AuthContext.useAuth).mockReturnValue({ + login: mockLogin, + isAuthenticated: true, + authExpiredMessage: null, + clearAuthExpiredMessage: mockClearAuthExpiredMessage, + user: { id: '1', email: 'test@example.com', name: 'Test User', role: 'user' }, + token: 'valid-token', + isLoading: false, + logout: vi.fn(), + checkAuth: vi.fn(), + }); + + render(); + + expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }); + }); +}); From 52b24613fa5b5903abb59c202dc223900ecfc30d Mon Sep 17 00:00:00 2001 From: Xiang Date: Thu, 22 Jan 2026 11:54:29 +0800 Subject: [PATCH 2/2] =?UTF-8?q?1.=20=E6=92=B0=E5=AF=AB=20pages=20=E9=A0=81?= =?UTF-8?q?=E9=9D=A2=E4=B8=8B=E7=9A=84=E6=B8=AC=E8=A9=A6=E6=A1=88=E4=BE=8B?= =?UTF-8?q?=202.=20=E5=9C=A8=20GitHub=20Action=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=87=AA=E5=8B=95=E6=B8=AC=E8=A9=A6=E7=9A=84=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fc682b5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: Automate Test & Coverage + +on: + push: + branches: + - '**' + +jobs: + test: + 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 + if: always() # Upload even if tests fail, though usually we want to see it only on success. But request implies "after testing". Let's keep default behavior (success) or explicitly simple. The user asked "testing finished, then generate report". Usually implies success. + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/