Files
Charon/frontend/src/pages/__tests__/ProxyHosts-bulk-delete.test.tsx
GitHub Actions 8f2f18edf7 feat: implement modern UI/UX design system (#409)
- 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
2025-12-16 21:21:39 +00:00

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();
});
});
});