initial: client
This commit is contained in:
Executable
+1
@@ -0,0 +1 @@
|
||||
export { AuthCodeForm } from './ui/AuthCodeForm'
|
||||
+67
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import Button from '@mui/material/Button'
|
||||
import InputAdornment from '@mui/material/InputAdornment'
|
||||
import Link from '@mui/material/Link'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Mail } from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
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' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center' }}>
|
||||
Нажимая «Войти», вы принимаете{' '}
|
||||
<Link component={RouterLink} to="/terms" underline="hover">
|
||||
пользовательское соглашение
|
||||
</Link>{' '}
|
||||
и{' '}
|
||||
<Link component={RouterLink} to="/privacy" underline="hover">
|
||||
политику конфиденциальности
|
||||
</Link>
|
||||
.
|
||||
</Typography>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user