421 lines
13 KiB
TypeScript
421 lines
13 KiB
TypeScript
import { useParams } from 'react-router-dom';
|
|
import { screen, waitFor, within } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { renderWrapper } from '@src/setupTest';
|
|
import LibrariesUserManager from './LibrariesUserManager';
|
|
import { useLibraryAuthZ } from './context';
|
|
import { useLibrary, useTeamMembers, useRevokeUserRoles } from '../data/hooks';
|
|
import { ToastManagerProvider } from './ToastManagerContext';
|
|
|
|
jest.mock('@edx/frontend-platform/logging', () => ({
|
|
logError: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('react-router-dom', () => ({
|
|
...jest.requireActual('react-router-dom'),
|
|
useParams: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('./context', () => ({
|
|
useLibraryAuthZ: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('../data/hooks', () => ({
|
|
useLibrary: jest.fn(),
|
|
useTeamMembers: jest.fn(),
|
|
useRevokeUserRoles: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('../components/RoleCard', () => ({
|
|
__esModule: true,
|
|
default: ({
|
|
title,
|
|
description,
|
|
handleDelete,
|
|
}: {
|
|
title: string;
|
|
description: string;
|
|
handleDelete?: () => void;
|
|
}) => (
|
|
<div>
|
|
<div>{title}</div>
|
|
<div>{description}</div>
|
|
{handleDelete && (
|
|
<button type="button" onClick={handleDelete}>
|
|
{`delete-role-${title}`}
|
|
</button>
|
|
)}
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
jest.mock('./components/AssignNewRoleModal', () => ({
|
|
AssignNewRoleTrigger: () => <button type="button">Assign Role</button>,
|
|
}));
|
|
|
|
describe('LibrariesUserManager', () => {
|
|
const mockMutate = jest.fn();
|
|
const defaultMockData = {
|
|
libraryId: 'lib:123',
|
|
permissions: [{ key: 'view' }, { key: 'reuse' }],
|
|
roles: [
|
|
{
|
|
role: 'admin',
|
|
name: 'Admin',
|
|
description: 'Administrator Role',
|
|
permissions: ['view', 'reuse'],
|
|
userCount: 5,
|
|
},
|
|
{
|
|
role: 'instructor',
|
|
name: 'Instructor',
|
|
description: 'Instructor Role',
|
|
permissions: ['view'],
|
|
userCount: 10,
|
|
},
|
|
],
|
|
resources: [{ key: 'library', label: 'Library', description: '' }],
|
|
canManageTeam: true,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
|
|
// Mock route params
|
|
(useParams as jest.Mock).mockReturnValue({ username: 'testuser' });
|
|
|
|
// Mock library authz context
|
|
(useLibraryAuthZ as jest.Mock).mockReturnValue(defaultMockData);
|
|
|
|
// Mock library data
|
|
(useLibrary as jest.Mock).mockReturnValue({
|
|
data: {
|
|
title: 'Test Library',
|
|
org: 'Test Org',
|
|
},
|
|
});
|
|
|
|
// Mock team members
|
|
(useTeamMembers as jest.Mock).mockReturnValue({
|
|
data: {
|
|
results: [
|
|
{
|
|
username: 'testuser',
|
|
email: 'testuser@example.com',
|
|
roles: ['admin', 'instructor'],
|
|
},
|
|
],
|
|
},
|
|
isLoading: false,
|
|
isFetching: false,
|
|
});
|
|
|
|
// Mock revoke user roles
|
|
(useRevokeUserRoles as jest.Mock).mockReturnValue({
|
|
mutate: mockMutate,
|
|
isPending: false,
|
|
});
|
|
});
|
|
|
|
const renderComponent = () => {
|
|
renderWrapper(
|
|
<ToastManagerProvider>
|
|
<LibrariesUserManager />
|
|
</ToastManagerProvider>,
|
|
);
|
|
};
|
|
|
|
it('renders the user roles correctly', () => {
|
|
renderComponent();
|
|
|
|
// Breadcrumb check
|
|
expect(screen.getByText('Manage Access')).toBeInTheDocument();
|
|
expect(screen.getByText('Library Team Management')).toBeInTheDocument();
|
|
expect(screen.getByRole('listitem', { current: 'page' })).toHaveTextContent('testuser');
|
|
// Page title and subtitle
|
|
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('testuser');
|
|
expect(screen.getByRole('paragraph')).toHaveTextContent('testuser@example.com');
|
|
|
|
expect(screen.getByText('Admin')).toBeInTheDocument();
|
|
expect(screen.getByText('Instructor')).toBeInTheDocument();
|
|
|
|
defaultMockData.roles.forEach((role) => {
|
|
expect(screen.getByText(role.name)).toBeInTheDocument();
|
|
expect(screen.getByText(role.description)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('renders assign role trigger when user has canManageTeam permission', () => {
|
|
renderComponent();
|
|
|
|
expect(screen.getByText('Assign Role')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders correct navigation link label and URL on breadcrumb', () => {
|
|
renderComponent();
|
|
const navLinkManageAccess = screen.getByRole('link', { name: 'Manage Access' });
|
|
expect(navLinkManageAccess).toBeInTheDocument();
|
|
// TODO: Update expected URL when dedicated Manage Access page is created
|
|
expect(navLinkManageAccess).toHaveAttribute('href', '/authz/libraries/lib:123');
|
|
const navLinkLibraryTeamManagement = screen.getByRole('link', { name: 'Library Team Management' });
|
|
expect(navLinkLibraryTeamManagement).toBeInTheDocument();
|
|
expect(navLinkLibraryTeamManagement).toHaveAttribute('href', '/authz/libraries/lib:123');
|
|
});
|
|
|
|
describe('Revoking User Role Flow', () => {
|
|
it('opens confirmation modal when delete role button is clicked', async () => {
|
|
const user = userEvent.setup();
|
|
renderComponent();
|
|
|
|
const deleteButton = screen.getByText('delete-role-Admin');
|
|
await user.click(deleteButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Remove role?')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('displays correct confirmation modal content', async () => {
|
|
const user = userEvent.setup();
|
|
renderComponent();
|
|
|
|
const deleteButton = screen.getByText('delete-role-Admin');
|
|
await user.click(deleteButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Remove role?')).toBeInTheDocument();
|
|
expect(screen.getByText(/Are you sure you want to remove the Admin role from/)).toBeInTheDocument();
|
|
expect(screen.getByText(/Test Library/)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('closes confirmation modal when cancel button is clicked', async () => {
|
|
const user = userEvent.setup();
|
|
renderComponent();
|
|
|
|
const deleteButton = screen.getByText('delete-role-Admin');
|
|
await user.click(deleteButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Remove role?')).toBeInTheDocument();
|
|
});
|
|
|
|
const cancelButton = screen.getByText('Cancel');
|
|
await user.click(cancelButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('Remove role?')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('calls revokeUserRoles mutation when Remove button is clicked', async () => {
|
|
const user = userEvent.setup();
|
|
renderComponent();
|
|
|
|
const deleteButton = screen.getByText('delete-role-Admin');
|
|
await user.click(deleteButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Remove role?')).toBeInTheDocument();
|
|
});
|
|
|
|
const removeButton = screen.getByText('Remove');
|
|
await user.click(removeButton);
|
|
|
|
expect(mockMutate).toHaveBeenCalledWith(
|
|
{
|
|
data: {
|
|
users: 'testuser',
|
|
role: 'admin',
|
|
scope: 'lib:123',
|
|
},
|
|
},
|
|
expect.objectContaining({
|
|
onSuccess: expect.any(Function),
|
|
onError: expect.any(Function),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('shows success toast when role is revoked successfully with multiple roles remaining', async () => {
|
|
const user = userEvent.setup();
|
|
renderComponent();
|
|
|
|
const deleteButton = screen.getByText('delete-role-Admin');
|
|
await user.click(deleteButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Remove role?')).toBeInTheDocument();
|
|
});
|
|
|
|
const removeButton = screen.getByText('Remove');
|
|
await user.click(removeButton);
|
|
|
|
const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess;
|
|
onSuccessCallback({ errors: [] });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/The Admin role has been successfully removed/)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows success toast with user removal message when last role is revoked', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
(useTeamMembers as jest.Mock).mockReturnValue({
|
|
data: {
|
|
results: [
|
|
{
|
|
username: 'testuser',
|
|
email: 'testuser@example.com',
|
|
roles: ['admin'],
|
|
},
|
|
],
|
|
},
|
|
isLoading: false,
|
|
isFetching: false,
|
|
});
|
|
|
|
renderComponent();
|
|
|
|
const deleteButton = screen.getByText('delete-role-Admin');
|
|
await user.click(deleteButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Remove role?')).toBeInTheDocument();
|
|
});
|
|
|
|
const removeButton = screen.getByText('Remove');
|
|
await user.click(removeButton);
|
|
|
|
const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess;
|
|
onSuccessCallback({ errors: [] });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/The user no longer has access to this library/)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows error toast when role revocation fails with server error', async () => {
|
|
const user = userEvent.setup();
|
|
renderComponent();
|
|
|
|
const deleteButton = screen.getByText('delete-role-Admin');
|
|
await user.click(deleteButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Remove role?')).toBeInTheDocument();
|
|
});
|
|
|
|
const removeButton = screen.getByText('Remove');
|
|
await user.click(removeButton);
|
|
|
|
const onErrorCallback = mockMutate.mock.calls[0][1].onError;
|
|
onErrorCallback(new Error('Network error'));
|
|
|
|
// Wait for the error toast to appear with a retry button
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
|
|
});
|
|
|
|
// Second call to mutate also fails
|
|
mockMutate.mockImplementationOnce((_vars, { onError }) => {
|
|
onError(new Error('Network error'), _vars);
|
|
});
|
|
|
|
// Click retry button
|
|
const retryButton = screen.getByRole('button', { name: /retry/i });
|
|
await user.click(retryButton);
|
|
|
|
// The retry toast should appear again
|
|
await waitFor(() => {
|
|
expect(screen.getAllByText(/Something went wrong/i).length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
// Ensure mutate was called twice (original + retry)
|
|
expect(mockMutate).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('shows error toast when API fails to remove a role', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
renderComponent();
|
|
|
|
const deleteButton = screen.getByText('delete-role-Admin');
|
|
await user.click(deleteButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Remove role?')).toBeInTheDocument();
|
|
});
|
|
|
|
const removeButton = screen.getByText('Remove');
|
|
await user.click(removeButton);
|
|
|
|
const { onSuccess } = mockMutate.mock.calls[0][1];
|
|
onSuccess({ errors: [{ error: 'role_removal_error' }] });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('closes confirmation modal after successful role revocation', async () => {
|
|
const user = userEvent.setup();
|
|
renderComponent();
|
|
|
|
const deleteButton = screen.getByText('delete-role-Admin');
|
|
await user.click(deleteButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Remove role?')).toBeInTheDocument();
|
|
});
|
|
|
|
const removeButton = screen.getByText('Remove');
|
|
await user.click(removeButton);
|
|
|
|
const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess;
|
|
onSuccessCallback({ errors: [] });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('Remove role?')).not.toBeInTheDocument();
|
|
});
|
|
expect(await screen.findByText(/role has been successfully removed/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('disables delete action when revocation is in progress', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
(useRevokeUserRoles as jest.Mock).mockReturnValue({
|
|
mutate: mockMutate,
|
|
isPending: true,
|
|
});
|
|
|
|
renderComponent();
|
|
|
|
const deleteButton = screen.getByText('delete-role-Admin');
|
|
await user.click(deleteButton);
|
|
|
|
expect(screen.queryByText('Remove role?')).not.toBeInTheDocument();
|
|
expect(mockMutate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('passes correct context to confirmation modal', async () => {
|
|
const user = userEvent.setup();
|
|
renderComponent();
|
|
|
|
const deleteButton = screen.getByText('delete-role-Instructor');
|
|
await user.click(deleteButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Remove role?')).toBeInTheDocument();
|
|
});
|
|
|
|
const modal = screen.getByRole('dialog');
|
|
expect(within(modal).getByText(/Instructor role/)).toBeInTheDocument();
|
|
expect(within(modal).getByText(/testuser/)).toBeInTheDocument();
|
|
expect(within(modal).getByText(/Test Library/)).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|