35% front tests

This commit is contained in:
iqubik
2026-03-29 22:17:34 +03:00
parent 61b814482c
commit 425c1d5e8d
29 changed files with 4942 additions and 15 deletions
+202
View File
@@ -0,0 +1,202 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { AuthProvider, useAuth } from '@/auth/AuthContext'
import { ReactNode } from 'react'
// Мокаем localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
store = {}
}),
}
})()
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
})
const wrapper = ({ children }: { children: ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
)
describe('AuthContext', () => {
beforeEach(() => {
localStorageMock.clear()
vi.clearAllMocks()
})
describe('useAuth', () => {
it('должен выбрасывать ошибку при использовании вне AuthProvider', () => {
// Отключаем console.error для этого теста
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => {
renderHook(() => useAuth())
}).toThrow('useAuth must be used within an AuthProvider')
consoleSpy.mockRestore()
})
})
describe('initial state', () => {
it('должен инициализироваться с token из localStorage', () => {
localStorageMock.getItem.mockReturnValue('test-token-123')
const { result } = renderHook(() => useAuth(), { wrapper })
expect(result.current.token).toBe('test-token-123')
expect(result.current.isAuthenticated).toBe(true)
expect(localStorageMock.getItem).toHaveBeenCalledWith('token')
})
it('должен инициализироваться с null если token отсутствует в localStorage', () => {
localStorageMock.getItem.mockReturnValue(null)
const { result } = renderHook(() => useAuth(), { wrapper })
expect(result.current.token).toBe(null)
expect(result.current.isAuthenticated).toBe(false)
})
})
describe('login', () => {
it('должен сохранять токен в localStorage и state', () => {
const { result } = renderHook(() => useAuth(), { wrapper })
act(() => {
result.current.login('new-token-456')
})
expect(result.current.token).toBe('new-token-456')
expect(result.current.isAuthenticated).toBe(true)
expect(localStorageMock.setItem).toHaveBeenCalledWith('token', 'new-token-456')
})
it('должен обновлять isAuthenticated после login', () => {
const { result } = renderHook(() => useAuth(), { wrapper })
expect(result.current.isAuthenticated).toBe(false)
act(() => {
result.current.login('another-token')
})
expect(result.current.isAuthenticated).toBe(true)
})
})
describe('logout', () => {
it('должен удалять токен из localStorage и state', () => {
localStorageMock.getItem.mockReturnValue('existing-token')
const { result } = renderHook(() => useAuth(), { wrapper })
expect(result.current.token).toBe('existing-token')
act(() => {
result.current.logout()
})
expect(result.current.token).toBe(null)
expect(result.current.isAuthenticated).toBe(false)
expect(localStorageMock.removeItem).toHaveBeenCalledWith('token')
})
it('должен корректно работать logout когда token уже null', () => {
const { result } = renderHook(() => useAuth(), { wrapper })
act(() => {
result.current.logout()
})
expect(result.current.token).toBe(null)
expect(result.current.isAuthenticated).toBe(false)
expect(localStorageMock.removeItem).toHaveBeenCalledWith('token')
})
})
describe('isAuthenticated', () => {
it('должен возвращать true когда token существует', () => {
localStorageMock.getItem.mockReturnValue('valid-token')
const { result } = renderHook(() => useAuth(), { wrapper })
expect(result.current.isAuthenticated).toBe(true)
})
it('должен возвращать false когда token null', () => {
localStorageMock.getItem.mockReturnValue(null)
const { result } = renderHook(() => useAuth(), { wrapper })
expect(result.current.isAuthenticated).toBe(false)
})
it('должен возвращать false когда token пустая строка', () => {
localStorageMock.getItem.mockReturnValue('')
const { result } = renderHook(() => useAuth(), { wrapper })
expect(result.current.isAuthenticated).toBe(false)
})
})
describe('context methods', () => {
it('должен предоставлять метод login', () => {
const { result } = renderHook(() => useAuth(), { wrapper })
expect(result.current.login).toBeDefined()
expect(typeof result.current.login).toBe('function')
})
it('должен предоставлять метод logout', () => {
const { result } = renderHook(() => useAuth(), { wrapper })
expect(result.current.logout).toBeDefined()
expect(typeof result.current.logout).toBe('function')
})
})
describe('multiple login/logout cycles', () => {
it('должен корректно обрабатывать несколько циклов login/logout', () => {
const { result } = renderHook(() => useAuth(), { wrapper })
// Первый цикл
act(() => {
result.current.login('token-1')
})
expect(result.current.token).toBe('token-1')
act(() => {
result.current.logout()
})
expect(result.current.token).toBe(null)
// Второй цикл
act(() => {
result.current.login('token-2')
})
expect(result.current.token).toBe('token-2')
act(() => {
result.current.logout()
})
expect(result.current.token).toBe(null)
// Третий цикл
act(() => {
result.current.login('token-3')
})
expect(result.current.token).toBe('token-3')
})
})
})
+174
View File
@@ -0,0 +1,174 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { AxiosInterceptor } from '../../src/auth/AxiosInterceptor'
import { AuthProvider } from '../../src/auth/AuthContext'
import { ThemeProvider } from '../../src/ThemeContext'
import { Logger } from '../../src/utils/logger'
const mockNavigate = vi.fn()
const mockLogout = vi.fn()
// Мок api с интерсепторами - используем vi.hoisted для подъёма
const mocks = vi.hoisted(() => ({
responseUse: vi.fn(),
responseEject: vi.fn(),
}))
vi.mock('../../src/api', () => ({
default: {
interceptors: {
request: {
use: vi.fn(),
eject: vi.fn(),
},
response: {
use: mocks.responseUse,
eject: mocks.responseEject,
},
},
},
}))
// Мок react-router-dom
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom')
return {
...(actual as object),
useNavigate: () => mockNavigate,
useLocation: () => ({ pathname: '/subscriptions' }),
}
})
// Мок AuthContext
vi.mock('../../src/auth/AuthContext', async () => {
const actual = await vi.importActual('../../src/auth/AuthContext')
return {
...(actual as object),
useAuth: () => ({ logout: mockLogout }),
}
})
vi.mock('../../src/utils/logger', () => ({
Logger: {
warn: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
},
}))
describe('AxiosInterceptor', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNavigate.mockClear()
mockLogout.mockClear()
vi.mocked(Logger.warn).mockClear()
vi.mocked(Logger.debug).mockClear()
mocks.responseUse.mockClear()
mocks.responseEject.mockClear()
})
it('должен рендериться без ошибок и возвращать null', () => {
const { container } = render(
<MemoryRouter>
<AuthProvider>
<ThemeProvider>
<AxiosInterceptor />
</ThemeProvider>
</AuthProvider>
</MemoryRouter>
)
expect(container.firstChild).toBeNull()
})
it('должен регистрировать interceptor при монтировании', async () => {
render(
<MemoryRouter>
<AuthProvider>
<ThemeProvider>
<AxiosInterceptor />
</ThemeProvider>
</AuthProvider>
</MemoryRouter>
)
await waitFor(() => {
expect(mocks.responseUse).toHaveBeenCalled()
})
// Проверяем что был зарегистрирован обработчик
const interceptorCall = vi.mocked(mocks.responseUse).mock.calls[0]
expect(interceptorCall).toBeDefined()
expect(typeof interceptorCall?.[0]).toBe('function') // success handler
expect(typeof interceptorCall?.[1]).toBe('function') // error handler
})
it('должен пропускать успешные ответы', () => {
const successHandler = (response: unknown) => response
const response = { data: { test: 'value' }, status: 200 }
const result = successHandler(response)
expect(result).toEqual(response)
})
it('должен отклонять ошибки не 401', async () => {
const errorHandler = (error: unknown) => Promise.reject(error)
const errorResponse = { response: { status: 500 } }
await expect(errorHandler(errorResponse)).rejects.toEqual(errorResponse)
})
it('должен удалять interceptor при размонтировании', async () => {
const { unmount } = render(
<MemoryRouter>
<AuthProvider>
<ThemeProvider>
<AxiosInterceptor />
</ThemeProvider>
</AuthProvider>
</MemoryRouter>
)
await waitFor(() => {
expect(mocks.responseUse).toHaveBeenCalled()
})
unmount()
expect(mocks.responseEject).toHaveBeenCalled()
})
it('должен обрабатывать 401 ошибки', async () => {
render(
<MemoryRouter>
<AuthProvider>
<ThemeProvider>
<AxiosInterceptor />
</ThemeProvider>
</AuthProvider>
</MemoryRouter>
)
await waitFor(() => {
expect(mocks.responseUse).toHaveBeenCalled()
})
const interceptorCall = vi.mocked(mocks.responseUse).mock.calls[0]
const errorHandler = interceptorCall?.[1]
if (errorHandler) {
try {
await errorHandler({ response: { status: 401 } })
} catch {
// Ожидаем что ошибка будет проброшена дальше
}
}
// Проверяем что logout и navigate были вызваны
expect(mockLogout).toHaveBeenCalled()
expect(mockNavigate).toHaveBeenCalledWith('/login')
})
})
+135
View File
@@ -0,0 +1,135 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import PublicRoute from '@/auth/PublicRoute'
import { AuthProvider } from '@/auth/AuthContext'
// Мокаем localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
store = {}
}),
}
})()
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
})
describe('PublicRoute', () => {
beforeEach(() => {
localStorageMock.clear()
vi.clearAllMocks()
})
it('должен рендерить Outlet когда пользователь не авторизован', () => {
localStorageMock.getItem.mockReturnValue(null)
render(
<MemoryRouter initialEntries={['/login']}>
<AuthProvider>
<Routes>
<Route element={<PublicRoute />}>
<Route path="/login" element={<div data-testid="login-page">Login Page</div>} />
</Route>
</Routes>
</AuthProvider>
</MemoryRouter>
)
expect(screen.getByTestId('login-page')).toBeInTheDocument()
expect(screen.getByText('Login Page')).toBeInTheDocument()
})
it('должен перенаправлять на / когда пользователь авторизован', () => {
localStorageMock.getItem.mockReturnValue('valid-token')
render(
<MemoryRouter initialEntries={['/login']}>
<AuthProvider>
<Routes>
<Route element={<PublicRoute />}>
<Route path="/login" element={<div data-testid="login-page">Login Page</div>} />
</Route>
<Route path="/" element={<div data-testid="home-page">Home Page</div>} />
</Routes>
</AuthProvider>
</MemoryRouter>
)
// Должен показать главную страницу вместо login
expect(screen.getByTestId('home-page')).toBeInTheDocument()
expect(screen.getByText('Home Page')).toBeInTheDocument()
})
it('должен работать с несколькими публичными роутами', () => {
localStorageMock.getItem.mockReturnValue(null)
render(
<MemoryRouter initialEntries={['/register']}>
<AuthProvider>
<Routes>
<Route element={<PublicRoute />}>
<Route path="/login" element={<div data-testid="login-page">Login Page</div>} />
<Route path="/register" element={<div data-testid="register-page">Register Page</div>} />
</Route>
</Routes>
</AuthProvider>
</MemoryRouter>
)
expect(screen.getByTestId('register-page')).toBeInTheDocument()
expect(screen.getByText('Register Page')).toBeInTheDocument()
})
it('должен перенаправлять авторизованного пользователя с любого публичного роута', () => {
localStorageMock.getItem.mockReturnValue('valid-token')
render(
<MemoryRouter initialEntries={['/register']}>
<AuthProvider>
<Routes>
<Route element={<PublicRoute />}>
<Route path="/login" element={<div data-testid="login-page">Login Page</div>} />
<Route path="/register" element={<div data-testid="register-page">Register Page</div>} />
</Route>
<Route path="/" element={<div data-testid="home-page">Home Page</div>} />
</Routes>
</AuthProvider>
</MemoryRouter>
)
// Должен показать главную страницу
expect(screen.getByTestId('home-page')).toBeInTheDocument()
expect(screen.queryByTestId('register-page')).not.toBeInTheDocument()
})
it('должен использовать replace при перенаправлении', () => {
localStorageMock.getItem.mockReturnValue('valid-token')
const { container } = render(
<MemoryRouter initialEntries={['/login']}>
<AuthProvider>
<Routes>
<Route element={<PublicRoute />}>
<Route path="/login" element={<div>Login</div>} />
</Route>
<Route path="/" element={<div>Home</div>} />
</Routes>
</AuthProvider>
</MemoryRouter>
)
// Проверяем что рендерится Home
expect(container.textContent).toContain('Home')
})
})
+186
View File
@@ -0,0 +1,186 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import RequireAuth from '@/auth/RequireAuth'
import { AuthProvider } from '@/auth/AuthContext'
// Мокаем localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
store = {}
}),
}
})()
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
})
describe('RequireAuth', () => {
beforeEach(() => {
localStorageMock.clear()
vi.clearAllMocks()
})
it('должен рендерить children когда пользователь авторизован', () => {
localStorageMock.getItem.mockReturnValue('valid-token')
render(
<MemoryRouter initialEntries={['/protected']}>
<AuthProvider>
<Routes>
<Route
path="/protected"
element={
<RequireAuth>
<div data-testid="protected-content">Protected Content</div>
</RequireAuth>
}
/>
</Routes>
</AuthProvider>
</MemoryRouter>
)
expect(screen.getByTestId('protected-content')).toBeInTheDocument()
expect(screen.getByText('Protected Content')).toBeInTheDocument()
})
it('должен перенаправлять на /login когда пользователь не авторизован', () => {
localStorageMock.getItem.mockReturnValue(null)
render(
<MemoryRouter initialEntries={['/protected']}>
<AuthProvider>
<Routes>
<Route
path="/protected"
element={
<RequireAuth>
<div data-testid="protected-content">Protected Content</div>
</RequireAuth>
}
/>
<Route path="/login" element={<div data-testid="login-page">Login Page</div>} />
</Routes>
</AuthProvider>
</MemoryRouter>
)
expect(screen.getByTestId('login-page')).toBeInTheDocument()
expect(screen.getByText('Login Page')).toBeInTheDocument()
})
it('должен сохранять state.from с текущим location при перенаправлении на login', () => {
localStorageMock.getItem.mockReturnValue(null)
render(
<MemoryRouter initialEntries={['/settings']}>
<AuthProvider>
<Routes>
<Route
path="/settings"
element={
<RequireAuth>
<div data-testid="settings-content">Settings</div>
</RequireAuth>
}
/>
<Route path="/login" element={<div data-testid="login-page">Login Page</div>} />
</Routes>
</AuthProvider>
</MemoryRouter>
)
// Должен показать login страницу
expect(screen.getByTestId('login-page')).toBeInTheDocument()
})
it('должен использовать replace: true при перенаправлении', () => {
// Этот тест проверяет поведение Navigate компонента
// В реальном сценарии replace предотвращает добавление записи в историю
localStorageMock.getItem.mockReturnValue(null)
const { container } = render(
<MemoryRouter initialEntries={['/protected']}>
<AuthProvider>
<Routes>
<Route
path="/protected"
element={
<RequireAuth>
<div>Protected</div>
</RequireAuth>
}
/>
<Route path="/login" element={<div>Login</div>} />
</Routes>
</AuthProvider>
</MemoryRouter>
)
// Проверяем что рендерится login
expect(container.textContent).toContain('Login')
})
it('должен работать с вложенными роутами', () => {
localStorageMock.getItem.mockReturnValue('valid-token')
render(
<MemoryRouter initialEntries={['/dashboard/profile']}>
<AuthProvider>
<Routes>
<Route
path="/dashboard/*"
element={
<RequireAuth>
<Routes>
<Route path="profile" element={<div data-testid="profile">Profile</div>} />
</Routes>
</RequireAuth>
}
/>
</Routes>
</AuthProvider>
</MemoryRouter>
)
expect(screen.getByTestId('profile')).toBeInTheDocument()
})
it('должен блокировать доступ к защищённому маршруту без авторизации', () => {
localStorageMock.getItem.mockReturnValue(null)
render(
<MemoryRouter initialEntries={['/admin']}>
<AuthProvider>
<Routes>
<Route
path="/admin"
element={
<RequireAuth>
<div data-testid="admin-content">Admin Panel</div>
</RequireAuth>
}
/>
<Route path="/login" element={<div data-testid="login-page">Login Page</div>} />
</Routes>
</AuthProvider>
</MemoryRouter>
)
// Контент админки не должен быть доступен
expect(screen.queryByTestId('admin-content')).not.toBeInTheDocument()
// Должна показываться страница логина
expect(screen.getByTestId('login-page')).toBeInTheDocument()
})
})