docs: audit reports + feature docs + skills + admin-v2 + config sync
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
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>
This commit is contained in:
219
admin-v2/tests/pages/Login.test.tsx
Normal file
219
admin-v2/tests/pages/Login.test.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
// ============================================================
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user