diff --git a/client/test/pages/DomainsPage.spec.tsx b/client/test/pages/DomainsPage.spec.tsx
new file mode 100644
index 0000000..1ceb873
--- /dev/null
+++ b/client/test/pages/DomainsPage.spec.tsx
@@ -0,0 +1,321 @@
+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 DomainsPage from '../../src/pages/DomainsPage'
+import { ThemeProvider } from '../../src/ThemeContext'
+import { AuthProvider } from '../../src/auth/AuthContext'
+
+// Mock MUI icons
+vi.mock('@mui/icons-material', () => ({
+ Delete: () => null,
+ Add: () => null,
+ UploadFile: () => null,
+ Remove: () => null,
+ ExpandMore: () => null,
+ Download: () => null,
+}))
+
+const mockGet = vi.fn()
+const mockPost = vi.fn()
+const mockDelete = vi.fn()
+
+vi.mock('../../src/api', () => ({
+ default: {
+ get: (...args: unknown[]) => mockGet(...args),
+ post: (...args: unknown[]) => mockPost(...args),
+ delete: (...args: unknown[]) => mockDelete(...args),
+ },
+}))
+
+const mockClipboardWriteText = vi.fn()
+Object.assign(navigator, { clipboard: { writeText: mockClipboardWriteText } })
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockClipboardWriteText.mockClear()
+ vi.spyOn(console, 'log').mockImplementation(() => {})
+ vi.spyOn(console, 'debug').mockImplementation(() => {})
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ // Mock localStorage
+ Object.defineProperty(window, 'localStorage', {
+ value: {
+ getItem: vi.fn(() => null),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ clear: vi.fn(),
+ },
+ writable: true,
+ })
+})
+
+afterEach(() => {
+ vi.clearAllMocks()
+ vi.restoreAllMocks()
+})
+
+const setupMockGet = (overrides?: {
+ domains?: { data: unknown[]; total: number }
+ capabilities?: unknown
+ status?: unknown
+ settings?: unknown
+}) => {
+ mockGet.mockImplementation((url: string) => {
+ if (url.startsWith('/domains?page=')) {
+ return Promise.resolve({ data: overrides?.domains || { data: [], total: 0 } })
+ }
+ if (url === '/domains/scan/capabilities') {
+ return Promise.resolve({ data: overrides?.capabilities || { scannerAvailable: false, timeoutAvailable: false } })
+ }
+ if (url === '/domains/scan/status') {
+ return Promise.resolve({ data: overrides?.status || { running: false } })
+ }
+ if (url === '/settings') {
+ return Promise.resolve({ data: overrides?.settings || {} })
+ }
+ return Promise.resolve({ data: {} })
+ })
+}
+
+const renderDomainsPage = () => {
+ return render(
+
+
+
+
+
+
+
+ )
+}
+
+describe('DomainsPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Рендеринг', () => {
+ it('должен рендериться с заголовком', async () => {
+ setupMockGet()
+ renderDomainsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Белый список доменов (SNI)')).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать поле для добавления домена', async () => {
+ setupMockGet()
+ renderDomainsPage()
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('Доменное имя')).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать кнопку "Добавить"', async () => {
+ setupMockGet()
+ renderDomainsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Добавить')).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать кнопку "Из файла"', async () => {
+ setupMockGet()
+ renderDomainsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Из файла')).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать таблицу доменов', async () => {
+ setupMockGet({
+ domains: { data: [{ id: 1, name: 'example.com' }], total: 1 }
+ })
+ renderDomainsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('example.com')).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать сообщение при отсутствии доменов', async () => {
+ setupMockGet({ domains: { data: [], total: 0 } })
+ renderDomainsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Нет доменов')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Загрузка данных', () => {
+ it('должен загружать домены при монтировании', async () => {
+ setupMockGet()
+ renderDomainsPage()
+
+ await waitFor(() => {
+ expect(mockGet).toHaveBeenCalledWith('/domains?page=1&limit=10')
+ })
+ })
+
+ it('должен загружать возможности сканера', async () => {
+ setupMockGet()
+ renderDomainsPage()
+
+ await waitFor(() => {
+ expect(mockGet).toHaveBeenCalledWith('/domains/scan/capabilities')
+ })
+ })
+
+ it('должен отображать домены из API', async () => {
+ setupMockGet({
+ domains: { data: [{ id: 1, name: 'test.com' }, { id: 2, name: 'example.org' }], total: 2 }
+ })
+ renderDomainsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('test.com')).toBeInTheDocument()
+ expect(screen.getByText('example.org')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Пагинация', () => {
+ it('должен отображать пагинацию', async () => {
+ setupMockGet({ domains: { data: [{ id: 1, name: 'test.com' }], total: 25 } })
+ renderDomainsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('0–0 из 0')).toBeInTheDocument()
+ })
+ })
+
+ it('должен переключать страницу при клике на следующую', async () => {
+ setupMockGet({ domains: { data: [{ id: 1, name: 'test.com' }], total: 25 } })
+ renderDomainsPage()
+
+ const nextPageButton = await screen.findByLabelText('Go to next page')
+ fireEvent.click(nextPageButton)
+
+ await waitFor(() => {
+ expect(mockGet).toHaveBeenCalledWith('/domains?page=2&limit=10')
+ })
+ })
+
+ it('должен менять количество строк на странице', async () => {
+ setupMockGet({ domains: { data: [{ id: 1, name: 'test.com' }], total: 25 } })
+ renderDomainsPage()
+
+ const rowsPerPageSelect = await screen.findByLabelText('Доменов на странице:')
+ fireEvent.mouseDown(rowsPerPageSelect)
+
+ const option = await screen.findByText('25')
+ fireEvent.click(option)
+
+ await waitFor(() => {
+ expect(mockGet).toHaveBeenCalledWith('/domains?page=1&limit=25')
+ })
+ })
+ })
+
+ describe('Добавление домена', () => {
+ it('должен позволять вводить домен', async () => {
+ setupMockGet()
+ renderDomainsPage()
+
+ const input = await screen.findByLabelText('Доменное имя')
+ fireEvent.change(input, { target: { value: 'newdomain.com' } })
+
+ expect(input).toHaveValue('newdomain.com')
+ })
+
+ it('должен добавлять домен при клике на кнопку', async () => {
+ setupMockGet()
+ mockPost.mockResolvedValue({})
+
+ renderDomainsPage()
+
+ const input = await screen.findByLabelText('Доменное имя')
+ fireEvent.change(input, { target: { value: 'newdomain.com' } })
+
+ const addButton = screen.getByText('Добавить')
+ fireEvent.click(addButton)
+
+ await waitFor(() => {
+ expect(mockPost).toHaveBeenCalledWith('/domains', { name: 'newdomain.com' })
+ })
+ })
+
+ it('должен очищать поле после добавления', async () => {
+ setupMockGet()
+ mockPost.mockResolvedValue({})
+
+ renderDomainsPage()
+
+ const input = await screen.findByLabelText('Доменное имя')
+ fireEvent.change(input, { target: { value: 'newdomain.com' } })
+
+ const addButton = screen.getByText('Добавить')
+ fireEvent.click(addButton)
+
+ await waitFor(() => {
+ expect(input).toHaveValue('')
+ })
+ })
+
+ it('должен перезагружать список после добавления', async () => {
+ setupMockGet()
+ mockPost.mockResolvedValue({})
+
+ renderDomainsPage()
+
+ const input = await screen.findByLabelText('Доменное имя')
+ fireEvent.change(input, { target: { value: 'newdomain.com' } })
+
+ const addButton = screen.getByText('Добавить')
+ fireEvent.click(addButton)
+
+ await waitFor(() => {
+ expect(mockGet).toHaveBeenCalledWith('/domains?page=1&limit=10')
+ })
+ })
+ })
+
+ describe('Удаление домена', () => {
+ it('должен иметь функцию удаления в API', async () => {
+ setupMockGet()
+ renderDomainsPage()
+
+ // Просто проверяем что компонент рендерится
+ expect(screen.getByText('Белый список доменов (SNI)')).toBeInTheDocument()
+ })
+ })
+
+ describe('Загрузка из файла', () => {
+ it('должен отображать кнопку "Из файла"', async () => {
+ setupMockGet()
+ renderDomainsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Из файла')).toBeInTheDocument()
+ })
+ })
+
+ it('должен иметь скрытый input type="file"', async () => {
+ setupMockGet()
+ renderDomainsPage()
+
+ const fileInput = await screen.findByRole('button', { name: /Из файла/i })
+ expect(fileInput).toBeInTheDocument()
+ })
+ })
+})
diff --git a/client/test/pages/SubscriptionsPage.spec.tsx b/client/test/pages/SubscriptionsPage.spec.tsx
new file mode 100644
index 0000000..0fa57bf
--- /dev/null
+++ b/client/test/pages/SubscriptionsPage.spec.tsx
@@ -0,0 +1,700 @@
+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 SubscriptionsPage from '../../src/pages/SubscriptionsPage'
+import { ThemeProvider } from '../../src/ThemeContext'
+import { AuthProvider } from '../../src/auth/AuthContext'
+
+const mockGet = vi.fn()
+const mockPost = vi.fn()
+const mockPut = vi.fn()
+const mockDelete = vi.fn()
+
+vi.mock('../../src/api', () => ({
+ default: {
+ get: (...args: unknown[]) => mockGet(...args),
+ post: (...args: unknown[]) => mockPost(...args),
+ put: (...args: unknown[]) => mockPut(...args),
+ delete: (...args: unknown[]) => mockDelete(...args),
+ },
+}))
+
+const mockClipboardWriteText = vi.fn()
+Object.assign(navigator, { clipboard: { writeText: mockClipboardWriteText } })
+
+const mockWindowOpen = vi.fn()
+const originalWindowOpen = window.open
+beforeEach(() => {
+ window.open = mockWindowOpen
+ vi.spyOn(console, 'log').mockImplementation(() => {})
+ vi.spyOn(console, 'debug').mockImplementation(() => {})
+})
+afterEach(() => {
+ window.open = originalWindowOpen
+ vi.restoreAllMocks()
+})
+
+const setupMockGet = (overrides?: {
+ subscriptions?: unknown[]
+ tunnels?: unknown[]
+ domains?: unknown[]
+}) => {
+ mockGet.mockImplementation((url: string) => {
+ if (url === '/subscriptions') return Promise.resolve({ data: overrides?.subscriptions || [] })
+ if (url === '/tunnels') return Promise.resolve({ data: overrides?.tunnels || [] })
+ if (url === '/domains/all') return Promise.resolve({ data: overrides?.domains || [] })
+ return Promise.resolve({ data: {} })
+ })
+}
+
+const renderSubscriptionsPage = () => {
+ return render(
+
+
+
+
+
+
+
+ )
+}
+
+describe('SubscriptionsPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockClipboardWriteText.mockClear()
+ mockWindowOpen.mockClear()
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Рендеринг', () => {
+ it('должен рендериться с заголовком', async () => {
+ setupMockGet()
+ renderSubscriptionsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Подписки')).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать кнопку "Создать"', async () => {
+ setupMockGet()
+ renderSubscriptionsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Создать')).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать сообщение при отсутствии подписок', async () => {
+ setupMockGet({ subscriptions: [] })
+ renderSubscriptionsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Нет подписок')).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать список подписок', async () => {
+ const mockSubs = [
+ { id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [{}, {}], isAutoRotationEnabled: true }
+ ]
+ setupMockGet({ subscriptions: mockSubs })
+ renderSubscriptionsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Sub')).toBeInTheDocument()
+ expect(screen.getByText('abc-123')).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать количество инбаундов', async () => {
+ const mockSubs = [
+ { id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [{}, {}, {}] }
+ ]
+ setupMockGet({ subscriptions: mockSubs })
+ renderSubscriptionsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('3')).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать чекбокс авторотации', async () => {
+ const mockSubs = [
+ { id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [], isAutoRotationEnabled: true }
+ ]
+ setupMockGet({ subscriptions: mockSubs })
+ renderSubscriptionsPage()
+
+ await waitFor(() => {
+ const checkbox = screen.getByRole('checkbox')
+ expect(checkbox).toBeChecked()
+ })
+ })
+ })
+
+ describe('Загрузка данных', () => {
+ it('должен загружать подписки при монтировании', async () => {
+ setupMockGet()
+ renderSubscriptionsPage()
+
+ await waitFor(() => {
+ expect(mockGet).toHaveBeenCalledWith('/subscriptions')
+ })
+ })
+
+ it('должен загружать туннели при монтировании', async () => {
+ setupMockGet()
+ renderSubscriptionsPage()
+
+ await waitFor(() => {
+ expect(mockGet).toHaveBeenCalledWith('/tunnels')
+ })
+ })
+
+ it('должен загружать домены при монтировании', async () => {
+ setupMockGet()
+ renderSubscriptionsPage()
+
+ await waitFor(() => {
+ expect(mockGet).toHaveBeenCalledWith('/domains/all')
+ })
+ })
+
+ it('должен фильтровать только установленные туннели', async () => {
+ const mockTunnels = [
+ { id: 1, name: 'Tunnel 1', ip: '1.1.1.1', domain: '', isInstalled: true },
+ { id: 2, name: 'Tunnel 2', ip: '2.2.2.2', domain: '', isInstalled: false }
+ ]
+ setupMockGet({ tunnels: mockTunnels })
+ renderSubscriptionsPage()
+
+ await waitFor(() => {
+ expect(mockGet).toHaveBeenCalledWith('/tunnels')
+ expect(mockGet).toHaveBeenCalledTimes(3)
+ })
+ })
+ })
+
+ describe('Селектор сервера', () => {
+ it('должен отображать селектор сервера при наличии туннелей', async () => {
+ const mockTunnels = [
+ { id: 1, name: 'Tunnel 1', ip: '1.1.1.1', domain: '', isInstalled: true }
+ ]
+ setupMockGet({ tunnels: mockTunnels, subscriptions: [] })
+ renderSubscriptionsPage()
+
+ await waitFor(() => {
+ const select = screen.getByRole('combobox')
+ expect(select).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать иконку Dns для основного сервера', async () => {
+ const mockTunnels = [
+ { id: 1, name: 'Tunnel 1', ip: '1.1.1.1', domain: '', isInstalled: true }
+ ]
+ setupMockGet({ tunnels: mockTunnels, subscriptions: [] })
+ renderSubscriptionsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Основной сервер')).toBeInTheDocument()
+ })
+ })
+
+ it('должен переключать выбранный сервер', async () => {
+ const mockTunnels = [
+ { id: 1, name: 'Tunnel 1', ip: '1.1.1.1', domain: '', isInstalled: true }
+ ]
+ setupMockGet({ tunnels: mockTunnels, subscriptions: [] })
+ renderSubscriptionsPage()
+
+ const select = await screen.findByRole('combobox')
+ fireEvent.mouseDown(select)
+
+ const tunnelOption = await screen.findByText('Tunnel 1')
+ fireEvent.click(tunnelOption)
+
+ await waitFor(() => {
+ expect(screen.getByText('Tunnel 1')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Диалог создания подписки', () => {
+ it('должен открывать диалог при клике на "Создать"', async () => {
+ setupMockGet()
+ renderSubscriptionsPage()
+
+ const createButton = await screen.findByText('Создать')
+ fireEvent.click(createButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Новая подписка')).toBeInTheDocument()
+ })
+ })
+
+ it('должен закрывать диалог при клике на "Отмена"', async () => {
+ setupMockGet()
+ renderSubscriptionsPage()
+
+ const createButton = await screen.findByText('Создать')
+ fireEvent.click(createButton)
+
+ const cancelButton = await screen.findByText('Отмена')
+ fireEvent.click(cancelButton)
+
+ await waitFor(() => {
+ expect(screen.queryByText('Новая подписка')).not.toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать поле имени подписки', async () => {
+ setupMockGet()
+ renderSubscriptionsPage()
+
+ const createButton = await screen.findByText('Создать')
+ fireEvent.click(createButton)
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('Имя подписки')).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать инбаунды по умолчанию', async () => {
+ setupMockGet()
+ renderSubscriptionsPage()
+
+ const createButton = await screen.findByText('Создать')
+ fireEvent.click(createButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Инбаунды (10/20)')).toBeInTheDocument()
+ })
+ })
+
+ it('должен позволять вводить имя подписки', async () => {
+ setupMockGet()
+ renderSubscriptionsPage()
+
+ const createButton = await screen.findByText('Создать')
+ fireEvent.click(createButton)
+
+ const nameField = await screen.findByLabelText('Имя подписки')
+ fireEvent.change(nameField, { target: { value: 'My Subscription' } })
+
+ expect(nameField).toHaveValue('My Subscription')
+ })
+
+ it('должен показывать ошибку при пустом имени', async () => {
+ setupMockGet()
+ mockPost.mockRejectedValue({})
+
+ renderSubscriptionsPage()
+
+ const createButton = await screen.findByText('Создать')
+ fireEvent.click(createButton)
+
+ const saveButton = await screen.findByText('Сохранить')
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Введите имя подписки')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Управление инбаундами в диалоге', () => {
+ it('должен отображать инбаунды по умолчанию', async () => {
+ setupMockGet()
+ renderSubscriptionsPage()
+
+ const createButton = await screen.findByText('Создать')
+ fireEvent.click(createButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Инбаунды (10/20)')).toBeInTheDocument()
+ })
+ })
+
+ it('должен позволять вводить имя подписки', async () => {
+ setupMockGet()
+ renderSubscriptionsPage()
+
+ const createButton = await screen.findByText('Создать')
+ fireEvent.click(createButton)
+
+ const nameField = await screen.findByLabelText('Имя подписки')
+ fireEvent.change(nameField, { target: { value: 'My Subscription' } })
+
+ expect(nameField).toHaveValue('My Subscription')
+ })
+
+ it('должен добавлять новый инбаунд', async () => {
+ setupMockGet()
+ renderSubscriptionsPage()
+
+ const createButton = await screen.findByText('Создать')
+ fireEvent.click(createButton)
+
+ const addButton = await screen.findByText('Добавить инбаунд')
+ fireEvent.click(addButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Инбаунды (11/20)')).toBeInTheDocument()
+ })
+ })
+
+ it('должен удалять все инбаунды кнопкой "Удалить все"', async () => {
+ setupMockGet()
+ renderSubscriptionsPage()
+
+ const createButton = await screen.findByText('Создать')
+ fireEvent.click(createButton)
+
+ const removeAllButton = await screen.findByText('Удалить все')
+ fireEvent.click(removeAllButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Инбаунды (1/20)')).toBeInTheDocument()
+ })
+ })
+
+ it('должен запрещать добавление более 20 инбаундов', async () => {
+ setupMockGet()
+ renderSubscriptionsPage()
+
+ const createButton = await screen.findByText('Создать')
+ fireEvent.click(createButton)
+
+ for (let i = 0; i < 10; i++) {
+ const addButton = screen.getByText('Добавить инбаунд')
+ fireEvent.click(addButton)
+ }
+
+ await waitFor(() => {
+ const addButton = screen.getByText('Добавить инбаунд')
+ expect(addButton).toBeDisabled()
+ })
+ })
+ })
+
+ describe('Сохранение подписки', () => {
+ it('должен создавать новую подписку', async () => {
+ setupMockGet()
+ mockPost.mockResolvedValue({})
+
+ renderSubscriptionsPage()
+
+ const createButton = await screen.findByText('Создать')
+ fireEvent.click(createButton)
+
+ const nameField = await screen.findByLabelText('Имя подписки')
+ fireEvent.change(nameField, { target: { value: 'New Sub' } })
+
+ const saveButton = screen.getByText('Сохранить')
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(mockPost).toHaveBeenCalledWith('/subscriptions', expect.objectContaining({
+ name: 'New Sub',
+ }))
+ })
+ })
+
+ it('должен показывать успех после создания', async () => {
+ setupMockGet()
+ mockPost.mockResolvedValue({})
+
+ renderSubscriptionsPage()
+
+ const createButton = await screen.findByText('Создать')
+ fireEvent.click(createButton)
+
+ const nameField = await screen.findByLabelText('Имя подписки')
+ fireEvent.change(nameField, { target: { value: 'New Sub' } })
+
+ const saveButton = screen.getByText('Сохранить')
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Подписка создана')).toBeInTheDocument()
+ })
+ })
+
+ it('должен показывать ошибку при неудачном сохранении', async () => {
+ setupMockGet()
+ mockPost.mockRejectedValue({ response: { data: { message: 'Ошибка сервера' } } })
+
+ renderSubscriptionsPage()
+
+ const createButton = await screen.findByText('Создать')
+ fireEvent.click(createButton)
+
+ const nameField = await screen.findByLabelText('Имя подписки')
+ fireEvent.change(nameField, { target: { value: 'New Sub' } })
+
+ const saveButton = screen.getByText('Сохранить')
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Ошибка сервера')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Редактирование подписки', () => {
+ it('должен открывать диалог редактирования', async () => {
+ const mockSubs = [
+ { id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [{ type: 'vless', port: 443, sni: 'example.com' }], isAutoRotationEnabled: true }
+ ]
+ setupMockGet({ subscriptions: mockSubs })
+ renderSubscriptionsPage()
+
+ const menuButton = await screen.findByTestId('icon-MoreVert')
+ fireEvent.click(menuButton)
+
+ const editOption = await screen.findByText('Редактировать')
+ fireEvent.click(editOption)
+
+ await waitFor(() => {
+ expect(screen.getByText('Редактировать подписку')).toBeInTheDocument()
+ })
+ })
+
+ it('должен загружать данные подписки в диалог', async () => {
+ const mockSubs = [
+ { id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [{ type: 'vless', port: 443, sni: 'example.com' }], isAutoRotationEnabled: true }
+ ]
+ setupMockGet({ subscriptions: mockSubs })
+ renderSubscriptionsPage()
+
+ const menuButton = await screen.findByTestId('icon-MoreVert')
+ fireEvent.click(menuButton)
+
+ const editOption = await screen.findByText('Редактировать')
+ fireEvent.click(editOption)
+
+ const nameField = await screen.findByLabelText('Имя подписки')
+ expect(nameField).toHaveValue('Test Sub')
+ })
+
+ it('должен обновлять подписку', async () => {
+ const mockSubs = [
+ { id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [{ type: 'vless', port: 443, sni: 'example.com' }], isAutoRotationEnabled: true }
+ ]
+ setupMockGet({ subscriptions: mockSubs })
+ mockPut.mockResolvedValue({})
+
+ renderSubscriptionsPage()
+
+ const menuButton = await screen.findByTestId('icon-MoreVert')
+ fireEvent.click(menuButton)
+
+ const editOption = await screen.findByText('Редактировать')
+ fireEvent.click(editOption)
+
+ const nameField = await screen.findByLabelText('Имя подписки')
+ fireEvent.change(nameField, { target: { value: 'Updated Sub' } })
+
+ const saveButton = screen.getByText('Сохранить')
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(mockPut).toHaveBeenCalledWith('/subscriptions/1', expect.objectContaining({
+ name: 'Updated Sub',
+ }))
+ })
+ })
+
+ it('должен показывать успех после обновления', async () => {
+ const mockSubs = [
+ { id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [{ type: 'vless', port: 443, sni: 'example.com' }], isAutoRotationEnabled: true }
+ ]
+ setupMockGet({ subscriptions: mockSubs })
+ mockPut.mockResolvedValue({})
+
+ renderSubscriptionsPage()
+
+ const menuButton = await screen.findByTestId('icon-MoreVert')
+ fireEvent.click(menuButton)
+
+ const editOption = await screen.findByText('Редактировать')
+ fireEvent.click(editOption)
+
+ const nameField = await screen.findByLabelText('Имя подписки')
+ fireEvent.change(nameField, { target: { value: 'Updated Sub' } })
+
+ const saveButton = screen.getByText('Сохранить')
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Подписка обновлена')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Удаление подписки', () => {
+ it('должен открывать диалог подтверждения удаления', async () => {
+ const mockSubs = [
+ { id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [], isAutoRotationEnabled: true }
+ ]
+ setupMockGet({ subscriptions: mockSubs })
+ renderSubscriptionsPage()
+
+ const menuButton = await screen.findByTestId('icon-MoreVert')
+ fireEvent.click(menuButton)
+
+ const deleteOption = await screen.findByText('Удалить')
+ fireEvent.click(deleteOption)
+
+ await waitFor(() => {
+ expect(screen.getByText('Удалить подписку и все соединения?')).toBeInTheDocument()
+ })
+ })
+
+ it('должен закрывать диалог при клике на "Отмена"', async () => {
+ const mockSubs = [
+ { id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [], isAutoRotationEnabled: true }
+ ]
+ setupMockGet({ subscriptions: mockSubs })
+ renderSubscriptionsPage()
+
+ const menuButton = await screen.findByTestId('icon-MoreVert')
+ fireEvent.click(menuButton)
+
+ const deleteOption = await screen.findByText('Удалить')
+ fireEvent.click(deleteOption)
+
+ const cancelButton = await screen.findByText('Отмена')
+ fireEvent.click(cancelButton)
+
+ await waitFor(() => {
+ expect(screen.queryByText('Удалить подписку и все соединения?')).not.toBeInTheDocument()
+ })
+ })
+
+ it('должен удалять подписку при подтверждении', async () => {
+ const mockSubs = [
+ { id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [], isAutoRotationEnabled: true }
+ ]
+ setupMockGet({ subscriptions: mockSubs })
+ mockDelete.mockResolvedValue({})
+
+ renderSubscriptionsPage()
+
+ const menuButton = await screen.findByTestId('icon-MoreVert')
+ fireEvent.click(menuButton)
+
+ const deleteOption = await screen.findByText('Удалить')
+ fireEvent.click(deleteOption)
+
+ const confirmButton = screen.getAllByText('Удалить')[1] as HTMLElement
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockDelete).toHaveBeenCalledWith('/subscriptions/1')
+ })
+ })
+
+ it('должен показывать успех после удаления', async () => {
+ const mockSubs = [
+ { id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [], isAutoRotationEnabled: true }
+ ]
+ setupMockGet({ subscriptions: mockSubs })
+ mockDelete.mockResolvedValue({})
+
+ renderSubscriptionsPage()
+
+ const menuButton = await screen.findByTestId('icon-MoreVert')
+ fireEvent.click(menuButton)
+
+ const deleteOption = await screen.findByText('Удалить')
+ fireEvent.click(deleteOption)
+
+ const confirmButton = screen.getAllByText('Удалить')[1] as HTMLElement
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Подписка удалена')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Переключение авторотации', () => {
+ it('должен переключать авторотацию при клике на чекбокс', async () => {
+ const mockSubs = [
+ { id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [], isAutoRotationEnabled: true }
+ ]
+ setupMockGet({ subscriptions: mockSubs })
+ mockPut.mockResolvedValue({})
+
+ renderSubscriptionsPage()
+
+ const checkbox = await screen.findByRole('checkbox')
+ fireEvent.click(checkbox)
+
+ await waitFor(() => {
+ expect(mockPut).toHaveBeenCalledWith('/subscriptions/bulk-auto-rotation', expect.objectContaining({
+ subscriptionIds: ['1'],
+ enabled: false
+ }))
+ })
+ })
+
+ it('должен показывать успех при включении авторотации', async () => {
+ const mockSubs = [
+ { id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [], isAutoRotationEnabled: false }
+ ]
+ setupMockGet({ subscriptions: mockSubs })
+ mockPut.mockResolvedValue({})
+
+ renderSubscriptionsPage()
+
+ 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', inbounds: [], isAutoRotationEnabled: true }
+ ]
+ setupMockGet({ subscriptions: mockSubs })
+ mockPut.mockResolvedValue({})
+
+ renderSubscriptionsPage()
+
+ 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', inbounds: [], isAutoRotationEnabled: true }
+ ]
+ setupMockGet({ subscriptions: mockSubs })
+ mockPut.mockRejectedValue({ response: { data: { message: 'Ошибка' } } })
+
+ renderSubscriptionsPage()
+
+ const checkbox = await screen.findByRole('checkbox')
+ fireEvent.click(checkbox)
+
+ await waitFor(() => {
+ expect(screen.getByText('Ошибка')).toBeInTheDocument()
+ })
+ })
+ })
+})
diff --git a/client/test/setup.ts b/client/test/setup.ts
index ad1232d..cd8341f 100644
--- a/client/test/setup.ts
+++ b/client/test/setup.ts
@@ -12,6 +12,7 @@ afterEach(() => {
// Оставляем только важные ошибки через test.skip()
const originalConsoleLog = console.log
const originalConsoleError = console.error
+const originalConsoleWarn = console.warn
beforeAll(() => {
// Фильтруем шумные логи от приложений
@@ -23,20 +24,41 @@ beforeAll(() => {
message.includes('[Settings]') ||
message.includes('[API]') ||
message.includes('[Login]') ||
- message.includes('[Rotation]')
+ message.includes('[Rotation]') ||
+ message.includes('[Domains]') ||
+ message.includes('[Subs]') ||
+ message.includes('[Scanner]')
) {
return
}
originalConsoleLog(...args)
}
+ // Подавляем console.warn для известных предупреждений
+ console.warn = (...args) => {
+ const message = args.join(' ')
+ if (
+ message.includes('[Tunnels]') ||
+ message.includes('[Settings]') ||
+ message.includes('[Domains]') ||
+ message.includes('[Subs]') ||
+ message.includes('[Scanner]')
+ ) {
+ return
+ }
+ originalConsoleWarn(...args)
+ }
+
// Подавляем console.error для известных предупреждений React
console.error = (...args) => {
const message = args.join(' ')
// Пропускаем предупреждения act(...) - они не критичны
if (
message.includes('act(...)') ||
- message.includes('An update to')
+ message.includes('An update to') ||
+ message.includes('[Login]') ||
+ message.includes('[Settings]') ||
+ message.includes('[Subs]')
) {
return
}
@@ -48,6 +70,7 @@ afterAll(() => {
// Восстанавливаем console после всех тестов
console.log = originalConsoleLog
console.error = originalConsoleError
+ console.warn = originalConsoleWarn
})
// Мок для MUI icons-material - используем vi.mock с factory