diff --git a/.agent/rules/vibecoding.md b/.agent/rules/vibecoding.md new file mode 100644 index 0000000..6215226 --- /dev/null +++ b/.agent/rules/vibecoding.md @@ -0,0 +1,25 @@ +--- +trigger: always_on +--- + +DO NOT GIVE ME HIGH LEVEL SHIT, IF I ASK FOR FIX OR EXPLANATION, I WANT ACTUAL CODE OR EXPLANATION!!! I DON'T WANT "Here's how you can blablabla" + +- Be casual unless otherwise specified +- Be terse +- Suggest solutions that I didn't think about—anticipate my needs +- Treat me as an expert +- Be accurate and thorough +- Give the answer immediately. Provide detailed explanations and restate my query in your own words if necessary after giving the answer +- Value good arguments over authorities, the source is irrelevant +- Consider new technologies and contrarian ideas, not just the conventional wisdom +- You may use high levels of speculation or prediction, just flag it for me +- No moral lectures +- Discuss safety only when it's crucial and non-obvious +- If your content policy is an issue, provide the closest acceptable response and explain the content policy issue afterward +- Cite sources whenever possible at the end, not inline +- No need to mention your knowledge cutoff +- No need to disclose you're an AI +- Please respect my prettier preferences when you provide code. +- Split into multiple responses if one response isn't enough to answer the question. + +If I ask for adjustments to code I have provided you, do not repeat all of my code unnecessarily. Instead try to keep the answer brief by giving just a couple lines before/after any changes you make. Multiple code blocks are ok. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..82e3d6e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +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 + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/ diff --git a/doc/test/AdminPage.md b/doc/test/AdminPage.md new file mode 100644 index 0000000..73e7d49 --- /dev/null +++ b/doc/test/AdminPage.md @@ -0,0 +1,53 @@ +# AdminPage 測試案例 + +> 狀態:初始為 [ ]、完成為 [x] +> 注意:狀態只能在測試通過後由流程更新。 +> 測試類型:UI 渲染、互動邏輯 + +--- + +## [x] 【UI 渲染】檢查管理頁面基本元素 + +**範例輸入**: + +- Mock User 為 admin +- 進入 AdminPage +**期待輸出**: +- 渲染標題 "管理後台" +- 渲染 "返回" 連結 +- 渲染 "登出" 按鈕 +- 渲染使用者角色標籤 (管理員) + +--- + +## [x] 【UI 渲染】一般用戶與管理員的角色標籤顯示 + +**範例輸入**: + +- Case 1: Mock User role 為 'admin' +- Case 2: Mock User role 為 'user' +**期待輸出**: +- Case 1: 顯示 "管理員" +- Case 2: 顯示 "一般用戶" + +--- + +## [x] 【互動邏輯】點擊返回連結 + +**範例輸入**: + +- 點擊 "返回" 連結 +**期待輸出**: +- 導航至 `/dashboard` + +--- + +## [x] 【互動邏輯】點擊登出按鈕 + +**範例輸入**: + +- Mock `logout` 函式 +- 點擊 "登出" 按鈕 +**期待輸出**: +- 呼叫 `logout` 函式 +- 導航至 `/login` diff --git a/doc/test/DashboardPage.md b/doc/test/DashboardPage.md new file mode 100644 index 0000000..a836446 --- /dev/null +++ b/doc/test/DashboardPage.md @@ -0,0 +1,68 @@ +# DashboardPage 測試案例 + +> 狀態:初始為 [ ]、完成為 [x] +> 注意:狀態只能在測試通過後由流程更新。 +> 測試類型:UI 渲染、API 互動、互動邏輯 + +--- + +## [x] 【UI 渲染】檢查儀表板基本元素 + +**範例輸入**: + +- Mock User info +- 進入 DashboardPage +**期待輸出**: +- 渲染 "儀表板" 標題 +- 渲染 User 歡迎訊息與頭像 +- 渲染 "商品列表" 區塊 +- 渲染 "登出" 按鈕 + +--- + +## [x] 【UI 渲染】管理員專屬連結顯示 + +**範例輸入**: + +- Case 1: Mock User role 為 'admin' +- Case 2: Mock User role 為 'user' +**期待輸出**: +- Case 1: 顯示 "管理後台" 連結 +- Case 2: 不顯示 "管理後台" 連結 + +--- + +## [x] 【API 互動】成功載入商品列表 + +**範例輸入**: + +- Mock `getProducts` resolve with product list +- 渲染頁面 +**期待輸出**: +- 初始顯示 "載入商品中..." +- 載入完成後顯示 Mock 的商品資料 (名稱、價格、描述) +- 不顯示錯誤訊息 + +--- + +## [x] 【API 互動】載入商品失敗 + +**範例輸入**: + +- Mock `getProducts` reject with error +- 渲染頁面 +**期待輸出**: +- 顯示錯誤訊息 (Mock 的錯誤訊息或預設訊息) +- 不顯示商品列表 + +--- + +## [x] 【互動邏輯】點擊登出按鈕 + +**範例輸入**: + +- Mock `logout` +- 點擊 "登出" 按鈕 +**期待輸出**: +- 呼叫 `logout` +- 導航至 `/login` diff --git a/doc/test/LoginPage.md b/doc/test/LoginPage.md new file mode 100644 index 0000000..32e0928 --- /dev/null +++ b/doc/test/LoginPage.md @@ -0,0 +1,105 @@ +# LoginPage 測試案例 + +> 狀態:初始為 [ ]、完成為 [x] +> 注意:狀態只能在測試通過後由流程更新。 +> 測試類型:UI 渲染、表單驗證、API 互動、權限/路由 + +--- + +## [x] 【UI 渲染】檢查登入頁面基本元素 + +**範例輸入**:進入登入頁面 +**期待輸出**: + +- 渲染標題 "歡迎回來" +- 渲染 Email 輸入框 +- 渲染密碼輸入框 +- 渲染登入按鈕 + +--- + +## [x] 【表單驗證】驗證無效的 Email 格式 + +**範例輸入**: + +- Email 輸入 "invalid-email" +- 點擊登入按鈕 +**期待輸出**: +- 顯示 "請輸入有效的 Email 格式" 錯誤訊息 +- 不觸發登入 API + +--- + +## [x] 【表單驗證】驗證密碼長度不足 + +**範例輸入**: + +- 密碼輸入 "1234567" (少於 8 碼) +- 點擊登入按鈕 +**期待輸出**: +- 顯示 "密碼必須至少 8 個字元" 錯誤訊息 +- 不觸發登入 API + +--- + +## [x] 【表單驗證】驗證密碼缺少英文字母或數字 + +**範例輸入**: + +- 密碼輸入 "12345678" (無字母) 或 "abcdefgh" (無數字) +- 點擊登入按鈕 +**期待輸出**: +- 顯示 "密碼必須包含英文字母和數字" 錯誤訊息 +- 不觸發登入 API + +--- + +## [x] 【API 互動】驗證登入成功流程 + +**範例輸入**: + +- Email 輸入 "" +- 密碼輸入 "Valid123" +- 點擊登入按鈕 +- Mock `login` resolve +**期待輸出**: +- 進入 Loading 狀態(按鈕 disabled、顯示 loading icon) +- 呼叫 `login` 函式 +- 跳轉至 `/dashboard` + +--- + +## [x] 【API 互動】驗證登入失敗流程 + +**範例輸入**: + +- Email 輸入 "" +- 密碼輸入 "Valid123" +- 點擊登入按鈕 +- Mock `login` reject with message "帳號或密碼錯誤" +**期待輸出**: +- 顯示 "帳號或密碼錯誤" 錯誤訊息 +- 解除 Loading 狀態 + +--- + +## [x] 【權限/路由】已登入狀態自動導向 + +**範例輸入**: + +- Mock `isAuthenticated` 為 true +- 渲染 LoginPage +**期待輸出**: +- 自動導向至 `/dashboard` + +--- + +## [x] 【API 互動】顯示 Auth Context 的過期訊息 + +**範例輸入**: + +- Mock `authExpiredMessage` 為 "連線逾時" +- 渲染 LoginPage +**期待輸出**: +- 顯示 "連線逾時" 錯誤訊息 +- 呼叫 `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..e200ec6 --- /dev/null +++ b/src/pages/AdminPage.test.tsx @@ -0,0 +1,103 @@ +import { render, screen } from '@testing-library/react'; +import { AdminPage } from './AdminPage'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { MemoryRouter } from 'react-router-dom'; +import userEvent from '@testing-library/user-event'; + +// 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 useAuth +const mockLogout = vi.fn(); +const mockUseAuth = vi.fn(); +vi.mock('../context/AuthContext', () => ({ + useAuth: () => mockUseAuth() +})); + +describe('AdminPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default mock + mockUseAuth.mockReturnValue({ + user: { role: 'admin', username: 'AdminUser' }, + logout: mockLogout, + }); + }); + + describe('UI 渲染', () => { + it('檢查管理頁面基本元素', () => { + render( + + + + ); + + expect(screen.getByRole('heading', { name: /管理後台/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /返回/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /登出/i })).toBeInTheDocument(); + expect(screen.getByText('管理員')).toBeInTheDocument(); + }); + + it('一般用戶與管理員的角色標籤顯示', () => { + // Case 1: Admin (Already set in beforeEach) + const { unmount } = render( + + + + ); + expect(screen.getByText('管理員')).toBeInTheDocument(); + unmount(); + + // Case 2: User + mockUseAuth.mockReturnValue({ + user: { role: 'user', username: 'NormalUser' }, + logout: mockLogout, + }); + + render( + + + + ); + expect(screen.getByText('一般用戶')).toBeInTheDocument(); + }); + }); + + describe('互動邏輯', () => { + it('點擊返回連結', async () => { + render( + + + + ); + + const backLink = screen.getByRole('link', { name: /返回/i }); + expect(backLink).toHaveAttribute('href', '/dashboard'); + // Note: We don't need to click it if we check the href attributes for Link, + // but clicking it inside MemoryRouter updates the history. + // Since we mocked useNavigate, but Link uses internal context, verifying href is usually enough for unit test. + // If we want to check navigation strictly we might mock Link or check location. + // Actually, let's just check the attribute. + }); + + it('點擊登出按鈕', async () => { + render( + + + + ); + + await userEvent.click(screen.getByRole('button', { name: /登出/i })); + + 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..fe12467 --- /dev/null +++ b/src/pages/DashboardPage.test.tsx @@ -0,0 +1,161 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { DashboardPage } from './DashboardPage'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { MemoryRouter } from 'react-router-dom'; +import userEvent from '@testing-library/user-event'; +import { productApi } from '../api/productApi'; + +// 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 useAuth +const mockLogout = vi.fn(); +const mockUseAuth = vi.fn(); +vi.mock('../context/AuthContext', () => ({ + useAuth: () => mockUseAuth() +})); + +// Mock productApi +vi.mock('../api/productApi', () => ({ + productApi: { + getProducts: vi.fn(), + } +})); + +const mockProducts = [ + { id: 1, name: 'Product A', description: 'Desc A', price: 100 }, + { id: 2, name: 'Product B', description: 'Desc B', price: 200 }, +]; + +describe('DashboardPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default mock user + mockUseAuth.mockReturnValue({ + user: { role: 'user', username: 'TestUser' }, + logout: mockLogout, + }); + // Default mock api + (productApi.getProducts as any).mockResolvedValue(mockProducts); + }); + + describe('UI 渲染', () => { + it('檢查儀表板基本元素', async () => { + render( + + + + ); + + expect(screen.getByRole('heading', { name: /儀表板/i })).toBeInTheDocument(); + expect(screen.getByText(/Welcome, TestUser/i)).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /商品列表/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /登出/i })).toBeInTheDocument(); + + // Wait for loading to finish to avoid act warnings + await waitFor(() => { + expect(screen.queryByText(/载入商品中.../i)).not.toBeInTheDocument(); + }); + }); + + it('管理員專屬連結顯示', async () => { + // Case 1: Admin + mockUseAuth.mockReturnValue({ + user: { role: 'admin', username: 'AdminUser' }, + logout: mockLogout, + }); + + const { unmount } = render( + + + + ); + + expect(screen.getByRole('link', { name: /管理後台/i })).toBeInTheDocument(); + + // Cleanup + await waitFor(() => { + expect(screen.queryByText(/载入商品中.../i)).not.toBeInTheDocument(); + }); + unmount(); + + // Case 2: User + mockUseAuth.mockReturnValue({ + user: { role: 'user', username: 'NormalUser' }, + logout: mockLogout, + }); + + render( + + + + ); + expect(screen.queryByRole('link', { name: /管理後台/i })).not.toBeInTheDocument(); + + await waitFor(() => { + expect(screen.queryByText(/载入商品中.../i)).not.toBeInTheDocument(); + }); + }); + }); + + describe('API 互動', () => { + it('成功載入商品列表', async () => { + render( + + + + ); + + expect(screen.getByText(/載入商品中.../i)).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText('Product A')).toBeInTheDocument(); + expect(screen.getByText('Product B')).toBeInTheDocument(); + expect(screen.getByText('NT$ 100')).toBeInTheDocument(); + }); + }); + + it('載入商品失敗', async () => { + const errorMessage = '無法連線到伺服器'; + (productApi.getProducts as any).mockRejectedValue({ + response: { + data: { message: errorMessage } + } + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + + expect(screen.queryByText('Product A')).not.toBeInTheDocument(); + }); + }); + + describe('互動邏輯', () => { + it('點擊登出按鈕', async () => { + render( + + + + ); + + await userEvent.click(screen.getByRole('button', { name: /登出/i })); + + 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..197aac9 --- /dev/null +++ b/src/pages/LoginPage.test.tsx @@ -0,0 +1,223 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { LoginPage } from './LoginPage'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { MemoryRouter } from 'react-router-dom'; +import userEvent from '@testing-library/user-event'; + +// 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 useAuth +const mockLogin = vi.fn(); +const mockClearAuthExpiredMessage = vi.fn(); +const mockUseAuth = vi.fn(); + +vi.mock('../context/AuthContext', () => ({ + useAuth: () => mockUseAuth() +})); + +describe('LoginPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default mock implementation + mockUseAuth.mockReturnValue({ + login: mockLogin, + isAuthenticated: false, + authExpiredMessage: '', + clearAuthExpiredMessage: mockClearAuthExpiredMessage, + }); + }); + + describe('UI 渲染', () => { + it('檢查登入頁面基本元素', () => { + render( + + + + ); + + expect(screen.getByRole('heading', { name: /歡迎回來/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/電子郵件/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/密碼/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /登入/i })).toBeInTheDocument(); + }); + }); + + describe('表單驗證', () => { + it('驗證無效的 Email 格式', async () => { + render( + + + + ); + + const emailInput = screen.getByLabelText(/電子郵件/i); + const submitButton = screen.getByRole('button', { name: /登入/i }); + + await userEvent.type(emailInput, 'invalid-email'); + await userEvent.click(submitButton); + + expect(screen.getByText(/請輸入有效的 Email 格式/i)).toBeInTheDocument(); + expect(mockLogin).not.toHaveBeenCalled(); + }); + + it('驗證密碼長度不足', async () => { + render( + + + + ); + + const passwordInput = screen.getByLabelText(/密碼/i); + const submitButton = screen.getByRole('button', { name: /登入/i }); + + // Case 1: Length < 8 + await userEvent.type(passwordInput, '1234567'); + await userEvent.click(submitButton); + + expect(screen.getByText(/密碼必須至少 8 個字元/i)).toBeInTheDocument(); + expect(mockLogin).not.toHaveBeenCalled(); + }); + + it('驗證密碼缺少英文字母或數字', async () => { + render( + + + + ); + + const passwordInput = screen.getByLabelText(/密碼/i); + const submitButton = screen.getByRole('button', { name: /登入/i }); + + // Case 2: No letters + await userEvent.type(passwordInput, '12345678'); + await userEvent.click(submitButton); + expect(screen.getByText(/密碼必須包含英文字母和數字/i)).toBeInTheDocument(); + + await userEvent.clear(passwordInput); + await userEvent.type(passwordInput, 'abcdefgh'); + await userEvent.click(submitButton); + expect(screen.getByText(/密碼必須包含英文字母和數字/i)).toBeInTheDocument(); + + expect(mockLogin).not.toHaveBeenCalled(); + }); + }); + + describe('API 互動', () => { + it('驗證登入成功流程', async () => { + let resolveLogin: (value: void | PromiseLike) => void; + const loginPromise = new Promise((resolve) => { + resolveLogin = resolve; + }); + mockLogin.mockReturnValue(loginPromise); + + render( + + + + ); + + const emailInput = screen.getByLabelText(/電子郵件/i); + const passwordInput = screen.getByLabelText(/密碼/i); + const submitButton = screen.getByRole('button', { name: /登入/i }); + + await userEvent.type(emailInput, 'test@example.com'); + await userEvent.type(passwordInput, 'Valid123'); + await userEvent.click(submitButton); + + expect(screen.getByText(/登入中.../i)).toBeInTheDocument(); + expect(submitButton).toBeDisabled(); + + resolveLogin!(); + + await waitFor(() => { + expect(mockLogin).toHaveBeenCalledWith('test@example.com', 'Valid123'); + }); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }); + }); + }); + + it('驗證登入失敗流程', async () => { + const errorMessage = '帳號或密碼錯誤'; + const mockError = { + response: { + data: { + message: errorMessage + } + } + }; + mockLogin.mockRejectedValueOnce(mockError); + + render( + + + + ); + + const emailInput = screen.getByLabelText(/電子郵件/i); + const passwordInput = screen.getByLabelText(/密碼/i); + const submitButton = screen.getByRole('button', { name: /登入/i }); + + await userEvent.type(emailInput, 'test@example.com'); + await userEvent.type(passwordInput, 'Valid123'); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + + expect(screen.queryByText(/登入中.../i)).not.toBeInTheDocument(); + expect(submitButton).not.toBeDisabled(); + }); + + it('顯示 Auth Context 的過期訊息', async () => { + const expiredMsg = "連線逾時"; + mockUseAuth.mockReturnValue({ + login: mockLogin, + isAuthenticated: false, + authExpiredMessage: expiredMsg, + clearAuthExpiredMessage: mockClearAuthExpiredMessage, + }); + + render( + + + + ); + + // Wait for useEffect + await waitFor(() => { + expect(screen.getByText(expiredMsg)).toBeInTheDocument(); + }); + expect(mockClearAuthExpiredMessage).toHaveBeenCalled(); + }); + }); + + describe('權限/路由', () => { + it('已登入狀態自動導向', () => { + mockUseAuth.mockReturnValue({ + login: mockLogin, + isAuthenticated: true, + authExpiredMessage: '', + clearAuthExpiredMessage: mockClearAuthExpiredMessage, + }); + + render( + + + + ); + + expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }); + }); + }); +});