feat: admin frontend - React + Vite, auth pages, user management, roles, permissions, webhooks, devices, logs
This commit is contained in:
391
frontend/admin/src/pages/auth/RegisterPage/RegisterPage.test.tsx
Normal file
391
frontend/admin/src/pages/auth/RegisterPage/RegisterPage.test.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { message } from 'antd'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AuthCapabilities, RegisterResponse } from '@/types'
|
||||
import { RegisterPage } from './RegisterPage'
|
||||
|
||||
const TEXT = {
|
||||
usernamePlaceholder: '用户名',
|
||||
nicknamePlaceholder: '昵称(选填)',
|
||||
emailPlaceholder: '邮箱地址(选填)',
|
||||
phonePlaceholder: '手机号(选填)',
|
||||
passwordPlaceholder: '密码',
|
||||
confirmPasswordPlaceholder: '确认密码',
|
||||
createAccount: '创建账号',
|
||||
returnLogin: '返回登录',
|
||||
forgotPassword: '忘记密码?',
|
||||
sendCode: '获取验证码',
|
||||
resendActivation: '重新发送激活邮件',
|
||||
adminBootstrapTitle: '当前仍需管理员初始化',
|
||||
adminBootstrapAction: '初始化管理员',
|
||||
phoneCodeValidation: '请输入 6 位短信验证码',
|
||||
activeSummary: '账号已创建,现在可以返回登录页使用新账号登录。',
|
||||
inactiveSummaryNoEmail: '账号已创建,请按页面提示完成激活后再登录。',
|
||||
smsDisabledDescription: '支持用户名和邮箱注册;当前环境未启用短信能力,因此手机号注册暂不可用。',
|
||||
}
|
||||
|
||||
const getAuthCapabilitiesMock = vi.fn<() => Promise<AuthCapabilities>>()
|
||||
const registerMock = vi.fn<(payload: unknown) => Promise<RegisterResponse>>()
|
||||
const sendSmsCodeMock = vi.fn<(payload: unknown) => Promise<void>>()
|
||||
|
||||
const defaultCapabilities: AuthCapabilities = {
|
||||
password: true,
|
||||
email_activation: false,
|
||||
email_code: false,
|
||||
sms_code: false,
|
||||
password_reset: false,
|
||||
admin_bootstrap_required: false,
|
||||
oauth_providers: [],
|
||||
}
|
||||
|
||||
const activeRegisterResponse: RegisterResponse = {
|
||||
user: {
|
||||
id: 2,
|
||||
username: 'new-user',
|
||||
email: 'new-user@example.com',
|
||||
phone: '',
|
||||
nickname: 'New User',
|
||||
avatar: '',
|
||||
status: 1,
|
||||
},
|
||||
message: 'registered successfully',
|
||||
}
|
||||
|
||||
vi.mock('@/services/auth', () => ({
|
||||
getAuthCapabilities: () => getAuthCapabilitiesMock(),
|
||||
register: (payload: unknown) => registerMock(payload),
|
||||
sendSmsCode: (payload: unknown) => sendSmsCodeMock(payload),
|
||||
}))
|
||||
|
||||
function renderRegisterPage() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/register']}>
|
||||
<RegisterPage />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
function fillBaseRegistrationFields(values?: {
|
||||
username?: string
|
||||
nickname?: string
|
||||
email?: string
|
||||
password?: string
|
||||
confirmPassword?: string
|
||||
}) {
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.usernamePlaceholder), {
|
||||
target: { value: values?.username ?? 'new-user' },
|
||||
})
|
||||
|
||||
if (values?.nickname !== undefined || screen.queryByPlaceholderText(TEXT.nicknamePlaceholder)) {
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.nicknamePlaceholder), {
|
||||
target: { value: values?.nickname ?? 'New User' },
|
||||
})
|
||||
}
|
||||
|
||||
if (values?.email !== undefined || screen.queryByPlaceholderText(TEXT.emailPlaceholder)) {
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.emailPlaceholder), {
|
||||
target: { value: values?.email ?? 'new-user@example.com' },
|
||||
})
|
||||
}
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.passwordPlaceholder), {
|
||||
target: { value: values?.password ?? 'SecurePass123!' },
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.confirmPasswordPlaceholder), {
|
||||
target: { value: values?.confirmPassword ?? values?.password ?? 'SecurePass123!' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('RegisterPage', () => {
|
||||
beforeEach(() => {
|
||||
getAuthCapabilitiesMock.mockReset()
|
||||
registerMock.mockReset()
|
||||
sendSmsCodeMock.mockReset()
|
||||
|
||||
getAuthCapabilitiesMock.mockResolvedValue(defaultCapabilities)
|
||||
registerMock.mockResolvedValue(activeRegisterResponse)
|
||||
sendSmsCodeMock.mockResolvedValue(undefined)
|
||||
|
||||
vi.spyOn(message, 'success').mockImplementation(() => undefined as never)
|
||||
vi.spyOn(message, 'error').mockImplementation(() => undefined as never)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders username registration fields and hides phone registration when sms is disabled', async () => {
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText(TEXT.nicknamePlaceholder)).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText(TEXT.emailPlaceholder)).toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText(TEXT.phonePlaceholder)).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /返回登录/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to default capabilities when loading capabilities fails', async () => {
|
||||
getAuthCapabilitiesMock.mockRejectedValue(new Error('capabilities unavailable'))
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument())
|
||||
|
||||
expect(screen.getByText(TEXT.smsDisabledDescription)).toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText(TEXT.phonePlaceholder)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: TEXT.forgotPassword })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the forgot-password entry when the capability is enabled', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
password_reset: true,
|
||||
})
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('link', { name: TEXT.forgotPassword })).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('sends register sms codes with the normalized purpose payload and starts the countdown', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
sms_code: true,
|
||||
})
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.phonePlaceholder)).toBeInTheDocument())
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.phonePlaceholder), {
|
||||
target: { value: '13812345678' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: TEXT.sendCode }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(sendSmsCodeMock).toHaveBeenCalledWith({
|
||||
phone: '13812345678',
|
||||
purpose: 'register',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(message.success).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByRole('button', { name: '60s' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ignores form validation errors when the phone format is invalid', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
sms_code: true,
|
||||
})
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.phonePlaceholder)).toBeInTheDocument())
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.phonePlaceholder), {
|
||||
target: { value: '123' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: TEXT.sendCode }))
|
||||
|
||||
await waitFor(() => expect(sendSmsCodeMock).not.toHaveBeenCalled())
|
||||
|
||||
expect(message.error).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('button', { name: '60s' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('surfaces sms code send failures from the backend', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
sms_code: true,
|
||||
})
|
||||
sendSmsCodeMock.mockRejectedValue(new Error('sms send failed'))
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.phonePlaceholder)).toBeInTheDocument())
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.phonePlaceholder), {
|
||||
target: { value: '13812345678' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: TEXT.sendCode }))
|
||||
|
||||
await waitFor(() => expect(message.error).toHaveBeenCalledWith('sms send failed'))
|
||||
|
||||
expect(screen.queryByRole('button', { name: '60s' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('blocks sms-backed registration when the phone code is missing', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
sms_code: true,
|
||||
})
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.phonePlaceholder)).toBeInTheDocument())
|
||||
|
||||
fillBaseRegistrationFields()
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.phonePlaceholder), {
|
||||
target: { value: '13812345678' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: TEXT.createAccount }))
|
||||
|
||||
await waitFor(() => expect(registerMock).not.toHaveBeenCalled())
|
||||
|
||||
expect(message.error).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: TEXT.createAccount })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits the self-service registration payload and shows the success state', async () => {
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument())
|
||||
|
||||
fillBaseRegistrationFields()
|
||||
fireEvent.click(screen.getByRole('button', { name: TEXT.createAccount }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(registerMock).toHaveBeenCalledWith({
|
||||
username: 'new-user',
|
||||
password: 'SecurePass123!',
|
||||
nickname: 'New User',
|
||||
email: 'new-user@example.com',
|
||||
phone: undefined,
|
||||
phone_code: undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('button', { name: TEXT.returnLogin })).toBeInTheDocument()
|
||||
expect(screen.getByText(TEXT.activeSummary)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits sms-backed registration payload with trimmed username, nickname, and phone code', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
sms_code: true,
|
||||
})
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.phonePlaceholder)).toBeInTheDocument())
|
||||
|
||||
fillBaseRegistrationFields({
|
||||
username: ' new-user ',
|
||||
nickname: ' New User ',
|
||||
email: 'new-user@example.com',
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.phonePlaceholder), {
|
||||
target: { value: '13812345678' },
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getAllByRole('textbox')[4], {
|
||||
target: { value: ' 654321 ' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: TEXT.createAccount }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(registerMock).toHaveBeenCalledWith({
|
||||
username: 'new-user',
|
||||
password: 'SecurePass123!',
|
||||
nickname: 'New User',
|
||||
email: 'new-user@example.com',
|
||||
phone: '13812345678',
|
||||
phone_code: '654321',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces self-service registration failures from the backend', async () => {
|
||||
registerMock.mockRejectedValue(new Error('register failed'))
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument())
|
||||
|
||||
fillBaseRegistrationFields()
|
||||
fireEvent.click(screen.getByRole('button', { name: TEXT.createAccount }))
|
||||
|
||||
await waitFor(() => expect(message.error).toHaveBeenCalledWith('register failed'))
|
||||
|
||||
expect(screen.getByRole('button', { name: TEXT.createAccount })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a resend-activation entry for newly created inactive email accounts', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
email_activation: true,
|
||||
})
|
||||
registerMock.mockResolvedValue({
|
||||
user: {
|
||||
id: 3,
|
||||
username: 'inactive-user',
|
||||
email: 'inactive-user@example.com',
|
||||
phone: '',
|
||||
nickname: 'Inactive User',
|
||||
avatar: '',
|
||||
status: 0,
|
||||
},
|
||||
message: 'registered successfully, please check your email to activate the account',
|
||||
})
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument())
|
||||
|
||||
fillBaseRegistrationFields({
|
||||
username: 'inactive-user',
|
||||
nickname: '',
|
||||
email: 'inactive-user@example.com',
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: TEXT.createAccount }))
|
||||
|
||||
const resendLink = await screen.findByRole('link', { name: TEXT.resendActivation })
|
||||
expect(resendLink).toHaveAttribute('href', '/activate-account?email=inactive-user%40example.com')
|
||||
})
|
||||
|
||||
it('shows the generic activation summary when the new inactive account has no email address', async () => {
|
||||
registerMock.mockResolvedValue({
|
||||
user: {
|
||||
id: 4,
|
||||
username: 'inactive-without-email',
|
||||
email: '',
|
||||
phone: '',
|
||||
nickname: '',
|
||||
avatar: '',
|
||||
status: 0,
|
||||
},
|
||||
message: 'registered successfully, activation required',
|
||||
})
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument())
|
||||
|
||||
fillBaseRegistrationFields({
|
||||
username: 'inactive-without-email',
|
||||
nickname: '',
|
||||
email: '',
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: TEXT.createAccount }))
|
||||
|
||||
expect(await screen.findByText(TEXT.inactiveSummaryNoEmail)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: TEXT.resendActivation })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an admin bootstrap entry when the system still has no active admin', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
admin_bootstrap_required: true,
|
||||
})
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
expect(await screen.findByText(TEXT.adminBootstrapTitle)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: TEXT.adminBootstrapAction }).closest('a')).toHaveAttribute('href', '/bootstrap-admin')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user