Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Update audit tracker, roadmap, architecture docs, add admin-v2 Roles page + Billing tests, sync CLAUDE.md, Cargo.toml, docker-compose.yml, add deep-research / frontend-design / chart-visualization skills Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
220 lines
6.9 KiB
TypeScript
220 lines
6.9 KiB
TypeScript
// ============================================================
|
|
// 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<string, unknown>) => 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<typeof import('react-router-dom')>('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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<MemoryRouter initialEntries={initialEntries}>
|
|
<Login />
|
|
</MemoryRouter>
|
|
</QueryClientProvider>,
|
|
)
|
|
}
|
|
|
|
/** Click the LoginForm submit button (Ant Design renders "登 录" with a space) */
|
|
function getSubmitButton(): HTMLElement {
|
|
const btn = document.querySelector<HTMLButtonElement>(
|
|
'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()
|
|
})
|
|
})
|