From 709aeb4e1aad3e50a3518f4d3d02d34882ee5230 Mon Sep 17 00:00:00 2001 From: Yunwen Zheng Date: Wed, 23 Jul 2025 13:12:23 -0400 Subject: [PATCH] BulkDeleteProvisionedResource: Add unit tests (#108484) * BulkDeleteProvisionedResource: added unit tests and reorganize folder permission --------- Co-authored-by: Tom Ratcliffe --- .../BrowseActions/BrowseActions.tsx | 2 +- .../BulkActionFailureBanner.test.tsx | 151 +++++++++++++ .../BulkActionFailureBanner.tsx | 0 .../BulkActions/BulkActionProgress.test.tsx | 62 ++++++ .../{ => BulkActions}/BulkActionProgress.tsx | 4 +- .../BulkDeleteProvisionedResource.test.tsx | 201 ++++++++++++++++++ .../BulkDeleteProvisionedResource.tsx | 10 +- 7 files changed, 422 insertions(+), 8 deletions(-) create mode 100644 public/app/features/browse-dashboards/components/BulkActions/BulkActionFailureBanner.test.tsx rename public/app/features/browse-dashboards/components/{ => BulkActions}/BulkActionFailureBanner.tsx (100%) create mode 100644 public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.test.tsx rename public/app/features/browse-dashboards/components/{ => BulkActions}/BulkActionProgress.tsx (89%) create mode 100644 public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.test.tsx rename public/app/features/browse-dashboards/components/{ => BulkActions}/BulkDeleteProvisionedResource.tsx (97%) diff --git a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx index d246ccfcce9..85b50db08c0 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx @@ -14,7 +14,7 @@ import { useDeleteItemsMutation, useMoveItemsMutation } from '../../api/browseDa import { useActionSelectionState } from '../../state/hooks'; import { setAllSelection } from '../../state/slice'; import { DashboardTreeSelection } from '../../types'; -import { BulkDeleteProvisionedResource } from '../BulkDeleteProvisionedResource'; +import { BulkDeleteProvisionedResource } from '../BulkActions/BulkDeleteProvisionedResource'; import { DeleteModal } from './DeleteModal'; import { MoveModal } from './MoveModal'; diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkActionFailureBanner.test.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkActionFailureBanner.test.tsx new file mode 100644 index 00000000000..9d91c74fbae --- /dev/null +++ b/public/app/features/browse-dashboards/components/BulkActions/BulkActionFailureBanner.test.tsx @@ -0,0 +1,151 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { BulkActionFailureBanner, MoveResultFailed } from './BulkActionFailureBanner'; + +const setup = (resultOverrides?: Array>, onDismissOverride?: () => void) => { + const defaultFailedItems: MoveResultFailed[] = [ + { + status: 'failed', + title: 'Dashboard 1', + errorMessage: 'Permission denied', + }, + { + status: 'failed', + title: 'Dashboard 2', + errorMessage: 'Network error', + }, + ]; + + const result = + resultOverrides !== undefined + ? resultOverrides.map((override) => ({ status: 'failed' as const, title: 'Default Title', ...override })) + : defaultFailedItems; + + const onDismiss = onDismissOverride || jest.fn(); + + const props = { + result, + onDismiss, + }; + + return { + user: userEvent.setup(), + ...render(), + props, + }; +}; + +describe('BulkActionFailureBanner', () => { + it('should display error alert with correct item count', () => { + const testData = [{ title: 'Single Item', errorMessage: 'Single error' }]; + setup(testData); + + // Test that an alert is rendered + const alert = screen.getByRole('alert'); + expect(alert).toBeInTheDocument(); + + // Test structure: should have same number of list items as input data + const listItems = screen.getAllByRole('listitem'); + expect(listItems).toHaveLength(testData.length); + }); + + it('should render correct number of failed items with proper structure', () => { + const testData = [ + { title: 'Failed Dashboard A', errorMessage: 'Access denied' }, + { title: 'Failed Dashboard B', errorMessage: 'Validation failed' }, + ]; + setup(testData); + + const alert = screen.getByRole('alert'); + expect(alert).toBeInTheDocument(); + + // Test structure: number of list items matches input + const listItems = screen.getAllByRole('listitem'); + expect(listItems).toHaveLength(testData.length); + + // Test that each item has the expected structure (title + error message) + listItems.forEach((item, index) => { + const title = testData[index].title; + const errorMessage = testData[index].errorMessage; + + if (title) { + expect(item).toHaveTextContent(title); + } + if (errorMessage) { + expect(item).toHaveTextContent(errorMessage); + } + }); + }); + + it('should handle mixed scenarios', () => { + const testData = [ + { title: 'Item without error' }, + { title: 'Item with empty error', errorMessage: '' }, + { title: 'Another item with error', errorMessage: 'Another error' }, + ]; + setup(testData); + + const alert = screen.getByRole('alert'); + expect(alert).toBeInTheDocument(); + + const listItems = screen.getAllByRole('listitem'); + expect(listItems).toHaveLength(testData.length); + + // Test that items with error messages contain both title and error + // Items without errors should only contain title + testData.forEach((data, index) => { + const listItem = listItems[index]; + + // All items should have their title + if (data.title) { + expect(listItem).toHaveTextContent(data.title); + } + + // Only items with non-empty error messages should show the error + if (data.errorMessage && data.errorMessage.trim() !== '') { + expect(listItem).toHaveTextContent(data.errorMessage); + } + }); + }); + + it('should maintain list structure', () => { + const testData = [ + { title: 'Item 1', errorMessage: 'Error 1' }, + { title: 'Item 2', errorMessage: 'Error 2' }, + ]; + setup(testData); + + // Test semantic structure + const list = screen.getByRole('list'); + expect(list).toBeInTheDocument(); + + const listItems = screen.getAllByRole('listitem'); + expect(listItems).toHaveLength(testData.length); + }); + + it('should handle items without error messages', () => { + const testData = [{ title: 'Just Title Item' }]; + setup(testData); + + const listItems = screen.getAllByRole('listitem'); + expect(listItems).toHaveLength(1); + + const item = listItems[0]; + expect(item).toHaveTextContent(testData[0].title!); + // Should not contain colon separator when no error message + expect(item.textContent).not.toMatch(/:\s*.+$/); + }); + + it('should render dismissible alert', () => { + const onDismiss = jest.fn(); + setup([], onDismiss); + + const alert = screen.getByRole('alert'); + expect(alert).toBeInTheDocument(); + + // Alert should have close button (dismissible) + const closeButton = screen.getByRole('button', { name: /close/i }); + expect(closeButton).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/browse-dashboards/components/BulkActionFailureBanner.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkActionFailureBanner.tsx similarity index 100% rename from public/app/features/browse-dashboards/components/BulkActionFailureBanner.tsx rename to public/app/features/browse-dashboards/components/BulkActions/BulkActionFailureBanner.tsx diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.test.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.test.tsx new file mode 100644 index 00000000000..e1dcbc41a94 --- /dev/null +++ b/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from '@testing-library/react'; + +import { BulkActionProgress, ProgressState } from './BulkActionProgress'; + +const setup = (progressOverrides: Partial = {}) => { + const defaultProgress: ProgressState = { + current: 5, + total: 10, + item: 'Test Dashboard', + ...progressOverrides, + }; + + const props = { + progress: defaultProgress, + }; + + return { + ...render(), + props, + }; +}; + +describe('BulkActionProgress', () => { + it('should render progress text with current and total values', () => { + setup({ current: 3, total: 8 }); + + expect(screen.getByText(/Progress: 3 of 8/)).toBeInTheDocument(); + }); + + it('should render current item being deleted', () => { + setup({ item: 'My Test Dashboard' }); + + expect(screen.getByText(/Deleting:/)).toBeInTheDocument(); + expect(screen.getByText(/My Test Dashboard/)).toBeInTheDocument(); + }); + + it('should handle edge case with total of 1', () => { + setup({ current: 1, total: 1 }); + + expect(screen.getByText(/Progress: 1 of 1/)).toBeInTheDocument(); + }); + + it('should handle edge case with zero current progress', () => { + setup({ current: 0, total: 5 }); + + expect(screen.getByText(/Progress: 0 of 5/)).toBeInTheDocument(); + }); + + it('should render all required elements together', () => { + setup({ current: 7, total: 15, item: 'Complex Dashboard Name' }); + + // Progress text + expect(screen.getByText(/Progress: 7 of 15/)).toBeInTheDocument(); + + // Spinner icon + expect(screen.getByTestId('Spinner')).toBeInTheDocument(); + + // Current item text + expect(screen.getByText(/Deleting:/)).toBeInTheDocument(); + expect(screen.getByText(/Complex Dashboard Name/)).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/browse-dashboards/components/BulkActionProgress.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.tsx similarity index 89% rename from public/app/features/browse-dashboards/components/BulkActionProgress.tsx rename to public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.tsx index 0b49fd547be..fd8ac5196a8 100644 --- a/public/app/features/browse-dashboards/components/BulkActionProgress.tsx +++ b/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.tsx @@ -1,5 +1,5 @@ import { Trans } from '@grafana/i18n'; -import { Box, Icon, Text, Stack } from '@grafana/ui'; +import { Box, Text, Stack, Spinner } from '@grafana/ui'; import ProgressBar from 'app/features/provisioning/Shared/ProgressBar'; export interface ProgressState { @@ -21,7 +21,7 @@ export function BulkActionProgress({ progress }: { progress: ProgressState }) { values={{ current: progress.current, total: progress.total }} /> - + diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.test.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.test.tsx new file mode 100644 index 00000000000..045efed7e54 --- /dev/null +++ b/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.test.tsx @@ -0,0 +1,201 @@ +import { screen, waitFor } from '@testing-library/react'; +import { HttpResponse, http } from 'msw'; +import { render } from 'test/test-utils'; + +import { setBackendSrv } from '@grafana/runtime'; +import server, { setupMockServer } from '@grafana/test-utils/server'; +import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1'; +import { backendSrv } from 'app/core/services/backend_srv'; + +import { BulkDeleteProvisionedResource } from './BulkDeleteProvisionedResource'; + +// Set up backendSrv as recommended in the PR comment +setBackendSrv(backendSrv); +setupMockServer(); + +jest.mock('../utils', () => ({ + collectSelectedItems: jest.fn().mockReturnValue([ + { uid: 'folder-1', isFolder: true, displayName: 'Test Folder' }, + { uid: 'dashboard-1', isFolder: false, displayName: 'Test Dashboard' }, + ]), + fetchProvisionedDashboardPath: jest.fn().mockResolvedValue('/test/dashboard.json'), +})); + +jest.mock('../../state/hooks', () => ({ + useChildrenByParentUIDState: jest.fn().mockReturnValue({}), + rootItemsSelector: jest.fn().mockReturnValue({ + items: [ + { uid: 'folder-1', title: 'Test Folder', kind: 'folder' }, + { uid: 'dashboard-1', title: 'Test Dashboard', kind: 'dashboard' }, + ], + }), +})); + +jest.mock('../../state/utils', () => ({ + findItem: jest.fn().mockImplementation((rootItems: unknown[], childrenByUID: unknown, uid: string) => { + const mockRootItems = [ + { uid: 'folder-1', title: 'Test Folder', kind: 'folder' }, + { uid: 'dashboard-1', title: 'Test Dashboard', kind: 'dashboard' }, + ]; + return mockRootItems.find((item) => item.uid === uid); + }), +})); + +jest.mock('../BrowseActions/DescendantCount', () => ({ + DescendantCount: jest.fn(({ selectedItems }) => ( +
+ Mocked descendant count for {Object.keys(selectedItems.folder).length} folders and{' '} + {Object.keys(selectedItems.dashboard).length} dashboards +
+ )), +})); + +jest.mock('app/features/provisioning/hooks/useGetResourceRepositoryView', () => ({ + useGetResourceRepositoryView: jest.fn(), +})); + +describe('BulkDeleteProvisionedResource', () => { + const defaultRepository: RepositoryView = { + name: 'test-folder', // This must match the folderUid passed to the component + type: 'github', + title: 'Test Repository', + target: 'folder', + workflows: ['branch', 'write'], + }; + + const selectedItems = { + folder: { 'folder-1': true }, + dashboard: { 'dashboard-1': true }, + }; + + beforeEach(() => { + server.use( + http.delete('/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/:name/files/*', () => { + return HttpResponse.json({ + urls: { repositoryURL: 'https://github.com/test/repo' }, + }); + }) + ); + jest.clearAllMocks(); + + const { useGetResourceRepositoryView } = jest.requireMock( + 'app/features/provisioning/hooks/useGetResourceRepositoryView' + ); + useGetResourceRepositoryView.mockReturnValue({ + repository: defaultRepository, + folder: { + metadata: { + annotations: { + 'grafana.app/file-path': '/test/folder', + }, + }, + }, + isInstanceManaged: false, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function setup(repository: RepositoryView | null = defaultRepository) { + const onDismiss = jest.fn(); + + const { useGetResourceRepositoryView } = jest.requireMock( + 'app/features/provisioning/hooks/useGetResourceRepositoryView' + ); + useGetResourceRepositoryView.mockReturnValue({ + repository, + folder: repository + ? { + metadata: { + annotations: { + 'grafana.app/file-path': '/test/folder', + }, + }, + } + : null, + isInstanceManaged: false, + }); + + const renderResult = render( + + ); + + return { + onDismiss, + ...renderResult, + }; + } + + it('renders the delete warning and form', async () => { + setup(); + + expect(await screen.findByText(/This will delete selected folders and their descendants/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Delete/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument(); + }); + + it('calls onDismiss when Cancel is clicked', async () => { + const { onDismiss, user } = setup(); + + await user.click(screen.getByRole('button', { name: /Cancel/i })); + + expect(onDismiss).toHaveBeenCalled(); + }); + + it('handles successful deletion', async () => { + const { user } = setup(); + + await user.click(screen.getByRole('button', { name: /Delete/i })); + + expect(await screen.findByText(/All resources have been deleted successfully/)).toBeInTheDocument(); + }); + + it('handles deletion errors', async () => { + const { user } = setup(); + + // Mock API to return error for this test + server.use( + http.delete('/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/:name/files/*', () => { + return HttpResponse.json({ message: 'Network error' }, { status: 500 }); + }) + ); + + await user.click(screen.getByRole('button', { name: /Delete/i })); + + await waitFor(() => { + // Should show error alert with failed items + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByLabelText(/items failed/)).toBeInTheDocument(); + + // Should have error list items for both folder and dashboard + const errorItems = screen.getAllByRole('listitem'); + expect(errorItems).toHaveLength(2); // One for folder, one for dashboard + }); + }); + + it('shows loading state during deletion', async () => { + const { user } = setup(); + + // Mock slow API response + server.use( + http.delete('/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/:name/files/*', async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return HttpResponse.json({ + urls: { repositoryURL: 'https://github.com/test/repo' }, + }); + }) + ); + + await user.click(screen.getByRole('button', { name: /Delete/i })); + + expect(screen.getByText(/Deleting.../)).toBeInTheDocument(); + }); + + it('returns null when repository is not available', () => { + const { container } = setup(null); + + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/public/app/features/browse-dashboards/components/BulkDeleteProvisionedResource.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx similarity index 97% rename from public/app/features/browse-dashboards/components/BulkDeleteProvisionedResource.tsx rename to public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx index 378e530672c..65e3463d60e 100644 --- a/public/app/features/browse-dashboards/components/BulkDeleteProvisionedResource.tsx +++ b/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx @@ -19,14 +19,14 @@ import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/us import { WorkflowOption } from 'app/features/provisioning/types'; import { useSelector } from 'app/types/store'; -import { useChildrenByParentUIDState, rootItemsSelector } from '../state/hooks'; -import { findItem } from '../state/utils'; -import { DashboardTreeSelection } from '../types'; +import { useChildrenByParentUIDState, rootItemsSelector } from '../../state/hooks'; +import { findItem } from '../../state/utils'; +import { DashboardTreeSelection } from '../../types'; +import { DescendantCount } from '../BrowseActions/DescendantCount'; +import { collectSelectedItems, fetchProvisionedDashboardPath } from '../utils'; -import { DescendantCount } from './BrowseActions/DescendantCount'; import { BulkActionFailureBanner, MoveResultFailed } from './BulkActionFailureBanner'; import { BulkActionProgress, ProgressState } from './BulkActionProgress'; -import { collectSelectedItems, fetchProvisionedDashboardPath } from './utils'; interface BulkDeleteFormData { comment: string;