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( {ui} ); }; 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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 "3 hosts selected (all)" await waitFor(() => { expect(screen.getByText(/hosts?\s*selected/)).toBeTruthy(); expect(screen.getByText(/\(all\)/)).toBeTruthy(); }); }); });