refactor(auth): extract AuthPasswordForm and AuthCodeForm to features

- Create auth-password feature with login/register form
- Create auth-code feature with email+code verification form
- Extract getApiErrorMessage to shared lib
- Simplify AuthPage to pure UI composer with tabs
- Update tests for new component structure
- All 40 tests passing
This commit is contained in:
Kirill
2026-05-22 14:36:19 +05:00
parent da13ce2848
commit 68bbbf8895
9 changed files with 461 additions and 379 deletions
+1
View File
@@ -0,0 +1 @@
export { AuthCodeForm } from './ui/AuthCodeForm'
@@ -0,0 +1,67 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import { AuthCodeForm } from '../ui/AuthCodeForm'
vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } }))
vi.mock('@/shared/model/auth', () => ({ tokenSet: vi.fn() }))
function renderForm() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const onSuccess = vi.fn()
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<AuthCodeForm onSuccess={onSuccess} />
</MemoryRouter>
</QueryClientProvider>,
)
}
describe('AuthCodeForm', () => {
it('renders email field, code field, and buttons', () => {
renderForm()
expect(screen.getByLabelText(/Email/i)).toBeTruthy()
expect(screen.getByLabelText(/Код/i)).toBeTruthy()
expect(screen.getByRole('button', { name: 'Отправить код' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Войти' })).toBeTruthy()
})
it('disables send button when email is empty', () => {
renderForm()
expect(screen.getByRole('button', { name: 'Отправить код' })).toBeDisabled()
})
it('disables login button when code.length !== 6', () => {
renderForm()
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
fireEvent.change(screen.getByLabelText(/Код/i), { target: { value: '123' } })
expect(screen.getByRole('button', { name: 'Войти' })).toBeDisabled()
})
it('enables login button when code is 6 digits', async () => {
renderForm()
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
fireEvent.change(screen.getByLabelText(/Код/i), { target: { value: '123456' } })
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Войти' })).not.toBeDisabled()
})
})
it('calls onSuccess after successful verify', async () => {
const { apiClient } = await import('@/shared/api/client')
const { tokenSet } = await import('@/shared/model/auth')
vi.mocked(apiClient.post).mockResolvedValue({ data: { token: 'test-token' } } as never)
renderForm()
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
fireEvent.change(screen.getByLabelText(/Код/i), { target: { value: '123456' } })
fireEvent.click(screen.getByRole('button', { name: 'Войти' }))
expect(screen.getByRole('button', { name: 'Войти' })).not.toBeDisabled()
await waitFor(() => {
expect(tokenSet).toHaveBeenCalledWith('test-token')
})
})
})
@@ -0,0 +1,100 @@
import Button from '@mui/material/Button'
import InputAdornment from '@mui/material/InputAdornment'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import { useMutation } from '@tanstack/react-query'
import { Mail } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { apiClient } from '@/shared/api/client'
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
import { tokenSet } from '@/shared/model/auth'
type AuthResponse = {
token: string
user: {
id: string
email: string
displayName?: string | null
avatar?: string | null
avatarStyle?: string | null
}
}
type FormValues = {
email: string
code: string
}
type Props = {
onSuccess: () => void
}
export function AuthCodeForm({ onSuccess }: Props) {
const { register, watch } = useForm<FormValues>({
defaultValues: { email: '', code: '' },
mode: 'onChange',
})
const email = watch('email')
const code = watch('code')
const requestCodeMutation = useMutation({
mutationFn: async () => {
await apiClient.post('auth/request-code', { email })
},
})
const verifyCodeMutation = useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<AuthResponse>('auth/verify-code', { email, code })
tokenSet(data.token)
},
onSuccess,
})
return (
<Stack spacing={2}>
<TextField
label="Email"
{...register('email')}
fullWidth
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Mail size={18} />
</InputAdornment>
),
},
}}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button
variant="outlined"
onClick={() => requestCodeMutation.mutate()}
disabled={!email || requestCodeMutation.isPending}
sx={{ whiteSpace: 'nowrap' }}
>
Отправить код
</Button>
<TextField label="Код (6 цифр)" inputMode="numeric" {...register('code')} sx={{ flex: 1 }} />
<Button
variant="contained"
onClick={() => verifyCodeMutation.mutate()}
disabled={!email || code.length !== 6 || verifyCodeMutation.isPending}
sx={{ whiteSpace: 'nowrap' }}
>
Войти
</Button>
</Stack>
{(requestCodeMutation.error || verifyCodeMutation.error) && (
<TextField
error
helperText={getApiErrorMessage(requestCodeMutation.error) || getApiErrorMessage(verifyCodeMutation.error)}
sx={{ display: 'none' }}
/>
)}
</Stack>
)
}
@@ -0,0 +1 @@
export { AuthPasswordForm } from './ui/AuthPasswordForm'
@@ -0,0 +1,74 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import { AuthPasswordForm } from '../ui/AuthPasswordForm'
vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } }))
vi.mock('@/shared/model/auth', () => ({ tokenSet: vi.fn() }))
function renderForm(isRegister: boolean) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const onSuccess = vi.fn()
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<AuthPasswordForm isRegister={isRegister} onSuccess={onSuccess} />
</MemoryRouter>
</QueryClientProvider>,
)
}
describe('AuthPasswordForm', () => {
it('renders login button when isRegister=false', () => {
renderForm(false)
expect(screen.getByRole('button', { name: 'Войти' })).toBeTruthy()
expect(screen.getByText('Вход')).toBeTruthy()
})
it('renders register button and passwordConfirm when isRegister=true', () => {
renderForm(true)
expect(screen.getByRole('button', { name: 'Зарегистрироваться' })).toBeTruthy()
expect(screen.getByLabelText(/Подтверждение пароля/i)).toBeTruthy()
})
it('disables button when password < 8 chars', async () => {
const { apiClient } = await import('@/shared/api/client')
vi.mocked(apiClient.post).mockResolvedValue({} as never)
renderForm(true)
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
fireEvent.change(screen.getByLabelText(/Пароль/i), { target: { value: '123' } })
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Зарегистрироваться' })).toBeDisabled()
})
})
it('shows error when passwords do not match', async () => {
renderForm(true)
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
fireEvent.change(screen.getByLabelText(/Пароль/i), { target: { value: 'password123' } })
fireEvent.change(screen.getByLabelText(/Подтверждение пароля/i), { target: { value: 'different' } })
await waitFor(() => {
expect(screen.getByText('Пароли не совпадают')).toBeTruthy()
})
})
it('calls onSuccess after successful login', async () => {
const { apiClient } = await import('@/shared/api/client')
const { tokenSet } = await import('@/shared/model/auth')
vi.mocked(apiClient.post).mockResolvedValue({ data: { token: 'test-token' } } as never)
renderForm(false)
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
fireEvent.change(screen.getByLabelText(/Пароль/i), { target: { value: 'password123' } })
fireEvent.click(screen.getByRole('button', { name: 'Войти' }))
await waitFor(() => {
expect(tokenSet).toHaveBeenCalledWith('test-token')
})
})
})
@@ -0,0 +1,189 @@
import Button from '@mui/material/Button'
import InputAdornment from '@mui/material/InputAdornment'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import { useMutation } from '@tanstack/react-query'
import { Lock, Mail } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { apiClient } from '@/shared/api/client'
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
import { tokenSet } from '@/shared/model/auth'
type AuthResponse = {
token: string
user: {
id: string
email: string
displayName?: string | null
avatar?: string | null
avatarStyle?: string | null
}
}
type FormValues = {
email: string
password: string
passwordConfirm: string
displayName: string
}
type Props = {
isRegister: boolean
onSuccess: () => void
}
export function AuthPasswordForm({ isRegister, onSuccess }: Props) {
const { register, watch } = useForm<FormValues>({
defaultValues: { email: '', password: '', passwordConfirm: '', displayName: '' },
mode: 'onChange',
})
const email = watch('email')
const password = watch('password')
const passwordConfirm = watch('passwordConfirm')
const displayName = watch('displayName')
const loginMutation = useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<AuthResponse>('auth/login', { email, password })
tokenSet(data.token)
},
onSuccess,
})
const registerMutation = useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<AuthResponse>('auth/register', {
email,
password,
displayName: displayName || undefined,
})
tokenSet(data.token)
},
onSuccess,
})
const passwordError = isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null
return (
<Stack spacing={2}>
<Stack direction="row" sx={{ justifyContent: 'center' }} spacing={3}>
<Button
variant="text"
size="small"
sx={{
color: !isRegister ? 'primary.main' : 'text.secondary',
borderBottom: !isRegister ? 2 : 0,
borderColor: 'primary.main',
borderRadius: 0,
pb: 0.5,
textTransform: 'none',
}}
disabled
>
Вход
</Button>
<Button
variant="text"
size="small"
sx={{
color: isRegister ? 'primary.main' : 'text.secondary',
borderBottom: isRegister ? 2 : 0,
borderColor: 'primary.main',
borderRadius: 0,
pb: 0.5,
textTransform: 'none',
}}
disabled
>
Регистрация
</Button>
</Stack>
<TextField
label="Email"
{...register('email')}
fullWidth
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Mail size={18} />
</InputAdornment>
),
},
}}
/>
{isRegister && (
<TextField
label="Имя (необязательно)"
{...register('displayName')}
fullWidth
helperText="Если не указать, будет использована часть email до @"
/>
)}
<TextField
label="Пароль"
type="password"
{...register('password')}
fullWidth
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Lock size={18} />
</InputAdornment>
),
},
}}
/>
{isRegister && (
<TextField
label="Подтверждение пароля"
type="password"
{...register('passwordConfirm')}
fullWidth
error={Boolean(passwordError)}
helperText={passwordError}
/>
)}
{isRegister ? (
<Button
variant="contained"
size="large"
disabled={
!email ||
!password ||
password.length < 8 ||
(isRegister && password !== passwordConfirm) ||
registerMutation.isPending
}
onClick={() => registerMutation.mutate()}
>
Зарегистрироваться
</Button>
) : (
<Button
variant="contained"
size="large"
disabled={!email || !password || loginMutation.isPending}
onClick={() => loginMutation.mutate()}
>
Войти
</Button>
)}
{(loginMutation.error || registerMutation.error) && (
<TextField
error
helperText={getApiErrorMessage(loginMutation.error) || getApiErrorMessage(registerMutation.error)}
sx={{ display: 'none' }}
/>
)}
</Stack>
)
}