- Add comprehensive design token system (colors, typography, spacing) - Create 12 new UI components with Radix UI primitives - Add layout components (PageShell, StatsCard, EmptyState, DataTable) - Polish all pages with new component library - Improve accessibility with WCAG 2.1 compliance - Add dark mode support with semantic color tokens - Update 947 tests to match new UI patterns Closes #409
526 lines
17 KiB
TypeScript
526 lines
17 KiB
TypeScript
import { render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { MemoryRouter } from 'react-router-dom';
|
|
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
|
import { createMockProxyHost } from '../../testUtils/createMockProxyHost';
|
|
import ProxyHosts from '../ProxyHosts';
|
|
import * as proxyHostsApi from '../../api/proxyHosts';
|
|
import * as backupsApi from '../../api/backups';
|
|
import * as certificatesApi from '../../api/certificates';
|
|
import * as accessListsApi from '../../api/accessLists';
|
|
import * as settingsApi from '../../api/settings';
|
|
import { toast } from 'react-hot-toast';
|
|
|
|
// Mock toast
|
|
vi.mock('react-hot-toast', () => ({
|
|
toast: {
|
|
success: vi.fn(),
|
|
error: vi.fn(),
|
|
loading: vi.fn(),
|
|
dismiss: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock API modules
|
|
vi.mock('../../api/proxyHosts', () => ({
|
|
getProxyHosts: vi.fn(),
|
|
createProxyHost: vi.fn(),
|
|
updateProxyHost: vi.fn(),
|
|
deleteProxyHost: vi.fn(),
|
|
bulkUpdateProxyHostACL: vi.fn(),
|
|
testProxyHostConnection: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../api/backups', () => ({
|
|
createBackup: vi.fn(),
|
|
getBackups: vi.fn(),
|
|
restoreBackup: vi.fn(),
|
|
deleteBackup: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../api/certificates', () => ({
|
|
getCertificates: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../api/accessLists', () => ({
|
|
accessListsApi: {
|
|
list: vi.fn(),
|
|
get: vi.fn(),
|
|
getTemplates: vi.fn(),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
delete: vi.fn(),
|
|
testIP: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../api/settings', () => ({
|
|
getSettings: vi.fn(),
|
|
}));
|
|
|
|
const mockProxyHosts = [
|
|
createMockProxyHost({ uuid: 'host-1', name: 'Test Host 1', domain_names: 'test1.example.com', forward_host: '192.168.1.10' }),
|
|
createMockProxyHost({ uuid: 'host-2', name: 'Test Host 2', domain_names: 'test2.example.com', forward_host: '192.168.1.20' }),
|
|
createMockProxyHost({ uuid: 'host-3', name: 'Test Host 3', domain_names: 'test3.example.com', forward_host: '192.168.1.30' }),
|
|
];
|
|
|
|
const createQueryClient = () => new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: false,
|
|
gcTime: 0,
|
|
},
|
|
mutations: {
|
|
retry: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
const renderWithProviders = (ui: React.ReactNode) => {
|
|
const queryClient = createQueryClient();
|
|
return render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<MemoryRouter>
|
|
{ui}
|
|
</MemoryRouter>
|
|
</QueryClientProvider>
|
|
);
|
|
};
|
|
|
|
describe('ProxyHosts - Bulk Delete with Backup', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
// Setup default mocks
|
|
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(mockProxyHosts);
|
|
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([]);
|
|
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([]);
|
|
vi.mocked(settingsApi.getSettings).mockResolvedValue({});
|
|
vi.mocked(backupsApi.createBackup).mockResolvedValue({
|
|
filename: 'backup-2024-01-01-12-00-00.db',
|
|
});
|
|
});
|
|
|
|
it('renders bulk delete button when hosts are selected', async () => {
|
|
renderWithProviders(<ProxyHosts />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
|
});
|
|
|
|
// Select all hosts using the select-all checkbox (checkboxes[0])
|
|
const checkboxes = screen.getAllByRole('checkbox');
|
|
await userEvent.click(checkboxes[0]);
|
|
|
|
// Bulk delete button should appear in the selection bar
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it('shows confirmation modal when delete button is clicked', async () => {
|
|
renderWithProviders(<ProxyHosts />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
|
});
|
|
|
|
// Select all hosts
|
|
const checkboxes = screen.getAllByRole('checkbox');
|
|
await userEvent.click(checkboxes[0]);
|
|
|
|
// Wait for bulk action buttons to appear, then click bulk delete button
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
|
});
|
|
const manageACLButton = screen.getByText('Manage ACL');
|
|
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
|
await userEvent.click(deleteButton);
|
|
|
|
// Modal should appear
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
|
});
|
|
|
|
// Should list hosts to be deleted (hosts appear in both table and modal)
|
|
expect(screen.getAllByText('Test Host 1').length).toBeGreaterThan(0);
|
|
expect(screen.getAllByText('Test Host 2').length).toBeGreaterThan(0);
|
|
expect(screen.getAllByText('Test Host 3').length).toBeGreaterThan(0);
|
|
|
|
// Should mention automatic backup
|
|
expect(screen.getByText(/automatic backup/i)).toBeTruthy();
|
|
});
|
|
|
|
it('creates backup before deleting hosts', async () => {
|
|
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue();
|
|
|
|
renderWithProviders(<ProxyHosts />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
|
});
|
|
|
|
// Select all hosts
|
|
const checkboxes = screen.getAllByRole('checkbox');
|
|
await userEvent.click(checkboxes[0]);
|
|
|
|
// Wait for bulk action buttons and click delete
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
|
});
|
|
const manageACLButton = screen.getByText('Manage ACL');
|
|
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
|
await userEvent.click(deleteButton);
|
|
|
|
// Wait for modal
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
|
});
|
|
|
|
// Click confirm delete
|
|
const confirmButton = screen.getByText('Delete Permanently');
|
|
await userEvent.click(confirmButton);
|
|
|
|
// Should create backup first
|
|
await waitFor(() => {
|
|
expect(backupsApi.createBackup).toHaveBeenCalled();
|
|
});
|
|
|
|
// Should show loading toast
|
|
expect(toast.loading).toHaveBeenCalledWith('Creating backup before deletion...');
|
|
|
|
// Should show success toast with backup filename
|
|
await waitFor(() => {
|
|
expect(toast.success).toHaveBeenCalledWith('Backup created: backup-2024-01-01-12-00-00.db');
|
|
});
|
|
|
|
// Should then delete the hosts
|
|
await waitFor(() => {
|
|
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('host-1');
|
|
});
|
|
});
|
|
|
|
it('deletes multiple selected hosts after backup', async () => {
|
|
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue();
|
|
|
|
renderWithProviders(<ProxyHosts />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
|
});
|
|
|
|
// Select all hosts
|
|
const checkboxes = screen.getAllByRole('checkbox');
|
|
await userEvent.click(checkboxes[0]);
|
|
|
|
// Wait for bulk action buttons to appear, then click bulk delete button
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
|
});
|
|
const manageACLButton = screen.getByText('Manage ACL');
|
|
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
|
await userEvent.click(deleteButton);
|
|
|
|
// Wait for modal
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
|
});
|
|
|
|
// Click confirm delete
|
|
const confirmButton = screen.getByText('Delete Permanently');
|
|
await userEvent.click(confirmButton);
|
|
|
|
// Should create backup first
|
|
await waitFor(() => {
|
|
expect(backupsApi.createBackup).toHaveBeenCalled();
|
|
});
|
|
|
|
// Should delete all selected hosts
|
|
await waitFor(() => {
|
|
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('host-1');
|
|
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('host-2');
|
|
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('host-3');
|
|
});
|
|
|
|
// Should show success message
|
|
await waitFor(() => {
|
|
expect(toast.success).toHaveBeenCalledWith(
|
|
'Successfully deleted 3 host(s). Backup available for restore.'
|
|
);
|
|
});
|
|
});
|
|
|
|
it('reports partial success when some deletions fail', async () => {
|
|
// Make second deletion fail
|
|
vi.mocked(proxyHostsApi.deleteProxyHost)
|
|
.mockResolvedValueOnce() // host-1 succeeds
|
|
.mockRejectedValueOnce(new Error('Network error')) // host-2 fails
|
|
.mockResolvedValueOnce(); // host-3 succeeds
|
|
|
|
renderWithProviders(<ProxyHosts />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
|
});
|
|
|
|
// Select all hosts
|
|
const checkboxes = screen.getAllByRole('checkbox');
|
|
await userEvent.click(checkboxes[0]);
|
|
|
|
// Wait for bulk action buttons to appear, then click bulk delete button
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
|
});
|
|
const manageACLButton = screen.getByText('Manage ACL');
|
|
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
|
await userEvent.click(deleteButton);
|
|
|
|
// Wait for modal and confirm
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
|
});
|
|
|
|
const confirmButton = screen.getByText('Delete Permanently');
|
|
await userEvent.click(confirmButton);
|
|
|
|
// Wait for backup
|
|
await waitFor(() => {
|
|
expect(backupsApi.createBackup).toHaveBeenCalled();
|
|
});
|
|
|
|
// Should show partial success
|
|
await waitFor(() => {
|
|
expect(toast.error).toHaveBeenCalledWith('Deleted 2 host(s), 1 failed');
|
|
});
|
|
});
|
|
|
|
it('handles backup creation failure', async () => {
|
|
vi.mocked(backupsApi.createBackup).mockRejectedValue(new Error('Backup failed'));
|
|
|
|
renderWithProviders(<ProxyHosts />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
|
});
|
|
|
|
// Select all hosts using select-all checkbox
|
|
const checkboxes = screen.getAllByRole('checkbox');
|
|
await userEvent.click(checkboxes[0]);
|
|
|
|
// Wait for bulk action buttons to appear, then click bulk delete button
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
|
});
|
|
const manageACLButton = screen.getByText('Manage ACL');
|
|
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
|
await userEvent.click(deleteButton);
|
|
|
|
// Wait for modal and confirm
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
|
});
|
|
|
|
const confirmButton = screen.getByText('Delete Permanently');
|
|
await userEvent.click(confirmButton);
|
|
|
|
// Should show error
|
|
await waitFor(() => {
|
|
expect(toast.error).toHaveBeenCalledWith('Backup failed');
|
|
});
|
|
|
|
// Should NOT delete hosts if backup fails
|
|
expect(proxyHostsApi.deleteProxyHost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('closes modal after successful deletion', async () => {
|
|
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue();
|
|
|
|
renderWithProviders(<ProxyHosts />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
|
});
|
|
|
|
// Select all hosts using select-all checkbox
|
|
const checkboxes = screen.getAllByRole('checkbox');
|
|
await userEvent.click(checkboxes[0]);
|
|
|
|
// Wait for bulk action buttons to appear, then click bulk delete button
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
|
});
|
|
const manageACLButton = screen.getByText('Manage ACL');
|
|
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
|
await userEvent.click(deleteButton);
|
|
|
|
// Wait for modal
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
|
});
|
|
|
|
// Click confirm delete
|
|
const confirmButton = screen.getByText('Delete Permanently');
|
|
await userEvent.click(confirmButton);
|
|
|
|
// Wait for completion
|
|
await waitFor(() => {
|
|
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalled();
|
|
});
|
|
|
|
// Modal should close
|
|
await waitFor(() => {
|
|
expect(screen.queryByText(/Delete 3 Proxy Hosts?/i)).toBeNull();
|
|
});
|
|
});
|
|
|
|
it('clears selection after successful deletion', async () => {
|
|
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue();
|
|
|
|
renderWithProviders(<ProxyHosts />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
|
});
|
|
|
|
// Select all hosts using select-all checkbox
|
|
const checkboxes = screen.getAllByRole('checkbox');
|
|
await userEvent.click(checkboxes[0]);
|
|
|
|
// Should show selection count
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/selected/)).toBeTruthy();
|
|
});
|
|
|
|
// Click bulk delete button and confirm (find it via Manage ACL sibling)
|
|
const manageACLButton = screen.getByText('Manage ACL');
|
|
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
|
await userEvent.click(deleteButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
|
});
|
|
|
|
const confirmButton = screen.getByText('Delete Permanently');
|
|
await userEvent.click(confirmButton);
|
|
|
|
// Wait for completion
|
|
await waitFor(() => {
|
|
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalled();
|
|
});
|
|
|
|
// Selection should be cleared - bulk action buttons should disappear
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('Manage ACL')).toBeNull();
|
|
});
|
|
});
|
|
|
|
it('disables confirm button while creating backup', async () => {
|
|
// Make backup creation take time
|
|
vi.mocked(backupsApi.createBackup).mockImplementation(
|
|
() => new Promise(resolve => setTimeout(() => resolve({ filename: 'backup.db' }), 100))
|
|
);
|
|
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue();
|
|
|
|
renderWithProviders(<ProxyHosts />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
|
});
|
|
|
|
// Select all hosts using select-all checkbox
|
|
const checkboxes = screen.getAllByRole('checkbox');
|
|
await userEvent.click(checkboxes[0]);
|
|
|
|
// Wait for bulk action buttons to appear, then click bulk delete button
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
|
});
|
|
const manageACLButton = screen.getByText('Manage ACL');
|
|
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
|
await userEvent.click(deleteButton);
|
|
|
|
// Wait for modal
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
|
});
|
|
|
|
// Click confirm delete
|
|
const confirmButton = screen.getByText('Delete Permanently');
|
|
await userEvent.click(confirmButton);
|
|
|
|
// Backup should be called (confirms the button works and backup process starts)
|
|
await waitFor(() => {
|
|
expect(backupsApi.createBackup).toHaveBeenCalled();
|
|
});
|
|
|
|
// Wait for deletion to complete to prevent test pollution
|
|
await waitFor(() => {
|
|
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('can cancel deletion from modal', async () => {
|
|
// Clear mocks to ensure no pollution from previous tests
|
|
vi.mocked(backupsApi.createBackup).mockClear();
|
|
vi.mocked(proxyHostsApi.deleteProxyHost).mockClear();
|
|
|
|
renderWithProviders(<ProxyHosts />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
|
});
|
|
|
|
// Select all hosts using select-all checkbox
|
|
const checkboxes = screen.getAllByRole('checkbox');
|
|
await userEvent.click(checkboxes[0]);
|
|
|
|
// Wait for bulk action buttons to appear, then click bulk delete button
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
|
});
|
|
const manageACLButton = screen.getByText('Manage ACL');
|
|
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
|
await userEvent.click(deleteButton);
|
|
|
|
// Wait for modal
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
|
});
|
|
|
|
// Click cancel
|
|
const cancelButton = screen.getByText('Cancel');
|
|
await userEvent.click(cancelButton);
|
|
|
|
// Modal should close
|
|
await waitFor(() => {
|
|
expect(screen.queryByText(/Delete 3 Proxy Hosts?/i)).toBeNull();
|
|
});
|
|
|
|
// Should NOT create backup or delete
|
|
expect(backupsApi.createBackup).not.toHaveBeenCalled();
|
|
expect(proxyHostsApi.deleteProxyHost).not.toHaveBeenCalled();
|
|
|
|
// Selection should remain
|
|
expect(screen.getByText(/selected/i)).toBeTruthy();
|
|
});
|
|
|
|
it('shows (all) indicator when all hosts selected for deletion', async () => {
|
|
renderWithProviders(<ProxyHosts />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
|
});
|
|
|
|
// Select all hosts using the select-all checkbox
|
|
const selectAll = screen.getByLabelText('Select all rows');
|
|
await userEvent.click(selectAll);
|
|
|
|
// Should show "(all)" indicator - format is "<strong>3</strong> hosts selected (all)"
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/hosts?\s*selected/)).toBeTruthy();
|
|
expect(screen.getByText(/\(all\)/)).toBeTruthy();
|
|
});
|
|
});
|
|
});
|