BulkDeleteProvisionedResource: Add unit tests (#108484)
* BulkDeleteProvisionedResource: added unit tests and reorganize folder permission --------- Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
This commit is contained in:
co-authored by
Tom Ratcliffe
parent
cc869e7668
commit
709aeb4e1a
@@ -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';
|
||||
|
||||
+151
@@ -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<Partial<MoveResultFailed>>, 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(<BulkActionFailureBanner {...props} />),
|
||||
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();
|
||||
});
|
||||
});
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { BulkActionProgress, ProgressState } from './BulkActionProgress';
|
||||
|
||||
const setup = (progressOverrides: Partial<ProgressState> = {}) => {
|
||||
const defaultProgress: ProgressState = {
|
||||
current: 5,
|
||||
total: 10,
|
||||
item: 'Test Dashboard',
|
||||
...progressOverrides,
|
||||
};
|
||||
|
||||
const props = {
|
||||
progress: defaultProgress,
|
||||
};
|
||||
|
||||
return {
|
||||
...render(<BulkActionProgress {...props} />),
|
||||
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();
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -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 }}
|
||||
/>
|
||||
</Text>
|
||||
<Icon name="spinner" className="fa-spin" size="sm" />
|
||||
<Spinner size="sm" />
|
||||
</Stack>
|
||||
<ProgressBar progress={progressPercentage} topBottomSpacing={1} />
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
+201
@@ -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 }) => (
|
||||
<div data-testid="descendant-count">
|
||||
Mocked descendant count for {Object.keys(selectedItems.folder).length} folders and{' '}
|
||||
{Object.keys(selectedItems.dashboard).length} dashboards
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
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(
|
||||
<BulkDeleteProvisionedResource folderUid="test-folder" selectedItems={selectedItems} onDismiss={onDismiss} />
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
+5
-5
@@ -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;
|
||||
Reference in New Issue
Block a user