35% front tests
This commit is contained in:
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
|
|||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist', 'coverage', 'node_modules', 'build']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
|
|||||||
Generated
+1375
-11
File diff suppressed because it is too large
Load Diff
+14
-2
@@ -7,7 +7,12 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest --run",
|
||||||
|
"test:watch": "vitest --watch",
|
||||||
|
"test:run": "vitest --run",
|
||||||
|
"test:cov": "vitest --coverage",
|
||||||
|
"test:ui": "vitest --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
@@ -23,16 +28,23 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^24.10.9",
|
"@types/node": "^24.10.9",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"@vitest/coverage-v8": "^4.1.2",
|
||||||
|
"@vitest/ui": "^4.1.2",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"jsdom": "^29.0.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"typescript-eslint": "^8.46.4",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4",
|
||||||
|
"vitest": "^4.1.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import App from '../src/App'
|
||||||
|
|
||||||
|
// Мок для страниц и компонентов
|
||||||
|
vi.mock('../src/pages/LoginPage', () => ({
|
||||||
|
default: () => <div data-testid="login-page">LoginPage</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../src/pages/SubscriptionsPage', () => ({
|
||||||
|
default: () => <div data-testid="subscriptions-page">SubscriptionsPage</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../src/pages/SettingsPage', () => ({
|
||||||
|
default: () => <div data-testid="settings-page">SettingsPage</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../src/pages/DomainsPage', () => ({
|
||||||
|
default: () => <div data-testid="domains-page">DomainsPage</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../src/pages/TunnelsPage', () => ({
|
||||||
|
default: () => <div data-testid="tunnels-page">TunnelsPage</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../src/pages/NotFoundPage', () => ({
|
||||||
|
default: () => <div data-testid="not-found-page">NotFoundPage</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../src/components/Layout', () => ({
|
||||||
|
default: () => <div data-testid="layout">Layout</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Мок для AuthContext
|
||||||
|
vi.mock('../src/auth/AuthContext', () => ({
|
||||||
|
AuthProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
useAuth: () => ({
|
||||||
|
isAuthenticated: false,
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Мок для ThemeContext
|
||||||
|
vi.mock('../src/ThemeContext', () => ({
|
||||||
|
ThemeProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
useThemeContext: () => ({
|
||||||
|
mode: 'light' as const,
|
||||||
|
toggleColorMode: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Мок для AxiosInterceptor
|
||||||
|
vi.mock('../src/auth/AxiosInterceptor', () => ({
|
||||||
|
AxiosInterceptor: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Мок для RequireAuth - просто рендерит children
|
||||||
|
vi.mock('../src/auth/RequireAuth', () => ({
|
||||||
|
default: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Мок для PublicRoute - просто рендерит children
|
||||||
|
vi.mock('../src/auth/PublicRoute', () => ({
|
||||||
|
default: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен рендериться без ошибок', () => {
|
||||||
|
expect(() => {
|
||||||
|
render(<App />)
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен содержать Layout компонент', () => {
|
||||||
|
render(<App />)
|
||||||
|
expect(screen.getByTestId('layout')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { renderHook, act } from '@testing-library/react'
|
||||||
|
import { ThemeProvider, useThemeContext } from '@/ThemeContext'
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Мокаем useMediaQuery
|
||||||
|
const useMediaQueryMock = vi.fn()
|
||||||
|
vi.mock('@mui/material', async () => {
|
||||||
|
const actual = await vi.importActual('@mui/material')
|
||||||
|
return {
|
||||||
|
...(actual as object),
|
||||||
|
useMediaQuery: () => useMediaQueryMock(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<ThemeProvider>{children}</ThemeProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('ThemeContext', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorageMock.clear()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
useMediaQueryMock.mockReturnValue(false) // light mode by default
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useThemeContext', () => {
|
||||||
|
it('должен предоставлять context', () => {
|
||||||
|
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||||
|
expect(result.current).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен предоставлять mode и toggleColorMode', () => {
|
||||||
|
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||||
|
expect(result.current.mode).toBeDefined()
|
||||||
|
expect(result.current.toggleColorMode).toBeDefined()
|
||||||
|
expect(typeof result.current.toggleColorMode).toBe('function')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('initial state', () => {
|
||||||
|
it('должен инициализироваться с mode из localStorage', () => {
|
||||||
|
localStorageMock.getItem.mockReturnValue('dark')
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||||
|
|
||||||
|
expect(result.current.mode).toBe('dark')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен инициализироваться с "system" если mode отсутствует в localStorage', () => {
|
||||||
|
localStorageMock.getItem.mockReturnValue(null)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||||
|
|
||||||
|
expect(result.current.mode).toBe('system')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('toggleColorMode', () => {
|
||||||
|
it('должен переключать light → dark → system → light', () => {
|
||||||
|
localStorageMock.getItem.mockReturnValue('light')
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||||
|
|
||||||
|
expect(result.current.mode).toBe('light')
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.toggleColorMode()
|
||||||
|
})
|
||||||
|
expect(result.current.mode).toBe('dark')
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.toggleColorMode()
|
||||||
|
})
|
||||||
|
expect(result.current.mode).toBe('system')
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.toggleColorMode()
|
||||||
|
})
|
||||||
|
expect(result.current.mode).toBe('light')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен сохранять mode в localStorage при переключении', () => {
|
||||||
|
localStorageMock.getItem.mockReturnValue('light')
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.toggleColorMode()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(localStorageMock.setItem).toHaveBeenCalledWith('themeMode', 'dark')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('system mode', () => {
|
||||||
|
it('должен использовать dark когда prefers-color-scheme: dark', () => {
|
||||||
|
localStorageMock.getItem.mockReturnValue('system')
|
||||||
|
useMediaQueryMock.mockReturnValue(true) // prefers dark
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||||
|
|
||||||
|
// mode должен быть 'system', но тема должна быть dark
|
||||||
|
expect(result.current.mode).toBe('system')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен использовать light когда prefers-color-scheme: light', () => {
|
||||||
|
localStorageMock.getItem.mockReturnValue('system')
|
||||||
|
useMediaQueryMock.mockReturnValue(false) // prefers light
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||||
|
|
||||||
|
expect(result.current.mode).toBe('system')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('localStorage persistence', () => {
|
||||||
|
it('должен сохранять mode в localStorage при инициализации', () => {
|
||||||
|
localStorageMock.getItem.mockReturnValue(null)
|
||||||
|
|
||||||
|
renderHook(() => useThemeContext(), { wrapper })
|
||||||
|
|
||||||
|
expect(localStorageMock.setItem).toHaveBeenCalledWith('themeMode', 'system')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен читать mode из localStorage', () => {
|
||||||
|
localStorageMock.getItem.mockReturnValue('dark')
|
||||||
|
|
||||||
|
renderHook(() => useThemeContext(), { wrapper })
|
||||||
|
|
||||||
|
expect(localStorageMock.getItem).toHaveBeenCalledWith('themeMode')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mode values', () => {
|
||||||
|
it('должен поддерживать light mode', () => {
|
||||||
|
localStorageMock.getItem.mockReturnValue('light')
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||||
|
|
||||||
|
expect(result.current.mode).toBe('light')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен поддерживать dark mode', () => {
|
||||||
|
localStorageMock.getItem.mockReturnValue('dark')
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||||
|
|
||||||
|
expect(result.current.mode).toBe('dark')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен поддерживать system mode', () => {
|
||||||
|
localStorageMock.getItem.mockReturnValue('system')
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||||
|
|
||||||
|
expect(result.current.mode).toBe('system')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
// Mock для всех MUI иконок
|
||||||
|
// Этот файл автоматически используется vitest для мока @mui/icons-material
|
||||||
|
|
||||||
|
import { forwardRef } from 'react'
|
||||||
|
|
||||||
|
// Создаем универсальный мок для любой иконки
|
||||||
|
const IconMock = forwardRef<SVGSVGElement>((props, ref) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
ref={ref}
|
||||||
|
data-testid="mui-icon-mock"
|
||||||
|
focusable="false"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<rect width="24" height="24" fill="transparent" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
IconMock.displayName = 'IconMock'
|
||||||
|
|
||||||
|
// Экспортируем все возможные иконки как один и тот же мок
|
||||||
|
export const GitHub = IconMock
|
||||||
|
export const YouTube = IconMock
|
||||||
|
export const Telegram = IconMock
|
||||||
|
export const Brightness7 = IconMock
|
||||||
|
export const Brightness4 = IconMock
|
||||||
|
export const BrightnessAuto = IconMock
|
||||||
|
export const Logout = IconMock
|
||||||
|
export const HelpOutline = IconMock
|
||||||
|
export const Menu = IconMock
|
||||||
|
export const People = IconMock
|
||||||
|
export const Settings = IconMock
|
||||||
|
export const Dns = IconMock
|
||||||
|
export const SwapHoriz = IconMock
|
||||||
|
export const Delete = IconMock
|
||||||
|
export const Add = IconMock
|
||||||
|
export const Terminal = IconMock
|
||||||
|
export const CheckCircle = IconMock
|
||||||
|
export const Error = IconMock
|
||||||
|
export const LinkIcon = IconMock
|
||||||
|
export const OpenInNew = IconMock
|
||||||
|
export const ContentCopy = IconMock
|
||||||
|
export const Router = IconMock
|
||||||
|
export const Edit = IconMock
|
||||||
|
export const MoreVert = IconMock
|
||||||
|
export const Remove = IconMock
|
||||||
|
export const Refresh = IconMock
|
||||||
|
export const Search = IconMock
|
||||||
|
export const FilterList = IconMock
|
||||||
|
export const RefreshTwoTone = IconMock
|
||||||
|
export const Warning = IconMock
|
||||||
|
export const Info = IconMock
|
||||||
|
export const Close = IconMock
|
||||||
|
export const Check = IconMock
|
||||||
|
export const ArrowDownward = IconMock
|
||||||
|
export const ArrowUpward = IconMock
|
||||||
|
export const MoreHoriz = IconMock
|
||||||
|
export const ContentPaste = IconMock
|
||||||
|
export const QrCode = IconMock
|
||||||
|
export const Usb = IconMock
|
||||||
|
export const VpnKey = IconMock
|
||||||
|
export const Security = IconMock
|
||||||
|
export const Speed = IconMock
|
||||||
|
export const Timeline = IconMock
|
||||||
|
export const Assessment = IconMock
|
||||||
|
export const SettingsApplications = IconMock
|
||||||
|
export const CloudDownload = IconMock
|
||||||
|
export const CloudUpload = IconMock
|
||||||
|
export const Folder = IconMock
|
||||||
|
export const FileCopy = IconMock
|
||||||
|
export const Save = IconMock
|
||||||
|
export const Print = IconMock
|
||||||
|
export const DeleteOutline = IconMock
|
||||||
|
export const Restore = IconMock
|
||||||
|
export const History = IconMock
|
||||||
|
export const Schedule = IconMock
|
||||||
|
export const AccessTime = IconMock
|
||||||
|
export const Today = IconMock
|
||||||
|
export const Event = IconMock
|
||||||
|
export const Notifications = IconMock
|
||||||
|
export const AccountCircle = IconMock
|
||||||
|
export const Person = IconMock
|
||||||
|
export const Group = IconMock
|
||||||
|
export const Public = IconMock
|
||||||
|
export const Language = IconMock
|
||||||
|
export const Translate = IconMock
|
||||||
|
export const Star = IconMock
|
||||||
|
export const Favorite = IconMock
|
||||||
|
export const Home = IconMock
|
||||||
|
export const LocationOn = IconMock
|
||||||
|
export const Place = IconMock
|
||||||
|
export const Email = IconMock
|
||||||
|
export const Phone = IconMock
|
||||||
|
export const Chat = IconMock
|
||||||
|
export const Message = IconMock
|
||||||
|
export const Forum = IconMock
|
||||||
|
export const Share = IconMock
|
||||||
|
export const Send = IconMock
|
||||||
|
export const Inbox = IconMock
|
||||||
|
export const Drafts = IconMock
|
||||||
|
export const Mail = IconMock
|
||||||
|
export const Markunread = IconMock
|
||||||
|
export const Lock = IconMock
|
||||||
|
export const LockOpen = IconMock
|
||||||
|
export const Unlock = IconMock
|
||||||
|
export const Visibility = IconMock
|
||||||
|
export const VisibilityOff = IconMock
|
||||||
|
export const Eye = IconMock
|
||||||
|
export const EyeOff = IconMock
|
||||||
|
export const ToggleOn = IconMock
|
||||||
|
export const ToggleOff = IconMock
|
||||||
|
export const RadioButtonChecked = IconMock
|
||||||
|
export const RadioButtonUnchecked = IconMock
|
||||||
|
export const CheckBox = IconMock
|
||||||
|
export const CheckBoxOutlineBlank = IconMock
|
||||||
|
export const IndeterminateCheckBox = IconMock
|
||||||
|
export const PlusOne = IconMock
|
||||||
|
export const ThumbUp = IconMock
|
||||||
|
export const ThumbDown = IconMock
|
||||||
|
export const Whatshot = IconMock
|
||||||
|
export const FavoriteBorder = IconMock
|
||||||
|
export const StarBorder = IconMock
|
||||||
|
export const Bookmark = IconMock
|
||||||
|
export const BookmarkBorder = IconMock
|
||||||
|
export const Bookmarks = IconMock
|
||||||
|
export const TurnedIn = IconMock
|
||||||
|
export const TurnedInNot = IconMock
|
||||||
|
export const Label = IconMock
|
||||||
|
export const LabelImportant = IconMock
|
||||||
|
export const Grade = IconMock
|
||||||
|
export const Done = IconMock
|
||||||
|
export const Clear = IconMock
|
||||||
|
export const Block = IconMock
|
||||||
|
export const Ban = IconMock
|
||||||
|
export const Stop = IconMock
|
||||||
|
export const Pause = IconMock
|
||||||
|
export const PlayArrow = IconMock
|
||||||
|
export const FastForward = IconMock
|
||||||
|
export const FastRewind = IconMock
|
||||||
|
export const SkipNext = IconMock
|
||||||
|
export const SkipPrevious = IconMock
|
||||||
|
export const FiberManualRecord = IconMock
|
||||||
|
export const Circle = IconMock
|
||||||
|
export const Square = IconMock
|
||||||
|
export const Rectangle = IconMock
|
||||||
|
export const Triangle = IconMock
|
||||||
|
export const NavigateNext = IconMock
|
||||||
|
export const NavigateBefore = IconMock
|
||||||
|
export const ChevronRight = IconMock
|
||||||
|
export const ChevronLeft = IconMock
|
||||||
|
export const ExpandMore = IconMock
|
||||||
|
export const ExpandLess = IconMock
|
||||||
|
export const UnfoldMore = IconMock
|
||||||
|
export const UnfoldLess = IconMock
|
||||||
|
export const ArrowRight = IconMock
|
||||||
|
export const ArrowLeft = IconMock
|
||||||
|
export const ArrowBack = IconMock
|
||||||
|
export const ArrowForward = IconMock
|
||||||
|
export const ArrowDropDown = IconMock
|
||||||
|
export const ArrowDropUp = IconMock
|
||||||
|
export const Expand = IconMock
|
||||||
|
export const SubdirectoryArrowRight = IconMock
|
||||||
|
export const SubdirectoryArrowLeft = IconMock
|
||||||
|
export const FileDownload = IconMock
|
||||||
|
export const FileUpload = IconMock
|
||||||
|
export const Attachment = IconMock
|
||||||
|
export const Link = IconMock
|
||||||
|
export const InsertLink = IconMock
|
||||||
|
export const Photo = IconMock
|
||||||
|
export const Image = IconMock
|
||||||
|
export const PictureAsPdf = IconMock
|
||||||
|
export const ImageIcon = IconMock
|
||||||
|
export const CameraAlt = IconMock
|
||||||
|
export const Videocam = IconMock
|
||||||
|
export const Movie = IconMock
|
||||||
|
export const MusicNote = IconMock
|
||||||
|
export const Mic = IconMock
|
||||||
|
export const VolumeUp = IconMock
|
||||||
|
export const VolumeOff = IconMock
|
||||||
|
export const Headset = IconMock
|
||||||
|
export const Headphones = IconMock
|
||||||
|
export const Speaker = IconMock
|
||||||
|
export const Radio = IconMock
|
||||||
|
export const Podcasts = IconMock
|
||||||
|
export const Tv = IconMock
|
||||||
|
export const DesktopWindows = IconMock
|
||||||
|
export const Laptop = IconMock
|
||||||
|
export const Computer = IconMock
|
||||||
|
export const Tablet = IconMock
|
||||||
|
export const Smartphone = IconMock
|
||||||
|
export const PhoneIphone = IconMock
|
||||||
|
export const PhoneAndroid = IconMock
|
||||||
|
export const Devices = IconMock
|
||||||
|
export const SmartDisplay = IconMock
|
||||||
|
export const Monitor = IconMock
|
||||||
|
export const ScreenShare = IconMock
|
||||||
|
export const StopScreenShare = IconMock
|
||||||
|
export const PresentToAll = IconMock
|
||||||
|
export const Cast = IconMock
|
||||||
|
export const CastConnected = IconMock
|
||||||
|
export const CastForEducation = IconMock
|
||||||
|
export const Wifi = IconMock
|
||||||
|
export const WifiOff = IconMock
|
||||||
|
export const NetworkWifi = IconMock
|
||||||
|
export const NetworkCell = IconMock
|
||||||
|
export const SignalCellular4Bar = IconMock
|
||||||
|
export const SignalWifi4Bar = IconMock
|
||||||
|
export const Bluetooth = IconMock
|
||||||
|
export const BluetoothConnected = IconMock
|
||||||
|
export const BluetoothDisabled = IconMock
|
||||||
|
export const GpsFixed = IconMock
|
||||||
|
export const GpsNotFixed = IconMock
|
||||||
|
export const LocationSearching = IconMock
|
||||||
|
export const MyLocation = IconMock
|
||||||
|
export const Navigation = IconMock
|
||||||
|
export const NearMe = IconMock
|
||||||
|
export const Directions = IconMock
|
||||||
|
export const DirectionsCar = IconMock
|
||||||
|
export const DirectionsBus = IconMock
|
||||||
|
export const DirectionsTrain = IconMock
|
||||||
|
export const DirectionsBike = IconMock
|
||||||
|
export const DirectionsWalk = IconMock
|
||||||
|
export const DirectionsRun = IconMock
|
||||||
|
export const Flight = IconMock
|
||||||
|
export const LocalAirport = IconMock
|
||||||
|
export const Hotel = IconMock
|
||||||
|
export const Restaurant = IconMock
|
||||||
|
export const LocalCafe = IconMock
|
||||||
|
export const LocalBar = IconMock
|
||||||
|
export const LocalPizza = IconMock
|
||||||
|
export const BrunchDining = IconMock
|
||||||
|
export const DinnerDining = IconMock
|
||||||
|
export const LunchDining = IconMock
|
||||||
|
export const Nightlife = IconMock
|
||||||
|
export const LocalHospital = IconMock
|
||||||
|
export const LocalPharmacy = IconMock
|
||||||
|
export const ShoppingBag = IconMock
|
||||||
|
export const ShoppingCart = IconMock
|
||||||
|
export const ShoppingBasket = IconMock
|
||||||
|
export const Store = IconMock
|
||||||
|
export const Shop = IconMock
|
||||||
|
export const Storefront = IconMock
|
||||||
|
export const LocalMall = IconMock
|
||||||
|
export const AccountBalance = IconMock
|
||||||
|
export const Business = IconMock
|
||||||
|
export const CorporateFare = IconMock
|
||||||
|
export const Work = IconMock
|
||||||
|
export const MeetingRoom = IconMock
|
||||||
|
export const Gite = IconMock
|
||||||
|
export const House = IconMock
|
||||||
|
export const Cottage = IconMock
|
||||||
|
export const Apartment = IconMock
|
||||||
|
export const Villa = IconMock
|
||||||
|
export const OtherHouses = IconMock
|
||||||
|
export const Foundation = IconMock
|
||||||
|
export const Fence = IconMock
|
||||||
|
export const Yard = IconMock
|
||||||
|
export const Pool = IconMock
|
||||||
|
export const HotTub = IconMock
|
||||||
|
export const Spa = IconMock
|
||||||
|
export const FitnessCenter = IconMock
|
||||||
|
export const SportsGymnasium = IconMock
|
||||||
|
export const SportsBasketball = IconMock
|
||||||
|
export const SportsFootball = IconMock
|
||||||
|
export const SportsSoccer = IconMock
|
||||||
|
export const SportsTennis = IconMock
|
||||||
|
export const SportsVolleyball = IconMock
|
||||||
|
export const SportsBaseball = IconMock
|
||||||
|
export const SportsCricket = IconMock
|
||||||
|
export const SportsGolf = IconMock
|
||||||
|
export const SportsHockey = IconMock
|
||||||
|
export const SportsMma = IconMock
|
||||||
|
export const SportsMotorsports = IconMock
|
||||||
|
export const SportsRugby = IconMock
|
||||||
|
export const SportsScore = IconMock
|
||||||
|
export const SportsHandball = IconMock
|
||||||
|
export const SportsKabaddi = IconMock
|
||||||
|
export const Rowing = IconMock
|
||||||
|
export const Surfing = IconMock
|
||||||
|
export const Kitesurfing = IconMock
|
||||||
|
export const Snowboarding = IconMock
|
||||||
|
export const DownhillSkiing = IconMock
|
||||||
|
export const Snowshoeing = IconMock
|
||||||
|
export const IceSkating = IconMock
|
||||||
|
export const Curling = IconMock
|
||||||
|
export const Sailing = IconMock
|
||||||
|
export const Kayaking = IconMock
|
||||||
|
export const Rafting = IconMock
|
||||||
|
export const ScubaDiving = IconMock
|
||||||
|
export const Diving = IconMock
|
||||||
|
export const Fishing = IconMock
|
||||||
|
export const Hiking = IconMock
|
||||||
|
export const RunningWithErrors = IconMock
|
||||||
|
|
||||||
|
// Экспорт по умолчанию
|
||||||
|
export default IconMock
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Mock для всех MUI иконок
|
||||||
|
const createIconMock = (name) => {
|
||||||
|
const IconMock = (props) => {
|
||||||
|
return <span data-testid={`icon-${name}`} {...props} />
|
||||||
|
}
|
||||||
|
IconMock.displayName = name
|
||||||
|
return IconMock
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экспортируем все иконки динамически
|
||||||
|
const icons = [
|
||||||
|
'GitHub', 'YouTube', 'Telegram', 'Brightness7', 'Brightness4', 'BrightnessAuto',
|
||||||
|
'Logout', 'HelpOutline', 'Menu', 'People', 'Settings', 'Dns', 'SwapHoriz',
|
||||||
|
'Delete', 'Add', 'Terminal', 'CheckCircle', 'Error', 'LinkIcon', 'OpenInNew',
|
||||||
|
'ContentCopy', 'Router', 'Edit', 'MoreVert', 'Remove', 'Refresh', 'Search',
|
||||||
|
'FilterList', 'RefreshTwoTone', 'Warning', 'Info', 'Close', 'Check',
|
||||||
|
'ArrowDownward', 'ArrowUpward', 'MoreHoriz', 'ContentPaste', 'QrCode', 'Usb',
|
||||||
|
'VpnKey', 'Security', 'Speed', 'Timeline', 'Assessment', 'SettingsApplications',
|
||||||
|
'CloudDownload', 'CloudUpload', 'Folder', 'FileCopy', 'Save', 'Print',
|
||||||
|
'DeleteOutline', 'Restore', 'History', 'Schedule', 'AccessTime', 'Today',
|
||||||
|
'Event', 'Notifications', 'AccountCircle', 'Person', 'Group', 'Public',
|
||||||
|
'Language', 'Translate', 'Star', 'Favorite', 'Home', 'LocationOn', 'Place',
|
||||||
|
'Email', 'Phone', 'Chat', 'Message', 'Forum', 'Share', 'Send', 'Inbox',
|
||||||
|
'Drafts', 'Mail', 'Markunread', 'Lock', 'LockOpen', 'Unlock', 'Visibility',
|
||||||
|
'VisibilityOff', 'Eye', 'EyeOff', 'ToggleOn', 'ToggleOff', 'RadioButtonChecked',
|
||||||
|
'RadioButtonUnchecked', 'CheckBox', 'CheckBoxOutlineBlank', 'IndeterminateCheckBox',
|
||||||
|
'PlusOne', 'ThumbUp', 'ThumbDown', 'Whatshot', 'FavoriteBorder', 'StarBorder',
|
||||||
|
'Bookmark', 'BookmarkBorder', 'Bookmarks', 'TurnedIn', 'TurnedInNot', 'Label',
|
||||||
|
'LabelImportant', 'Grade', 'Done', 'Clear', 'Block', 'Ban', 'Stop', 'Pause',
|
||||||
|
'PlayArrow', 'FastForward', 'FastRewind', 'SkipNext', 'SkipPrevious',
|
||||||
|
'FiberManualRecord', 'Circle', 'Square', 'Rectangle', 'Triangle', 'NavigateNext',
|
||||||
|
'NavigateBefore', 'ChevronRight', 'ChevronLeft', 'ExpandMore', 'ExpandLess',
|
||||||
|
'UnfoldMore', 'UnfoldLess', 'ArrowRight', 'ArrowLeft', 'ArrowBack', 'ArrowForward',
|
||||||
|
'ArrowDropDown', 'ArrowDropUp', 'Expand', 'SubdirectoryArrowRight',
|
||||||
|
'SubdirectoryArrowLeft', 'FileDownload', 'FileUpload', 'Attachment', 'Link',
|
||||||
|
'InsertLink', 'Photo', 'Image', 'PictureAsPdf', 'ImageIcon', 'CameraAlt',
|
||||||
|
'Videocam', 'Movie', 'MusicNote', 'Mic', 'VolumeUp', 'VolumeOff', 'Headset',
|
||||||
|
'Headphones', 'Speaker', 'Radio', 'Podcasts', 'Tv', 'DesktopWindows', 'Laptop',
|
||||||
|
'Computer', 'Tablet', 'Smartphone', 'PhoneIphone', 'PhoneAndroid', 'Devices',
|
||||||
|
'SmartDisplay', 'Monitor', 'ScreenShare', 'StopScreenShare', 'PresentToAll',
|
||||||
|
'Cast', 'CastConnected', 'CastForEducation', 'Wifi', 'WifiOff', 'NetworkWifi',
|
||||||
|
'NetworkCell', 'SignalCellular4Bar', 'SignalWifi4Bar', 'Bluetooth',
|
||||||
|
'BluetoothConnected', 'BluetoothDisabled', 'GpsFixed', 'GpsNotFixed',
|
||||||
|
'LocationSearching', 'MyLocation', 'Navigation', 'NearMe', 'Directions',
|
||||||
|
'DirectionsCar', 'DirectionsBus', 'DirectionsTrain', 'DirectionsBike',
|
||||||
|
'DirectionsWalk', 'DirectionsRun', 'Flight', 'LocalAirport', 'Hotel',
|
||||||
|
'Restaurant', 'LocalCafe', 'LocalBar', 'LocalPizza', 'BrunchDining',
|
||||||
|
'DinnerDining', 'LunchDining', 'Nightlife', 'LocalHospital', 'LocalPharmacy',
|
||||||
|
'ShoppingBag', 'ShoppingCart', 'ShoppingBasket', 'Store', 'Shop', 'Storefront',
|
||||||
|
'LocalMall', 'AccountBalance', 'Business', 'CorporateFare', 'Work',
|
||||||
|
'MeetingRoom', 'Gite', 'House', 'Cottage', 'Apartment', 'Villa', 'OtherHouses',
|
||||||
|
'Foundation', 'Fence', 'Yard', 'Pool', 'HotTub', 'Spa', 'FitnessCenter',
|
||||||
|
'SportsGymnasium', 'SportsBasketball', 'SportsFootball', 'SportsSoccer',
|
||||||
|
'SportsTennis', 'SportsVolleyball', 'SportsBaseball', 'SportsCricket',
|
||||||
|
'SportsGolf', 'SportsHockey', 'SportsMma', 'SportsMotorsports', 'SportsRugby',
|
||||||
|
'SportsScore', 'SportsHandball', 'SportsKabaddi', 'Rowing', 'Surfing',
|
||||||
|
'Kitesurfing', 'Snowboarding', 'DownhillSkiing', 'Snowshoeing', 'IceSkating',
|
||||||
|
'Curling', 'Sailing', 'Kayaking', 'Rafting', 'ScubaDiving', 'Diving', 'Fishing',
|
||||||
|
'Hiking', 'RunningWithErrors', 'PlayCircleFilled', 'PauseCircleFilled',
|
||||||
|
'CheckCircle', 'Dns'
|
||||||
|
]
|
||||||
|
|
||||||
|
// Создаем экспорт для каждой иконки
|
||||||
|
const exportsObj = {}
|
||||||
|
icons.forEach(name => {
|
||||||
|
exportsObj[name] = createIconMock(name)
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = exportsObj
|
||||||
|
module.exports.default = createIconMock('DefaultIcon')
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "@mui/icons-material",
|
||||||
|
"main": "index.js",
|
||||||
|
"module": "index.js"
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
|
||||||
|
// Мок для 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,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Динамический импорт после настройки моков
|
||||||
|
let api: typeof import('@/api').default
|
||||||
|
|
||||||
|
describe('api', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
// Очищаем модуль перед каждым тестом
|
||||||
|
await vi.resetModules()
|
||||||
|
const module = await import('@/api')
|
||||||
|
api = module.default
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('API instance', () => {
|
||||||
|
it('должен быть создан с baseURL /api', () => {
|
||||||
|
expect(api.defaults.baseURL).toBe('/api')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Request interceptor', () => {
|
||||||
|
it('должен добавлять токен из localStorage в заголовок Authorization', async () => {
|
||||||
|
localStorageMock.getItem.mockReturnValue('test-token')
|
||||||
|
|
||||||
|
const mockAdapter = vi.fn(() => Promise.resolve({ data: {}, status: 200, headers: {}, config: {} }))
|
||||||
|
api.defaults.adapter = mockAdapter
|
||||||
|
|
||||||
|
await api.get('/test')
|
||||||
|
|
||||||
|
const config = mockAdapter.mock.calls[0][0]
|
||||||
|
expect(config.headers.get('Authorization')).toBe('Bearer test-token')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('не должен добавлять токен если он отсутствует', async () => {
|
||||||
|
localStorageMock.getItem.mockReturnValue(null)
|
||||||
|
|
||||||
|
const mockAdapter = vi.fn(() => Promise.resolve({ data: {}, status: 200, headers: {}, config: {} }))
|
||||||
|
api.defaults.adapter = mockAdapter
|
||||||
|
|
||||||
|
await api.get('/test')
|
||||||
|
|
||||||
|
const config = mockAdapter.mock.calls[0][0]
|
||||||
|
expect(config.headers.get('Authorization')).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен логировать запрос с существующим токеном', async () => {
|
||||||
|
localStorageMock.getItem.mockReturnValue('test-token')
|
||||||
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
|
||||||
|
const mockAdapter = vi.fn(() => Promise.resolve({ data: {}, status: 200, headers: {}, config: {} }))
|
||||||
|
api.defaults.adapter = mockAdapter
|
||||||
|
|
||||||
|
await api.get('/test')
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Token: EXISTS'), expect.any(String))
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен логировать запрос без токена', async () => {
|
||||||
|
localStorageMock.getItem.mockReturnValue(null)
|
||||||
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
|
||||||
|
const mockAdapter = vi.fn(() => Promise.resolve({ data: {}, status: 200, headers: {}, config: {} }))
|
||||||
|
api.defaults.adapter = mockAdapter
|
||||||
|
|
||||||
|
await api.get('/test')
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Token: NULL'), expect.any(String))
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Response interceptor', () => {
|
||||||
|
it('должен логировать успешный ответ', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
|
||||||
|
const mockAdapter = vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: { result: 'ok' },
|
||||||
|
status: 200,
|
||||||
|
headers: {},
|
||||||
|
config: { method: 'get', url: '/test' }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
api.defaults.adapter = mockAdapter
|
||||||
|
|
||||||
|
const response = await api.get('/test')
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('200 OK'), expect.any(String))
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен логировать ошибку с status кодом', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
const mockAdapter = vi.fn(() =>
|
||||||
|
Promise.reject({
|
||||||
|
response: { status: 401, data: { message: 'Unauthorized' } },
|
||||||
|
config: { method: 'get', url: '/test' }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
api.defaults.adapter = mockAdapter
|
||||||
|
|
||||||
|
await expect(api.get('/test')).rejects.toThrow()
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('ERROR 401: Unauthorized'),
|
||||||
|
expect.any(String)
|
||||||
|
)
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен логировать сетевую ошибку без status', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
const mockAdapter = vi.fn(() =>
|
||||||
|
Promise.reject({
|
||||||
|
message: 'Network Error',
|
||||||
|
config: { method: 'get', url: '/test' }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
api.defaults.adapter = mockAdapter
|
||||||
|
|
||||||
|
await expect(api.get('/test')).rejects.toThrow()
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('ERROR NETWORK: Network Error'),
|
||||||
|
expect.any(String)
|
||||||
|
)
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен возвращать сообщение из response.data.message', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
const mockAdapter = vi.fn(() =>
|
||||||
|
Promise.reject({
|
||||||
|
response: { status: 400, data: { message: 'Bad Request' } },
|
||||||
|
config: { method: 'post', url: '/create' }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
api.defaults.adapter = mockAdapter
|
||||||
|
|
||||||
|
await expect(api.post('/create')).rejects.toThrow()
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Bad Request'),
|
||||||
|
expect.any(String)
|
||||||
|
)
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import Footer from '../../src/components/Footer'
|
||||||
|
|
||||||
|
describe('Footer', () => {
|
||||||
|
const renderFooter = (props: Partial<React.ComponentProps<typeof Footer>> = {}) => {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Footer {...props} />
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('должен рендериться с логотипом', () => {
|
||||||
|
renderFooter()
|
||||||
|
const logo = screen.getByAltText('Logo')
|
||||||
|
expect(logo).toBeInTheDocument()
|
||||||
|
expect(logo).toHaveAttribute('src', '/img/logo.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать ссылку на документацию', () => {
|
||||||
|
renderFooter()
|
||||||
|
const docLink = screen.getByText('Документация')
|
||||||
|
expect(docLink).toBeInTheDocument()
|
||||||
|
expect(docLink).toHaveAttribute('href', 'https://3dp-manager.com/docs/intro')
|
||||||
|
expect(docLink).toHaveAttribute('target', '_blank')
|
||||||
|
expect(docLink).toHaveAttribute('rel', 'noopener')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать иконку GitHub', () => {
|
||||||
|
renderFooter()
|
||||||
|
const githubButton = screen.getByLabelText('GitHub')
|
||||||
|
expect(githubButton).toBeInTheDocument()
|
||||||
|
expect(githubButton.closest('a')).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'https://github.com/denpiligrim/3dp-manager'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать иконку YouTube', () => {
|
||||||
|
renderFooter()
|
||||||
|
const youtubeButton = screen.getByLabelText('YouTube')
|
||||||
|
expect(youtubeButton).toBeInTheDocument()
|
||||||
|
expect(youtubeButton.closest('a')).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'https://youtube.com/@denpiligrim'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать иконку Telegram', () => {
|
||||||
|
renderFooter()
|
||||||
|
const telegramButton = screen.getByLabelText('Telegram')
|
||||||
|
expect(telegramButton).toBeInTheDocument()
|
||||||
|
expect(telegramButton.closest('a')).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'https://t.me/denpiligrim_web'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен принимать prop isMobile', () => {
|
||||||
|
renderFooter({ isMobile: true })
|
||||||
|
expect(screen.getByAltText('Logo')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен иметь правильный семантический тег footer', () => {
|
||||||
|
renderFooter()
|
||||||
|
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен иметь правильный layout с Grid', () => {
|
||||||
|
renderFooter()
|
||||||
|
// Проверяем, что все три колонки присутствуют
|
||||||
|
expect(screen.getByText('Документация')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('GitHub')).toBeInTheDocument()
|
||||||
|
expect(screen.getByAltText('Logo')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import Header from '../../src/components/Header'
|
||||||
|
import { useThemeContext } from '../../src/ThemeContext'
|
||||||
|
import { useAuth } from '../../src/auth/AuthContext'
|
||||||
|
|
||||||
|
vi.mock('../../src/ThemeContext', () => ({
|
||||||
|
useThemeContext: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../src/auth/AuthContext', () => ({
|
||||||
|
useAuth: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('Header', () => {
|
||||||
|
const mockToggleColorMode = vi.fn()
|
||||||
|
const mockLogout = vi.fn()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(useThemeContext).mockReturnValue({
|
||||||
|
mode: 'light',
|
||||||
|
toggleColorMode: mockToggleColorMode,
|
||||||
|
})
|
||||||
|
vi.mocked(useAuth).mockReturnValue({
|
||||||
|
isAuthenticated: true,
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: mockLogout,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderHeader = (props: Partial<React.ComponentProps<typeof Header>> = {}) => {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Header {...props} />
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('должен рендериться с логотипом и названием', () => {
|
||||||
|
renderHeader()
|
||||||
|
expect(screen.getByText('3DP-MANAGER')).toBeInTheDocument()
|
||||||
|
const logo = screen.getByAltText('Logo')
|
||||||
|
expect(logo).toBeInTheDocument()
|
||||||
|
expect(logo).toHaveAttribute('src', '/img/logo.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать иконку справки', () => {
|
||||||
|
renderHeader()
|
||||||
|
const helpButton = screen.getByLabelText('Справка о программе')
|
||||||
|
expect(helpButton).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен открывать диалог справки при клике на иконку справки', async () => {
|
||||||
|
renderHeader()
|
||||||
|
const helpButton = screen.getByLabelText('Справка о программе')
|
||||||
|
fireEvent.click(helpButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Об утилите 3DP-MANAGER')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.getByText(/Утилита для автогенерации инбаундов/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен закрывать диалог справки при клике на кнопку "Понятно"', async () => {
|
||||||
|
renderHeader()
|
||||||
|
const helpButton = screen.getByLabelText('Справка о программе')
|
||||||
|
fireEvent.click(helpButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Об утилите 3DP-MANAGER')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const closeButton = screen.getByText('Понятно')
|
||||||
|
fireEvent.click(closeButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Об утилите 3DP-MANAGER')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать иконку темы', () => {
|
||||||
|
renderHeader()
|
||||||
|
const themeButton = screen.getByLabelText('Режим: Светлая тема')
|
||||||
|
expect(themeButton).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен вызывать toggleColorMode при клике на иконку темы', () => {
|
||||||
|
renderHeader()
|
||||||
|
const themeButton = screen.getByLabelText('Режим: Светлая тема')
|
||||||
|
fireEvent.click(themeButton)
|
||||||
|
expect(mockToggleColorMode).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать правильную иконку для light mode', () => {
|
||||||
|
vi.mocked(useThemeContext).mockReturnValue({
|
||||||
|
mode: 'light',
|
||||||
|
toggleColorMode: mockToggleColorMode,
|
||||||
|
})
|
||||||
|
renderHeader()
|
||||||
|
expect(screen.getByLabelText('Режим: Светлая тема')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать правильную иконку для dark mode', () => {
|
||||||
|
vi.mocked(useThemeContext).mockReturnValue({
|
||||||
|
mode: 'dark',
|
||||||
|
toggleColorMode: mockToggleColorMode,
|
||||||
|
})
|
||||||
|
renderHeader()
|
||||||
|
expect(screen.getByLabelText('Режим: Темная тема')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать правильную иконку для system mode', () => {
|
||||||
|
vi.mocked(useThemeContext).mockReturnValue({
|
||||||
|
mode: 'system',
|
||||||
|
toggleColorMode: mockToggleColorMode,
|
||||||
|
})
|
||||||
|
renderHeader()
|
||||||
|
expect(screen.getByLabelText('Режим: Системная тема')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать иконку выхода', () => {
|
||||||
|
renderHeader()
|
||||||
|
const logoutButton = screen.getByLabelText('Выйти из системы')
|
||||||
|
expect(logoutButton).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен открывать диалог подтверждения при клике на выход', async () => {
|
||||||
|
renderHeader()
|
||||||
|
const logoutButton = screen.getByLabelText('Выйти из системы')
|
||||||
|
fireEvent.click(logoutButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Вы действительно хотите выйти?')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен закрывать диалог подтверждения при клике на "Отмена"', async () => {
|
||||||
|
renderHeader()
|
||||||
|
const logoutButton = screen.getByLabelText('Выйти из системы')
|
||||||
|
fireEvent.click(logoutButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Вы действительно хотите выйти?')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const cancelButton = screen.getByText('Отмена')
|
||||||
|
fireEvent.click(cancelButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Вы действительно хотите выйти?')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен вызывать logout при подтверждении выхода', async () => {
|
||||||
|
renderHeader()
|
||||||
|
const logoutButton = screen.getByLabelText('Выйти из системы')
|
||||||
|
fireEvent.click(logoutButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Вы действительно хотите выйти?')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const confirmButton = screen.getByText('Выйти')
|
||||||
|
fireEvent.click(confirmButton)
|
||||||
|
|
||||||
|
expect(mockLogout).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен принимать prop isMobile', () => {
|
||||||
|
renderHeader({ isMobile: true })
|
||||||
|
expect(screen.getByText('3DP-MANAGER')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать кнопку меню в мобильном режиме', () => {
|
||||||
|
const onMenuClick = vi.fn()
|
||||||
|
renderHeader({ isMobile: true, onMenuClick })
|
||||||
|
|
||||||
|
const menuButton = screen.getByTestId('icon-Menu').closest('button')
|
||||||
|
expect(menuButton).toBeInTheDocument()
|
||||||
|
|
||||||
|
if (menuButton) {
|
||||||
|
fireEvent.click(menuButton)
|
||||||
|
expect(onMenuClick).toHaveBeenCalled()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать версию в диалоге справки', async () => {
|
||||||
|
renderHeader()
|
||||||
|
const helpButton = screen.getByLabelText('Справка о программе')
|
||||||
|
fireEvent.click(helpButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Разработчик:/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
// Версия отображается отдельным текстом с br переносом
|
||||||
|
expect(screen.getByText(/\d+\.\d+\.\d+/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать список возможностей в диалоге справки', async () => {
|
||||||
|
renderHeader()
|
||||||
|
const helpButton = screen.getByLabelText('Справка о программе')
|
||||||
|
fireEvent.click(helpButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Автоматическая генерация')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.getByText('Управление подписками')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Белый список доменов')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Перенаправление')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||||
|
import Layout from '../../src/components/Layout'
|
||||||
|
import { useThemeContext } from '../../src/ThemeContext'
|
||||||
|
import { useAuth } from '../../src/auth/AuthContext'
|
||||||
|
|
||||||
|
vi.mock('../../src/ThemeContext', () => ({
|
||||||
|
useThemeContext: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../src/auth/AuthContext', () => ({
|
||||||
|
useAuth: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('Layout', () => {
|
||||||
|
const mockToggleColorMode = vi.fn()
|
||||||
|
const mockLogout = vi.fn()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(useThemeContext).mockReturnValue({
|
||||||
|
mode: 'light',
|
||||||
|
toggleColorMode: mockToggleColorMode,
|
||||||
|
})
|
||||||
|
vi.mocked(useAuth).mockReturnValue({
|
||||||
|
isAuthenticated: true,
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: mockLogout,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderLayout = (initialPath = '/') => {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Layout />}>
|
||||||
|
<Route index element={<div data-testid="outlet">SubscriptionsPage</div>} />
|
||||||
|
<Route path="domains" element={<div data-testid="outlet">DomainsPage</div>} />
|
||||||
|
<Route path="tunnels" element={<div data-testid="outlet">TunnelsPage</div>} />
|
||||||
|
<Route path="settings" element={<div data-testid="outlet">SettingsPage</div>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('должен рендериться без ошибок', () => {
|
||||||
|
renderLayout()
|
||||||
|
expect(screen.getByTestId('outlet')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать Header', () => {
|
||||||
|
renderLayout()
|
||||||
|
expect(screen.getByText('3DP-MANAGER')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать навигационное меню', () => {
|
||||||
|
renderLayout()
|
||||||
|
expect(screen.getByText('Подписки')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Домены')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Перенаправление')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Настройки')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен выделять активный пункт меню для главной страницы', () => {
|
||||||
|
renderLayout('/')
|
||||||
|
const subscriptionsItem = screen.getByText('Подписки').closest('.Mui-selected')
|
||||||
|
expect(subscriptionsItem).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен выделять активный пункт меню для страницы доменов', () => {
|
||||||
|
renderLayout('/domains')
|
||||||
|
const domainsItem = screen.getByText('Домены').closest('.Mui-selected')
|
||||||
|
expect(domainsItem).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен выделять активный пункт меню для страницы туннелей', () => {
|
||||||
|
renderLayout('/tunnels')
|
||||||
|
const tunnelsItem = screen.getByText('Перенаправление').closest('.Mui-selected')
|
||||||
|
expect(tunnelsItem).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен выделять активный пункт меню для страницы настроек', () => {
|
||||||
|
renderLayout('/settings')
|
||||||
|
const settingsItem = screen.getByText('Настройки').closest('.Mui-selected')
|
||||||
|
expect(settingsItem).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен переходить на главную при клике на "Подписки"', () => {
|
||||||
|
renderLayout('/domains')
|
||||||
|
const subscriptionsLink = screen.getByText('Подписки')
|
||||||
|
fireEvent.click(subscriptionsLink)
|
||||||
|
expect(screen.getByText('SubscriptionsPage')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен переходить на страницу доменов при клике на "Домены"', () => {
|
||||||
|
renderLayout('/')
|
||||||
|
const domainsLink = screen.getByText('Домены')
|
||||||
|
fireEvent.click(domainsLink)
|
||||||
|
expect(screen.getByText('DomainsPage')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен переходить на страницу туннелей при клике на "Перенаправление"', () => {
|
||||||
|
renderLayout('/')
|
||||||
|
const tunnelsLink = screen.getByText('Перенаправление')
|
||||||
|
fireEvent.click(tunnelsLink)
|
||||||
|
expect(screen.getByText('TunnelsPage')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен переходить на страницу настроек при клике на "Настройки"', () => {
|
||||||
|
renderLayout('/')
|
||||||
|
const settingsLink = screen.getByText('Настройки')
|
||||||
|
fireEvent.click(settingsLink)
|
||||||
|
expect(screen.getByText('SettingsPage')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать Footer', () => {
|
||||||
|
renderLayout()
|
||||||
|
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен иметь правильную структуру с main и Toolbar', () => {
|
||||||
|
renderLayout()
|
||||||
|
const main = screen.getByTestId('outlet').closest('main')
|
||||||
|
expect(main).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен иметь правильные иконки для пунктов меню', () => {
|
||||||
|
renderLayout()
|
||||||
|
expect(screen.getByTestId('icon-People')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('icon-Dns')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('icon-SwapHoriz')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('icon-Settings')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
// Мок для window.matchMedia
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation(query => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Мок для scrollIntoView
|
||||||
|
Element.prototype.scrollIntoView = vi.fn()
|
||||||
|
|
||||||
|
// Мок для IntersectionObserver
|
||||||
|
window.IntersectionObserver = vi.fn(() => ({
|
||||||
|
observe: vi.fn(),
|
||||||
|
unobserve: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
}))
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import LoginPage from '../../src/pages/LoginPage'
|
||||||
|
import { ThemeProvider } from '../../src/ThemeContext'
|
||||||
|
import { AuthProvider } from '../../src/auth/AuthContext'
|
||||||
|
|
||||||
|
const mockNavigate = vi.fn()
|
||||||
|
const mockLogin = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('react-router-dom', async () => {
|
||||||
|
const actual = await vi.importActual('react-router-dom')
|
||||||
|
return {
|
||||||
|
...(actual as object),
|
||||||
|
useNavigate: () => mockNavigate,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('../../src/auth/AuthContext', async () => {
|
||||||
|
const actual = await vi.importActual('../../src/auth/AuthContext')
|
||||||
|
return {
|
||||||
|
...(actual as object),
|
||||||
|
useAuth: () => ({ login: mockLogin }),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('../../src/api', () => ({
|
||||||
|
default: {
|
||||||
|
post: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const renderLoginPage = () => {
|
||||||
|
return render(
|
||||||
|
<BrowserRouter>
|
||||||
|
<ThemeProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<LoginPage />
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LoginPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockNavigate.mockClear()
|
||||||
|
mockLogin.mockClear()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать заголовок с версией', () => {
|
||||||
|
renderLoginPage()
|
||||||
|
expect(screen.getByText(/Вход в 3DP-MANAGER/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать поля ввода логина и пароля', () => {
|
||||||
|
renderLoginPage()
|
||||||
|
expect(screen.getByLabelText('Логин')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Пароль')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать кнопку "Войти"', () => {
|
||||||
|
renderLoginPage()
|
||||||
|
expect(screen.getByText('Войти')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен позволять вводить текст в поля', () => {
|
||||||
|
renderLoginPage()
|
||||||
|
const loginField = screen.getByLabelText('Логин')
|
||||||
|
const passwordField = screen.getByLabelText('Пароль')
|
||||||
|
|
||||||
|
fireEvent.change(loginField, { target: { value: 'admin' } })
|
||||||
|
fireEvent.change(passwordField, { target: { value: 'password123' } })
|
||||||
|
|
||||||
|
expect(loginField).toHaveValue('admin')
|
||||||
|
expect(passwordField).toHaveValue('password123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен показывать ошибку при неверных учетных данных', async () => {
|
||||||
|
const api = await import('../../src/api')
|
||||||
|
vi.mocked(api.default.post).mockRejectedValue({ response: { status: 401 } })
|
||||||
|
|
||||||
|
renderLoginPage()
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Логин'), { target: { value: 'admin' } })
|
||||||
|
fireEvent.change(screen.getByLabelText('Пароль'), { target: { value: 'wrong' } })
|
||||||
|
fireEvent.click(screen.getByText('Войти'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Неверный логин или пароль')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен выполнять навигацию на главную при успешном входе', async () => {
|
||||||
|
const api = await import('../../src/api')
|
||||||
|
vi.mocked(api.default.post).mockResolvedValue({ data: { access_token: 'fake-token' } })
|
||||||
|
|
||||||
|
renderLoginPage()
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Логин'), { target: { value: 'admin' } })
|
||||||
|
fireEvent.change(screen.getByLabelText('Пароль'), { target: { value: 'password' } })
|
||||||
|
fireEvent.click(screen.getByText('Войти'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockLogin).toHaveBeenCalledWith('fake-token')
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import NotFoundPage from '../../src/pages/NotFoundPage'
|
||||||
|
|
||||||
|
const mockNavigate = vi.fn()
|
||||||
|
vi.mock('react-router-dom', async () => {
|
||||||
|
const actual = await vi.importActual('react-router-dom')
|
||||||
|
return {
|
||||||
|
...(actual as object),
|
||||||
|
useNavigate: () => mockNavigate,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderNotFoundPage = () => {
|
||||||
|
return render(
|
||||||
|
<BrowserRouter>
|
||||||
|
<NotFoundPage />
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('NotFoundPage', () => {
|
||||||
|
it('должен отображать код ошибки 404', () => {
|
||||||
|
renderNotFoundPage()
|
||||||
|
expect(screen.getByText('404')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать сообщение "Страница не найдена"', () => {
|
||||||
|
renderNotFoundPage()
|
||||||
|
expect(screen.getByText('Страница не найдена')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен иметь кнопку "На главную"', () => {
|
||||||
|
renderNotFoundPage()
|
||||||
|
expect(screen.getByText('На главную')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен выполнять навигацию на главную при клике на кнопку', () => {
|
||||||
|
renderNotFoundPage()
|
||||||
|
fireEvent.click(screen.getByText('На главную'))
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,651 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import SettingsPage from '../../src/pages/SettingsPage'
|
||||||
|
import { ThemeProvider } from '../../src/ThemeContext'
|
||||||
|
import { AuthProvider } from '../../src/auth/AuthContext'
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPut = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('../../src/api', () => ({
|
||||||
|
default: {
|
||||||
|
get: (...args: unknown[]) => mockGet(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
put: (...args: unknown[]) => mockPut(...args),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Хелпер для настройки мока get по умолчанию
|
||||||
|
const setupMockGet = (overrides?: { settings?: Record<string, unknown>, subscriptions?: unknown[] }) => {
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url === '/settings') return Promise.resolve({ data: overrides?.settings || {} })
|
||||||
|
if (url === '/subscriptions') return Promise.resolve({ data: overrides?.subscriptions || [] })
|
||||||
|
return Promise.resolve({ data: {} })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSettingsPage = () => {
|
||||||
|
return render(
|
||||||
|
<BrowserRouter>
|
||||||
|
<ThemeProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<SettingsPage />
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SettingsPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Рендеринг', () => {
|
||||||
|
it('должен рендериться с заголовком', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Настройки утилиты')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать секцию панели 3x-ui', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Панель 3x-ui')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать секцию генерации инбаундов', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Генерация инбаундов')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать поля ввода для 3x-ui', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText('URL панели')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Логин 3x-ui')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Пароль 3x-ui')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать поле интервала генерации', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText('Интервал генерации')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать пресеты интервалов', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Сутки')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('3 дня')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Неделя')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Загрузка настроек', () => {
|
||||||
|
it('должен загружать настройки при монтировании', async () => {
|
||||||
|
const mockSettings = {
|
||||||
|
xui_url: 'https://test.com:2053',
|
||||||
|
xui_login: 'admin',
|
||||||
|
xui_password: 'password',
|
||||||
|
rotation_interval: '60',
|
||||||
|
rotation_status: 'active',
|
||||||
|
last_rotation_timestamp: '1234567890',
|
||||||
|
}
|
||||||
|
setupMockGet({ settings: mockSettings })
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/settings')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен загружать подписки при монтировании', async () => {
|
||||||
|
const mockSubs = [
|
||||||
|
{ id: '1', name: 'Test Sub', uuid: 'abc-123', isAutoRotationEnabled: true }
|
||||||
|
]
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url === '/subscriptions') return Promise.resolve({ data: mockSubs })
|
||||||
|
return Promise.resolve({ data: {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/subscriptions')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Изменение полей', () => {
|
||||||
|
it('должен позволять изменять URL панели', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const urlField = await screen.findByLabelText('URL панели')
|
||||||
|
fireEvent.change(urlField, { target: { value: 'https://new-url.com:2053' } })
|
||||||
|
|
||||||
|
expect(urlField).toHaveValue('https://new-url.com:2053')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен позволять изменять логин 3x-ui', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const loginField = await screen.findByLabelText('Логин 3x-ui')
|
||||||
|
fireEvent.change(loginField, { target: { value: 'newadmin' } })
|
||||||
|
|
||||||
|
expect(loginField).toHaveValue('newadmin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен позволять изменять пароль 3x-ui', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const passwordField = await screen.findByLabelText('Пароль 3x-ui')
|
||||||
|
fireEvent.change(passwordField, { target: { value: 'newpassword' } })
|
||||||
|
|
||||||
|
expect(passwordField).toHaveValue('newpassword')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Пресеты интервалов', () => {
|
||||||
|
it('должен отображать пресеты интервалов', async () => {
|
||||||
|
setupMockGet({ settings: { rotation_interval: '30' } })
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Сутки')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('3 дня')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Неделя')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Сохранение настроек подключения', () => {
|
||||||
|
it('должен показывать ошибку при пустых полях', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const saveButton = await screen.findByText('Сохранить подключение')
|
||||||
|
fireEvent.click(saveButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Заполните все поля/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен сохранять настройки при валидных данных', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
mockPost.mockResolvedValue({ data: {} })
|
||||||
|
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const urlField = await screen.findByLabelText('URL панели')
|
||||||
|
const loginField = screen.getByLabelText('Логин 3x-ui')
|
||||||
|
const passwordField = screen.getByLabelText('Пароль 3x-ui')
|
||||||
|
|
||||||
|
fireEvent.change(urlField, { target: { value: 'https://test.com:2053' } })
|
||||||
|
fireEvent.change(loginField, { target: { value: 'admin' } })
|
||||||
|
fireEvent.change(passwordField, { target: { value: 'password' } })
|
||||||
|
|
||||||
|
const saveButton = screen.getByText('Сохранить подключение')
|
||||||
|
fireEvent.click(saveButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/settings', expect.objectContaining({
|
||||||
|
xui_url: 'https://test.com:2053',
|
||||||
|
xui_login: 'admin',
|
||||||
|
xui_password: 'password',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен показывать сообщение об успехе после сохранения', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
mockPost.mockResolvedValue({ data: {} })
|
||||||
|
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const urlField = await screen.findByLabelText('URL панели')
|
||||||
|
const loginField = screen.getByLabelText('Логин 3x-ui')
|
||||||
|
const passwordField = screen.getByLabelText('Пароль 3x-ui')
|
||||||
|
|
||||||
|
fireEvent.change(urlField, { target: { value: 'https://test.com:2053' } })
|
||||||
|
fireEvent.change(loginField, { target: { value: 'admin' } })
|
||||||
|
fireEvent.change(passwordField, { target: { value: 'password' } })
|
||||||
|
|
||||||
|
const saveButton = screen.getByText('Сохранить подключение')
|
||||||
|
fireEvent.click(saveButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Настройки сохранены!')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Проверка подключения', () => {
|
||||||
|
it('должен проверять подключение при клике на кнопку "Проверить"', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
mockPost.mockResolvedValue({ data: { success: true } })
|
||||||
|
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const urlField = await screen.findByLabelText('URL панели')
|
||||||
|
const loginField = screen.getByLabelText('Логин 3x-ui')
|
||||||
|
const passwordField = screen.getByLabelText('Пароль 3x-ui')
|
||||||
|
|
||||||
|
fireEvent.change(urlField, { target: { value: 'https://test.com:2053' } })
|
||||||
|
fireEvent.change(loginField, { target: { value: 'admin' } })
|
||||||
|
fireEvent.change(passwordField, { target: { value: 'password' } })
|
||||||
|
|
||||||
|
const checkButton = screen.getByText('Проверить')
|
||||||
|
fireEvent.click(checkButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/settings/check', expect.objectContaining({
|
||||||
|
xui_url: 'https://test.com:2053',
|
||||||
|
xui_login: 'admin',
|
||||||
|
xui_password: 'password',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен показывать успех при успешной проверке', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
mockPost.mockResolvedValue({ data: { success: true } })
|
||||||
|
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const urlField = await screen.findByLabelText('URL панели')
|
||||||
|
const loginField = screen.getByLabelText('Логин 3x-ui')
|
||||||
|
const passwordField = screen.getByLabelText('Пароль 3x-ui')
|
||||||
|
|
||||||
|
fireEvent.change(urlField, { target: { value: 'https://test.com:2053' } })
|
||||||
|
fireEvent.change(loginField, { target: { value: 'admin' } })
|
||||||
|
fireEvent.change(passwordField, { target: { value: 'password' } })
|
||||||
|
|
||||||
|
const checkButton = screen.getByText('Проверить')
|
||||||
|
fireEvent.click(checkButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Подключение успешно!')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен показывать ошибку при неудачной проверке', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
mockPost.mockResolvedValue({ data: { success: false } })
|
||||||
|
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const urlField = await screen.findByLabelText('URL панели')
|
||||||
|
const loginField = screen.getByLabelText('Логин 3x-ui')
|
||||||
|
const passwordField = screen.getByLabelText('Пароль 3x-ui')
|
||||||
|
|
||||||
|
fireEvent.change(urlField, { target: { value: 'https://test.com:2053' } })
|
||||||
|
fireEvent.change(loginField, { target: { value: 'admin' } })
|
||||||
|
fireEvent.change(passwordField, { target: { value: 'wrong' } })
|
||||||
|
|
||||||
|
const checkButton = screen.getByText('Проверить')
|
||||||
|
fireEvent.click(checkButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Ошибка/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Сохранение интервала', () => {
|
||||||
|
it('должен сохранять интервал при клике на кнопку', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
mockPost.mockResolvedValue({ data: {} })
|
||||||
|
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const intervalField = await screen.findByLabelText('Интервал генерации')
|
||||||
|
fireEvent.change(intervalField, { target: { value: '120' } })
|
||||||
|
|
||||||
|
const saveButton = screen.getByText('Применить интервал')
|
||||||
|
fireEvent.click(saveButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/settings', {
|
||||||
|
rotation_interval: '120',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен показывать сообщение об успехе после сохранения интервала', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
mockPost.mockResolvedValue({ data: {} })
|
||||||
|
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const intervalField = await screen.findByLabelText('Интервал генерации')
|
||||||
|
fireEvent.change(intervalField, { target: { value: '120' } })
|
||||||
|
|
||||||
|
const saveButton = screen.getByText('Применить интервал')
|
||||||
|
fireEvent.click(saveButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Интервал генерации применён!')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Принудительная ротация', () => {
|
||||||
|
it('должен показывать диалог подтверждения при клике на "Сгенерировать сейчас"', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const rotateButton = await screen.findByText('Сгенерировать сейчас')
|
||||||
|
fireEvent.click(rotateButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/ВНИМАНИЕ/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен выполнять ротацию при подтверждении', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
mockPost.mockResolvedValue({ data: { success: true, message: 'Ротация выполнена' } })
|
||||||
|
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const rotateButton = await screen.findByText('Сгенерировать сейчас')
|
||||||
|
fireEvent.click(rotateButton)
|
||||||
|
|
||||||
|
const confirmButton = await screen.findByText('Продолжить')
|
||||||
|
fireEvent.click(confirmButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/rotation/rotate-all')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Управление авторотацией подписок', () => {
|
||||||
|
it('должен отображать список подписок с чекбоксами', async () => {
|
||||||
|
const mockSubs = [
|
||||||
|
{ id: '1', name: 'Test Sub', uuid: 'abc-123', isAutoRotationEnabled: true }
|
||||||
|
]
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url === '/subscriptions') return Promise.resolve({ data: mockSubs })
|
||||||
|
return Promise.resolve({ data: {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Test Sub')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен переключать авторотацию при клике на чекбокс', async () => {
|
||||||
|
const mockSubs = [
|
||||||
|
{ id: '1', name: 'Test Sub', uuid: 'abc-123', isAutoRotationEnabled: true }
|
||||||
|
]
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url === '/subscriptions') return Promise.resolve({ data: mockSubs })
|
||||||
|
return Promise.resolve({ data: {} })
|
||||||
|
})
|
||||||
|
mockPut.mockResolvedValue({ data: {} })
|
||||||
|
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const checkbox = await screen.findByRole('checkbox')
|
||||||
|
fireEvent.click(checkbox)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/subscriptions/bulk-auto-rotation', expect.anything())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен показывать сообщение при включении авторотации', async () => {
|
||||||
|
const mockSubs = [
|
||||||
|
{ id: '1', name: 'Test Sub', uuid: 'abc-123', isAutoRotationEnabled: false }
|
||||||
|
]
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url === '/subscriptions') return Promise.resolve({ data: mockSubs })
|
||||||
|
return Promise.resolve({ data: {} })
|
||||||
|
})
|
||||||
|
mockPut.mockResolvedValue({ data: {} })
|
||||||
|
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const checkbox = await screen.findByRole('checkbox')
|
||||||
|
fireEvent.click(checkbox)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Авторотация включена')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен выполнять ручную ротацию при клике на кнопку обновления', async () => {
|
||||||
|
const mockSubs = [
|
||||||
|
{ id: '1', name: 'Test Sub', uuid: 'abc-123', isAutoRotationEnabled: true }
|
||||||
|
]
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url === '/subscriptions') return Promise.resolve({ data: mockSubs })
|
||||||
|
return Promise.resolve({ data: {} })
|
||||||
|
})
|
||||||
|
mockPost.mockResolvedValue({ data: { message: 'Ротация выполнена' } })
|
||||||
|
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const refreshButton = await screen.findByRole('button', { name: /Обновить подписку вручную/i })
|
||||||
|
fireEvent.click(refreshButton)
|
||||||
|
|
||||||
|
const confirmButton = await screen.findByText('Продолжить')
|
||||||
|
fireEvent.click(confirmButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/rotation/rotate-one/1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен выполнять массовое включение авторотации', async () => {
|
||||||
|
const mockSubs = [
|
||||||
|
{ id: '1', name: 'Test Sub', uuid: 'abc-123', isAutoRotationEnabled: false }
|
||||||
|
]
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url === '/subscriptions') return Promise.resolve({ data: mockSubs })
|
||||||
|
return Promise.resolve({ data: {} })
|
||||||
|
})
|
||||||
|
mockPut.mockResolvedValue({ data: { message: 'Настройки обновлены' } })
|
||||||
|
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const enableAllButton = await screen.findByText('Включить для всех')
|
||||||
|
fireEvent.click(enableAllButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/subscriptions/bulk-auto-rotation', expect.objectContaining({
|
||||||
|
enabled: true,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен выполнять массовое выключение авторотации', async () => {
|
||||||
|
const mockSubs = [
|
||||||
|
{ id: '1', name: 'Test Sub', uuid: 'abc-123', isAutoRotationEnabled: true }
|
||||||
|
]
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url === '/subscriptions') return Promise.resolve({ data: mockSubs })
|
||||||
|
return Promise.resolve({ data: {} })
|
||||||
|
})
|
||||||
|
mockPut.mockResolvedValue({ data: { message: 'Настройки обновлены' } })
|
||||||
|
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const disableAllButton = await screen.findByText('Выключить для всех')
|
||||||
|
fireEvent.click(disableAllButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/subscriptions/bulk-auto-rotation', expect.objectContaining({
|
||||||
|
enabled: false,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Обновление профиля администратора', () => {
|
||||||
|
it('должен позволять изменять логин администратора', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const loginField = await screen.findByLabelText('Логин администратора')
|
||||||
|
fireEvent.change(loginField, { target: { value: 'newadmin' } })
|
||||||
|
|
||||||
|
expect(loginField).toHaveValue('newadmin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен позволять изменять пароль администратора', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const passwordField = await screen.findByLabelText('Новый пароль')
|
||||||
|
fireEvent.change(passwordField, { target: { value: 'newpassword' } })
|
||||||
|
|
||||||
|
expect(passwordField).toHaveValue('newpassword')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен сохранять профиль администратора', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
mockPost.mockResolvedValue({ data: {} })
|
||||||
|
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const loginField = await screen.findByLabelText('Логин администратора')
|
||||||
|
const passwordField = screen.getByLabelText('Новый пароль')
|
||||||
|
|
||||||
|
fireEvent.change(loginField, { target: { value: 'newadmin' } })
|
||||||
|
fireEvent.change(passwordField, { target: { value: 'newpassword' } })
|
||||||
|
|
||||||
|
const saveButton = screen.getByText('Обновить профиль')
|
||||||
|
fireEvent.click(saveButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/auth/update-profile', expect.objectContaining({
|
||||||
|
login: 'newadmin',
|
||||||
|
password: 'newpassword',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен показывать сообщение об успехе после обновления профиля', async () => {
|
||||||
|
setupMockGet()
|
||||||
|
mockPost.mockResolvedValue({ data: {} })
|
||||||
|
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const loginField = await screen.findByLabelText('Логин администратора')
|
||||||
|
const passwordField = screen.getByLabelText('Новый пароль')
|
||||||
|
|
||||||
|
fireEvent.change(loginField, { target: { value: 'newadmin' } })
|
||||||
|
fireEvent.change(passwordField, { target: { value: 'newpassword' } })
|
||||||
|
|
||||||
|
const saveButton = screen.getByText('Обновить профиль')
|
||||||
|
fireEvent.click(saveButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Профиль администратора обновлен!')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Пауза/возобновление ротации', () => {
|
||||||
|
it('должен отображать статус "Активен" при active статусе', async () => {
|
||||||
|
setupMockGet({ settings: { rotation_status: 'active' } })
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Активен')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать статус "Остановлен" при stopped статусе', async () => {
|
||||||
|
setupMockGet({ settings: { rotation_status: 'stopped' } })
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Остановлен')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен переключать статус при клике на кнопку паузы', async () => {
|
||||||
|
setupMockGet({ settings: { rotation_status: 'active' } })
|
||||||
|
mockPost.mockResolvedValue({ data: {} })
|
||||||
|
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
const pauseButton = await screen.findByRole('button', { name: 'Поставить на паузу' })
|
||||||
|
fireEvent.click(pauseButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/settings', expect.objectContaining({
|
||||||
|
rotation_status: 'stopped',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Отображение дат ротации', () => {
|
||||||
|
it('должен отображать дату последней ротации', async () => {
|
||||||
|
setupMockGet({ settings: { last_rotation_timestamp: '1234567890' } })
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Последняя генерация')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать дату следующей ротации', async () => {
|
||||||
|
setupMockGet({ settings: {
|
||||||
|
rotation_status: 'active',
|
||||||
|
last_rotation_timestamp: '1234567890',
|
||||||
|
rotation_interval: '60'
|
||||||
|
} })
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Следующая генерация')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать "Пауза" для следующей ротации при stopped статусе', async () => {
|
||||||
|
setupMockGet({ settings: { rotation_status: 'stopped' } })
|
||||||
|
renderSettingsPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Пауза')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import TunnelsPage from '../../src/pages/TunnelsPage'
|
||||||
|
import api from '../../src/api'
|
||||||
|
|
||||||
|
vi.mock('../../src/api')
|
||||||
|
|
||||||
|
describe('TunnelsPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(api.get).mockResolvedValue({ data: [] })
|
||||||
|
vi.mocked(api.post).mockResolvedValue({ data: {} })
|
||||||
|
vi.mocked(api.delete).mockResolvedValue({ data: {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderPage = async () => {
|
||||||
|
const result = render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<TunnelsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
// Ждём завершения первоначальной загрузки данных
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Relay серверы')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
it('должен рендериться с заголовком', async () => {
|
||||||
|
await renderPage()
|
||||||
|
expect(screen.getByText('Relay серверы')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать кнопку "Добавить"', async () => {
|
||||||
|
await renderPage()
|
||||||
|
expect(screen.getByText('Добавить')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен открывать диалог создания при клике на "Добавить"', async () => {
|
||||||
|
await renderPage()
|
||||||
|
const addButton = screen.getByText('Добавить')
|
||||||
|
fireEvent.click(addButton)
|
||||||
|
|
||||||
|
expect(screen.getByText('Новый редирект сервер')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен закрывать диалог создания при клике на отмену', async () => {
|
||||||
|
await renderPage()
|
||||||
|
const addButton = screen.getByText('Добавить')
|
||||||
|
fireEvent.click(addButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Новый редирект сервер')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const cancelButton = screen.getByText('Отмена')
|
||||||
|
fireEvent.click(cancelButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Новый редирект сервер')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать поля формы в диалоге создания', async () => {
|
||||||
|
await renderPage()
|
||||||
|
const addButton = screen.getByText('Добавить')
|
||||||
|
fireEvent.click(addButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText('Название')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.getByLabelText('IP адрес')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('SSH Порт')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('SSH User')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен переключать режим аутентификации между паролем и ключом', async () => {
|
||||||
|
await renderPage()
|
||||||
|
const addButton = screen.getByText('Добавить')
|
||||||
|
fireEvent.click(addButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText('SSH Пароль')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const keyRadio = screen.getByLabelText('По SSH ключу')
|
||||||
|
fireEvent.click(keyRadio)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/SSH Private Key/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен показывать сообщение "Нет серверов" при пустом списке', async () => {
|
||||||
|
await renderPage()
|
||||||
|
expect(screen.getByText('Нет серверов')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать список туннелей', async () => {
|
||||||
|
vi.mocked(api.get).mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{ id: 1, name: 'Test Tunnel', ip: '192.168.1.1', sshPort: 22, username: 'root', isInstalled: true }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Test Tunnel')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.getByText('192.168.1.1')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать статус "Активен" для установленного туннеля', async () => {
|
||||||
|
vi.mocked(api.get).mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{ id: 1, name: 'Active Tunnel', ip: '192.168.1.1', sshPort: 22, username: 'root', isInstalled: true }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Активен')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать статус "Не настроен" для неустановленного туннеля', async () => {
|
||||||
|
vi.mocked(api.get).mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{ id: 1, name: 'Inactive Tunnel', ip: '192.168.1.1', sshPort: 22, username: 'root', isInstalled: false }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Не настроен')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен отображать кнопку "Установить" для неустановленного туннеля', async () => {
|
||||||
|
vi.mocked(api.get).mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{ id: 1, name: 'Tunnel', ip: '192.168.1.1', sshPort: 22, username: 'root', isInstalled: false }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Установить')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен открывать диалог подтверждения при удалении туннеля', async () => {
|
||||||
|
vi.mocked(api.get).mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{ id: 1, name: 'Tunnel', ip: '192.168.1.1', sshPort: 22, username: 'root', isInstalled: true }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Tunnel')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteButton = screen.getAllByTestId('icon-Delete')[0]
|
||||||
|
fireEvent.click(deleteButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Удалить сервер из списка?')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен закрывать диалог подтверждения при клике на "Отмена"', async () => {
|
||||||
|
vi.mocked(api.get).mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{ id: 1, name: 'Tunnel', ip: '192.168.1.1', sshPort: 22, username: 'root', isInstalled: true }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Tunnel')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteButton = screen.getAllByTestId('icon-Delete')[0]
|
||||||
|
fireEvent.click(deleteButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Удалить сервер из списка?')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const cancelButton = screen.getByText('Отмена')
|
||||||
|
fireEvent.click(cancelButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Удалить сервер из списка?')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import '@testing-library/jest-dom'
|
||||||
|
import { cleanup } from '@testing-library/react'
|
||||||
|
import { afterEach, vi } from 'vitest'
|
||||||
|
import './mocks'
|
||||||
|
|
||||||
|
// Очищать DOM после каждого теста
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Подавляем шумные console.log/console.error во время тестов
|
||||||
|
// Оставляем только важные ошибки через test.skip()
|
||||||
|
const originalConsoleLog = console.log
|
||||||
|
const originalConsoleError = console.error
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// Фильтруем шумные логи от приложений
|
||||||
|
console.log = (...args) => {
|
||||||
|
const message = args.join(' ')
|
||||||
|
// Пропускаем логи от компонентов которые шумят
|
||||||
|
if (
|
||||||
|
message.includes('[Tunnels]') ||
|
||||||
|
message.includes('[Settings]') ||
|
||||||
|
message.includes('[API]') ||
|
||||||
|
message.includes('[Login]') ||
|
||||||
|
message.includes('[Rotation]')
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
originalConsoleLog(...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подавляем console.error для известных предупреждений React
|
||||||
|
console.error = (...args) => {
|
||||||
|
const message = args.join(' ')
|
||||||
|
// Пропускаем предупреждения act(...) - они не критичны
|
||||||
|
if (
|
||||||
|
message.includes('act(...)') ||
|
||||||
|
message.includes('An update to')
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
originalConsoleError(...args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
// Восстанавливаем console после всех тестов
|
||||||
|
console.log = originalConsoleLog
|
||||||
|
console.error = originalConsoleError
|
||||||
|
})
|
||||||
|
|
||||||
|
// Мок для MUI icons-material - используем vi.mock с factory
|
||||||
|
vi.mock('@mui/icons-material', async () => {
|
||||||
|
const React = await import('react')
|
||||||
|
|
||||||
|
const createIconMock = (name: string) => {
|
||||||
|
const IconMock = (props: Record<string, unknown>) => {
|
||||||
|
return React.createElement('span', {
|
||||||
|
'data-testid': `icon-${name}`,
|
||||||
|
...props
|
||||||
|
}, name)
|
||||||
|
}
|
||||||
|
IconMock.displayName = name
|
||||||
|
return IconMock
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем мок для всех иконок
|
||||||
|
const mock: Record<string, unknown> = {}
|
||||||
|
const icons = [
|
||||||
|
'GitHub', 'YouTube', 'Telegram', 'Brightness7', 'Brightness4', 'BrightnessAuto',
|
||||||
|
'Logout', 'HelpOutline', 'Menu', 'People', 'Settings', 'Dns', 'SwapHoriz',
|
||||||
|
'Delete', 'Add', 'Terminal', 'CheckCircle', 'Error', 'LinkIcon', 'OpenInNew',
|
||||||
|
'ContentCopy', 'Router', 'Edit', 'MoreVert', 'Remove', 'Refresh', 'Search',
|
||||||
|
'FilterList', 'Warning', 'Info', 'Close', 'Check', 'ArrowDownward', 'ArrowUpward',
|
||||||
|
'MoreHoriz', 'ContentPaste', 'QrCode', 'Usb', 'VpnKey', 'Security', 'Speed',
|
||||||
|
'Timeline', 'Assessment', 'SettingsApplications', 'CloudDownload', 'CloudUpload',
|
||||||
|
'Folder', 'FileCopy', 'Save', 'Print', 'DeleteOutline', 'Restore', 'History',
|
||||||
|
'Schedule', 'AccessTime', 'Today', 'Event', 'Notifications', 'AccountCircle',
|
||||||
|
'Person', 'Group', 'Public', 'Language', 'Translate', 'Star', 'Favorite',
|
||||||
|
'Home', 'LocationOn', 'Place', 'Email', 'Phone', 'Chat', 'Message', 'Forum',
|
||||||
|
'Share', 'Send', 'Inbox', 'Drafts', 'Mail', 'Markunread', 'Lock', 'LockOpen',
|
||||||
|
'Unlock', 'Visibility', 'VisibilityOff', 'ToggleOn', 'ToggleOff',
|
||||||
|
'RadioButtonChecked', 'RadioButtonUnchecked', 'CheckBox', 'CheckBoxOutlineBlank',
|
||||||
|
'PlusOne', 'ThumbUp', 'ThumbDown', 'Whatshot', 'FavoriteBorder', 'StarBorder',
|
||||||
|
'Bookmark', 'BookmarkBorder', 'Bookmarks', 'TurnedIn', 'TurnedInNot', 'Label',
|
||||||
|
'LabelImportant', 'Grade', 'Done', 'Clear', 'Block', 'Stop', 'Pause', 'PlayArrow',
|
||||||
|
'FastForward', 'FastRewind', 'SkipNext', 'SkipPrevious', 'FiberManualRecord',
|
||||||
|
'Circle', 'NavigateNext', 'NavigateBefore', 'ChevronRight', 'ChevronLeft',
|
||||||
|
'ExpandMore', 'ExpandLess', 'UnfoldMore', 'UnfoldLess', 'ArrowRight', 'ArrowLeft',
|
||||||
|
'ArrowBack', 'ArrowForward', 'ArrowDropDown', 'ArrowDropUp', 'Expand',
|
||||||
|
'FileDownload', 'FileUpload', 'Attachment', 'Link', 'InsertLink', 'Photo',
|
||||||
|
'Image', 'PictureAsPdf', 'ImageIcon', 'CameraAlt', 'Videocam', 'Movie',
|
||||||
|
'MusicNote', 'Mic', 'VolumeUp', 'VolumeOff', 'Headset', 'Headphones', 'Speaker',
|
||||||
|
'Radio', 'Podcasts', 'Tv', 'DesktopWindows', 'Laptop', 'Computer', 'Tablet',
|
||||||
|
'Smartphone', 'PhoneIphone', 'PhoneAndroid', 'Devices', 'SmartDisplay', 'Monitor',
|
||||||
|
'ScreenShare', 'StopScreenShare', 'PresentToAll', 'Cast', 'CastConnected',
|
||||||
|
'Wifi', 'WifiOff', 'NetworkWifi', 'NetworkCell', 'SignalCellular4Bar',
|
||||||
|
'SignalWifi4Bar', 'Bluetooth', 'BluetoothConnected', 'BluetoothDisabled',
|
||||||
|
'GpsFixed', 'GpsNotFixed', 'LocationSearching', 'MyLocation', 'Navigation',
|
||||||
|
'NearMe', 'Directions', 'DirectionsCar', 'DirectionsBus', 'DirectionsTrain',
|
||||||
|
'DirectionsBike', 'DirectionsWalk', 'DirectionsRun', 'Flight', 'LocalAirport',
|
||||||
|
'Hotel', 'Restaurant', 'LocalCafe', 'LocalBar', 'LocalPizza', 'BrunchDining',
|
||||||
|
'DinnerDining', 'LunchDining', 'Nightlife', 'LocalHospital', 'LocalPharmacy',
|
||||||
|
'ShoppingBag', 'ShoppingCart', 'ShoppingBasket', 'Store', 'Shop', 'Storefront',
|
||||||
|
'LocalMall', 'AccountBalance', 'Business', 'CorporateFare', 'Work', 'MeetingRoom',
|
||||||
|
'Gite', 'House', 'Cottage', 'Apartment', 'Villa', 'OtherHouses', 'Foundation',
|
||||||
|
'Fence', 'Yard', 'Pool', 'HotTub', 'Spa', 'FitnessCenter', 'SportsGymnasium',
|
||||||
|
'SportsBasketball', 'SportsFootball', 'SportsSoccer', 'SportsTennis',
|
||||||
|
'SportsVolleyball', 'SportsBaseball', 'SportsCricket', 'SportsGolf', 'SportsHockey',
|
||||||
|
'SportsMma', 'SportsMotorsports', 'SportsRugby', 'SportsScore', 'SportsHandball',
|
||||||
|
'SportsKabaddi', 'Rowing', 'Surfing', 'Kitesurfing', 'Snowboarding',
|
||||||
|
'DownhillSkiing', 'Snowshoeing', 'IceSkating', 'Curling', 'Sailing', 'Kayaking',
|
||||||
|
'Rafting', 'ScubaDiving', 'Diving', 'Fishing', 'Hiking', 'RunningWithErrors',
|
||||||
|
'PlayCircleFilled', 'PauseCircleFilled', 'RefreshTwoTone', 'SubdirectoryArrowRight',
|
||||||
|
'SubdirectoryArrowLeft', 'SettingsInputComponent', 'SettingsInputComponentOutlined',
|
||||||
|
'Dns',
|
||||||
|
]
|
||||||
|
|
||||||
|
icons.forEach(name => {
|
||||||
|
mock[name] = createIconMock(name)
|
||||||
|
})
|
||||||
|
|
||||||
|
mock.default = createIconMock('DefaultIcon')
|
||||||
|
return mock
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
import { RenderOptions, render } from '@testing-library/react'
|
||||||
|
import { ReactElement, ReactNode } from 'react'
|
||||||
|
import { ThemeProvider } from '../src/ThemeContext'
|
||||||
|
import { AuthProvider } from '../src/auth/AuthContext'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
|
||||||
|
interface AllProvidersProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function AllProviders({ children }: AllProvidersProps) {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<ThemeProvider>{children}</ThemeProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||||
|
wrapper?: ReactElement
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customRender(
|
||||||
|
ui: ReactElement,
|
||||||
|
options?: CustomRenderOptions
|
||||||
|
) {
|
||||||
|
return render(ui, {
|
||||||
|
wrapper: AllProviders,
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переэкспортируем всё из @testing-library/react
|
||||||
|
export * from '@testing-library/react'
|
||||||
|
|
||||||
|
// Переопределяем render с нашими провайдерами
|
||||||
|
export { customRender as render }
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { getDesignTokens } from '@/theme'
|
||||||
|
|
||||||
|
describe('theme', () => {
|
||||||
|
describe('getDesignTokens', () => {
|
||||||
|
it('должен возвращать объект с palette, typography, shape и components для light mode', () => {
|
||||||
|
const tokens = getDesignTokens('light')
|
||||||
|
|
||||||
|
expect(tokens).toHaveProperty('palette')
|
||||||
|
expect(tokens).toHaveProperty('typography')
|
||||||
|
expect(tokens).toHaveProperty('shape')
|
||||||
|
expect(tokens).toHaveProperty('components')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен возвращать объект с palette, typography, shape и components для dark mode', () => {
|
||||||
|
const tokens = getDesignTokens('dark')
|
||||||
|
|
||||||
|
expect(tokens).toHaveProperty('palette')
|
||||||
|
expect(tokens).toHaveProperty('typography')
|
||||||
|
expect(tokens).toHaveProperty('shape')
|
||||||
|
expect(tokens).toHaveProperty('components')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен устанавливать правильный mode в palette', () => {
|
||||||
|
const lightTokens = getDesignTokens('light')
|
||||||
|
const darkTokens = getDesignTokens('dark')
|
||||||
|
|
||||||
|
expect(lightTokens.palette.mode).toBe('light')
|
||||||
|
expect(darkTokens.palette.mode).toBe('dark')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен использовать lightPalette для light mode', () => {
|
||||||
|
const tokens = getDesignTokens('light')
|
||||||
|
|
||||||
|
expect(tokens.palette.primary.main).toBe('#1395de')
|
||||||
|
expect(tokens.palette.background.default).toBe('#f3f4f6')
|
||||||
|
expect(tokens.palette.background.paper).toBe('#ffffff')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен использовать darkPalette для dark mode', () => {
|
||||||
|
const tokens = getDesignTokens('dark')
|
||||||
|
|
||||||
|
expect(tokens.palette.primary.main).toBe('#1395de')
|
||||||
|
expect(tokens.palette.background.default).toBe('#0B0F19')
|
||||||
|
expect(tokens.palette.background.paper).toBe('#111827')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен устанавливать fontFamily', () => {
|
||||||
|
const tokens = getDesignTokens('light')
|
||||||
|
|
||||||
|
expect(tokens.typography.fontFamily).toBe('"Inter", "Roboto", "Helvetica", "Arial", sans-serif')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен устанавливать fontWeight для заголовков', () => {
|
||||||
|
const tokens = getDesignTokens('light')
|
||||||
|
|
||||||
|
expect(tokens.typography.h1.fontWeight).toBe(700)
|
||||||
|
expect(tokens.typography.h2.fontWeight).toBe(700)
|
||||||
|
expect(tokens.typography.h3.fontWeight).toBe(600)
|
||||||
|
expect(tokens.typography.h4.fontWeight).toBe(600)
|
||||||
|
expect(tokens.typography.h5.fontWeight).toBe(600)
|
||||||
|
expect(tokens.typography.h6.fontWeight).toBe(600)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен устанавливать textTransform none для кнопок', () => {
|
||||||
|
const tokens = getDesignTokens('light')
|
||||||
|
|
||||||
|
expect(tokens.typography.button.textTransform).toBe('none')
|
||||||
|
expect(tokens.typography.button.fontWeight).toBe(600)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен устанавливать borderRadius 12', () => {
|
||||||
|
const tokens = getDesignTokens('light')
|
||||||
|
|
||||||
|
expect(tokens.shape.borderRadius).toBe(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен настраивать MuiCssBaseline для кастомных скроллбаров', () => {
|
||||||
|
const lightTokens = getDesignTokens('light')
|
||||||
|
const darkTokens = getDesignTokens('dark')
|
||||||
|
|
||||||
|
expect(lightTokens.components.MuiCssBaseline).toBeDefined()
|
||||||
|
expect(darkTokens.components.MuiCssBaseline).toBeDefined()
|
||||||
|
|
||||||
|
// Проверяем что styleOverrides существуют
|
||||||
|
expect(lightTokens.components.MuiCssBaseline.styleOverrides).toBeDefined()
|
||||||
|
expect(darkTokens.components.MuiCssBaseline.styleOverrides).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен настраивать MuiButton с borderRadius 8', () => {
|
||||||
|
const tokens = getDesignTokens('light')
|
||||||
|
|
||||||
|
expect(tokens.components.MuiButton.styleOverrides.root.borderRadius).toBe(8)
|
||||||
|
expect(tokens.components.MuiButton.styleOverrides.root.boxShadow).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен настраивать MuiPaper без backgroundImage', () => {
|
||||||
|
const tokens = getDesignTokens('light')
|
||||||
|
|
||||||
|
expect(tokens.components.MuiPaper.styleOverrides.root.backgroundImage).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен настраивать MuiPaper с border в зависимости от mode', () => {
|
||||||
|
const lightTokens = getDesignTokens('light')
|
||||||
|
const darkTokens = getDesignTokens('dark')
|
||||||
|
|
||||||
|
expect(lightTokens.components.MuiPaper.styleOverrides.elevation1.border).toBe('1px solid #e5e7eb')
|
||||||
|
expect(darkTokens.components.MuiPaper.styleOverrides.elevation1.border).toBe('1px solid #374151')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен настраивать MuiOutlinedInput с правильными borderColor', () => {
|
||||||
|
const lightTokens = getDesignTokens('light')
|
||||||
|
const darkTokens = getDesignTokens('dark')
|
||||||
|
|
||||||
|
expect(lightTokens.components.MuiOutlinedInput.styleOverrides.root['& .MuiOutlinedInput-notchedOutline'].borderColor).toBe('#e5e7eb')
|
||||||
|
expect(darkTokens.components.MuiOutlinedInput.styleOverrides.root['& .MuiOutlinedInput-notchedOutline'].borderColor).toBe('#374151')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен настраивать MuiAppBar с backdropFilter', () => {
|
||||||
|
const tokens = getDesignTokens('light')
|
||||||
|
|
||||||
|
expect(tokens.components.MuiAppBar.styleOverrides.root.backdropFilter).toBe('blur(8px)')
|
||||||
|
expect(tokens.components.MuiAppBar.styleOverrides.root.boxShadow).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен настраивать MuiTableRow без border у последнего элемента', () => {
|
||||||
|
const tokens = getDesignTokens('light')
|
||||||
|
|
||||||
|
expect(tokens.components.MuiTableRow.styleOverrides.root['&:last-child td'].borderBottom).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
isApiError,
|
||||||
|
getApiErrorMessage,
|
||||||
|
getApiErrorStatus,
|
||||||
|
} from '@/utils/errorHandlers'
|
||||||
|
|
||||||
|
describe('errorHandlers', () => {
|
||||||
|
describe('isApiError', () => {
|
||||||
|
it('должен возвращать true для API ошибки с response', () => {
|
||||||
|
const error = { response: { status: 400, data: { message: 'Error' } } }
|
||||||
|
expect(isApiError(error)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен возвращать true для API ошибки без response.data', () => {
|
||||||
|
const error = { response: {} }
|
||||||
|
expect(isApiError(error)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен возвращать false для null', () => {
|
||||||
|
expect(isApiError(null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен возвращать false для строки', () => {
|
||||||
|
expect(isApiError('error')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен возвращать false для объекта без response', () => {
|
||||||
|
expect(isApiError({ message: 'Error' })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getApiErrorMessage', () => {
|
||||||
|
it('должен извлекать строковое сообщение из ошибки', () => {
|
||||||
|
const error = { response: { data: { message: 'Custom error' } } }
|
||||||
|
expect(getApiErrorMessage(error)).toBe('Custom error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен извлекать массив сообщений и объединять через точку с запятой', () => {
|
||||||
|
const error = { response: { data: { message: ['Error 1', 'Error 2'] } } }
|
||||||
|
expect(getApiErrorMessage(error)).toBe('Error 1; Error 2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен возвращать сообщение по умолчанию для обычной ошибки', () => {
|
||||||
|
expect(getApiErrorMessage('string error')).toBe('Произошла ошибка')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен извлекать message из Error объекта', () => {
|
||||||
|
const error = new Error('Native error')
|
||||||
|
expect(getApiErrorMessage(error)).toBe('Native error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен использовать кастомное сообщение по умолчанию', () => {
|
||||||
|
expect(getApiErrorMessage(null, 'Custom default')).toBe('Custom default')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен возвращать undefined message как сообщение по умолчанию', () => {
|
||||||
|
const error = { response: { data: { message: undefined } } }
|
||||||
|
expect(getApiErrorMessage(error)).toBe('Произошла ошибка')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getApiErrorStatus', () => {
|
||||||
|
it('должен извлекать status код из ошибки', () => {
|
||||||
|
const error = { response: { status: 404 } }
|
||||||
|
expect(getApiErrorStatus(error)).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен возвращать undefined для ошибки без status', () => {
|
||||||
|
const error = { response: {} }
|
||||||
|
expect(getApiErrorStatus(error)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен возвращать undefined для не API ошибки', () => {
|
||||||
|
expect(getApiErrorStatus('error')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен возвращать undefined для null', () => {
|
||||||
|
expect(getApiErrorStatus(null)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { Logger } from '@/utils/logger'
|
||||||
|
|
||||||
|
describe('Logger', () => {
|
||||||
|
describe('Logger.error', () => {
|
||||||
|
it('должен вызывать console.error с правильным форматом', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
Logger.error('Test error', 'TestModule')
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test error', '')
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен вызывать console.error с данными', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
const data = { code: 500, message: 'Internal error' }
|
||||||
|
Logger.error('Test error', 'TestModule', data)
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test error', data)
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен использовать модуль по умолчанию "App"', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
Logger.error('Test error')
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('[App] Test error', '')
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Logger.warn', () => {
|
||||||
|
it('должен вызывать console.warn с правильным форматом', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
Logger.warn('Test warning', 'TestModule')
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test warning', '')
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен вызывать console.warn с данными', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
const data = { code: 400, field: 'email' }
|
||||||
|
Logger.warn('Test warning', 'TestModule', data)
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test warning', data)
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Logger.info', () => {
|
||||||
|
it('должен вызывать console.info с правильным форматом', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||||
|
Logger.info('Test info', 'TestModule')
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test info', '')
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен вызывать console.info с данными', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||||
|
const data = { user: 'admin', action: 'login' }
|
||||||
|
Logger.info('Test info', 'TestModule', data)
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test info', data)
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Logger.debug', () => {
|
||||||
|
it('должен вызывать console.log с правильным форматом', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
Logger.debug('Test debug', 'TestModule')
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test debug', '')
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен вызывать console.log с данными', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
const data = { state: 'loading', progress: 50 }
|
||||||
|
Logger.debug('Test debug', 'TestModule', data)
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test debug', data)
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Logger.verbose', () => {
|
||||||
|
it('должен вызывать console.log с правильным форматом', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
Logger.verbose('Test verbose', 'TestModule')
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test verbose', '')
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен вызывать console.log с данными', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
const data = { detailed: 'trace', step: 3 }
|
||||||
|
Logger.verbose('Test verbose', 'TestModule', data)
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test verbose', data)
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatMessage', () => {
|
||||||
|
it('должен форматировать сообщение без данных', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||||
|
Logger.info('Simple message', 'Module')
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('[Module] Simple message', '')
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен форматировать сообщение с объектом данных', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||||
|
Logger.info('Message with data', 'Module', { key: 'value' })
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('[Module] Message with data', { key: 'value' })
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен форматировать сообщение с массивом данных', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||||
|
Logger.info('Message with array', 'Module', [1, 2, 3])
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('[Module] Message with array', [1, 2, 3])
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { APP_VERSION } from '@/utils/version'
|
||||||
|
|
||||||
|
describe('version', () => {
|
||||||
|
describe('APP_VERSION', () => {
|
||||||
|
it('должен быть строкой', () => {
|
||||||
|
expect(typeof APP_VERSION).toBe('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен соответствовать формату семантического версионирования', () => {
|
||||||
|
const semverRegex = /^\d+\.\d+\.\d+$/
|
||||||
|
expect(APP_VERSION).toMatch(semverRegex)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен быть непустой строкой', () => {
|
||||||
|
expect(APP_VERSION.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
+13
-1
@@ -2,5 +2,17 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()]
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './test/setup.ts',
|
||||||
|
pool: 'forks',
|
||||||
|
poolOptions: {
|
||||||
|
forks: {
|
||||||
|
maxForks: 4,
|
||||||
|
minForks: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 8080,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3100',
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/bus': {
|
||||||
|
target: 'http://localhost:3100',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './test/setup.ts',
|
||||||
|
css: true,
|
||||||
|
singleThread: true,
|
||||||
|
env: {
|
||||||
|
VITE_LOG_LEVEL: 'verbose',
|
||||||
|
},
|
||||||
|
coverage: {
|
||||||
|
enabled: true,
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html', 'lcov'],
|
||||||
|
reportsDirectory: './coverage',
|
||||||
|
include: ['src/**/*.{ts,tsx}'],
|
||||||
|
exclude: [
|
||||||
|
'src/main.tsx',
|
||||||
|
'src/**/*.d.ts',
|
||||||
|
'src/types/**',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user