383 lines
13 KiB
TypeScript
383 lines
13 KiB
TypeScript
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 = {
|
|
id: 2,
|
|
username: 'new-user',
|
|
email: 'new-user@example.com',
|
|
phone: '',
|
|
nickname: 'New User',
|
|
avatar: '',
|
|
status: 1,
|
|
}
|
|
|
|
vi.mock('@/services/auth', () => ({
|
|
getAuthCapabilities: () => getAuthCapabilitiesMock(),
|
|
register: (payload: unknown) => registerMock(payload),
|
|
sendSmsCode: (payload: unknown) => sendSmsCodeMock(payload),
|
|
}))
|
|
|
|
function renderRegisterPage() {
|
|
return render(
|
|
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }} 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({
|
|
id: 3,
|
|
username: 'inactive-user',
|
|
email: 'inactive-user@example.com',
|
|
phone: '',
|
|
nickname: 'Inactive User',
|
|
avatar: '',
|
|
status: 0,
|
|
})
|
|
|
|
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({
|
|
id: 4,
|
|
username: 'inactive-without-email',
|
|
email: '',
|
|
phone: '',
|
|
nickname: '',
|
|
avatar: '',
|
|
status: 0,
|
|
})
|
|
|
|
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')
|
|
})
|
|
})
|