// ============================================================ // Login 页面测试 // ============================================================ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MemoryRouter } from 'react-router-dom' import Login from '@/pages/Login' // ── Mock data ──────────────────────────────────────────────── const mockLoginResponse = { token: 'jwt-token-123', refresh_token: 'refresh-token-456', account: { id: 'acc-001', username: 'testadmin', email: 'admin@zclaw.ai', display_name: 'Admin', role: 'super_admin', status: 'active', totp_enabled: false, last_login_at: null, created_at: '2026-01-01T00:00:00Z', llm_routing: 'relay', }, } const mockAccount = { id: 'acc-001', username: 'testadmin', email: 'admin@zclaw.ai', display_name: 'Admin', role: 'super_admin', status: 'active', totp_enabled: false, last_login_at: null, created_at: '2026-01-01T00:00:00Z', llm_routing: 'relay', } // ── Hoisted mocks ──────────────────────────────────────────── const { mockLogin, mockNavigate, mockAuthServiceLogin } = vi.hoisted(() => ({ mockLogin: vi.fn(), mockNavigate: vi.fn(), mockAuthServiceLogin: vi.fn(), })) vi.mock('@/stores/authStore', () => ({ useAuthStore: Object.assign( vi.fn((selector: (s: Record) => unknown) => selector({ login: mockLogin }), ), { getState: () => ({ token: null, refreshToken: null, logout: vi.fn() }) }, ), })) vi.mock('@/services/auth', () => ({ authService: { login: mockAuthServiceLogin, }, })) vi.mock('react-router-dom', async () => { const actual = await vi.importActual('react-router-dom') return { ...actual, useNavigate: () => mockNavigate, } }) beforeEach(() => { mockLogin.mockClear() mockNavigate.mockClear() mockAuthServiceLogin.mockClear() }) // ── Helper: render with providers ──────────────────────────── function renderLogin(initialEntries = ['/login']) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, }, }) return render( , ) } /** Click the LoginForm submit button (Ant Design renders "登 录" with a space) */ function getSubmitButton(): HTMLElement { const btn = document.querySelector( 'button.ant-btn-primary[type="button"]', ) if (!btn) throw new Error('Submit button not found') return btn } // ── Tests ──────────────────────────────────────────────────── describe('Login page', () => { it('renders the login form with username and password fields', () => { renderLogin() expect(screen.getByText('登录到 ZCLAW')).toBeInTheDocument() expect(screen.getByPlaceholderText('请输入用户名')).toBeInTheDocument() expect(screen.getByPlaceholderText('请输入密码')).toBeInTheDocument() const submitButton = getSubmitButton() expect(submitButton).toBeTruthy() }) it('shows the ZCLAW brand logo', () => { renderLogin() expect(screen.getByText('Z')).toBeInTheDocument() expect(screen.getByText(/ZCLAW Admin/)).toBeInTheDocument() }) it('successful login calls authStore.login and navigates to /', async () => { const user = userEvent.setup() mockAuthServiceLogin.mockResolvedValue(mockLoginResponse) renderLogin() await user.type(screen.getByPlaceholderText('请输入用户名'), 'testadmin') await user.type(screen.getByPlaceholderText('请输入密码'), 'password123') await user.click(getSubmitButton()) await waitFor(() => { expect(mockLogin).toHaveBeenCalledWith( 'jwt-token-123', 'refresh-token-456', mockAccount, ) }) expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true }) }) it('navigates to redirect path after login', async () => { const user = userEvent.setup() mockAuthServiceLogin.mockResolvedValue(mockLoginResponse) renderLogin(['/login?from=/settings']) await user.type(screen.getByPlaceholderText('请输入用户名'), 'testadmin') await user.type(screen.getByPlaceholderText('请输入密码'), 'password123') await user.click(getSubmitButton()) await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith('/settings', { replace: true }) }) }) it('shows TOTP field when server returns TOTP-related error', async () => { const user = userEvent.setup() const error = new Error('请输入两步验证码 (TOTP)') Object.assign(error, { status: 403 }) mockAuthServiceLogin.mockRejectedValue(error) renderLogin() // Initially no TOTP field expect(screen.queryByPlaceholderText('请输入 6 位验证码')).not.toBeInTheDocument() await user.type(screen.getByPlaceholderText('请输入用户名'), 'testadmin') await user.type(screen.getByPlaceholderText('请输入密码'), 'password123') await user.click(getSubmitButton()) // After TOTP error, TOTP field appears await waitFor(() => { expect(screen.getByPlaceholderText('请输入 6 位验证码')).toBeInTheDocument() }) }) it('shows error message on invalid credentials', async () => { const user = userEvent.setup() const error = new Error('用户名或密码错误') mockAuthServiceLogin.mockRejectedValue(error) renderLogin() await user.type(screen.getByPlaceholderText('请输入用户名'), 'wrong') await user.type(screen.getByPlaceholderText('请输入密码'), 'wrong') await user.click(getSubmitButton()) await waitFor(() => { expect(screen.getByText('用户名或密码错误')).toBeInTheDocument() }) }) it('does not call authStore.login on failed login', async () => { const user = userEvent.setup() const error = new Error('用户名或密码错误') mockAuthServiceLogin.mockRejectedValue(error) renderLogin() await user.type(screen.getByPlaceholderText('请输入用户名'), 'wrong') await user.type(screen.getByPlaceholderText('请输入密码'), 'wrong') await user.click(getSubmitButton()) await waitFor(() => { expect(screen.getByText('用户名或密码错误')).toBeInTheDocument() }) expect(mockLogin).not.toHaveBeenCalled() expect(mockNavigate).not.toHaveBeenCalled() }) })