Files
frontend-app-authoring/src/course-libraries/CourseLibraries.test.tsx
2025-11-12 13:26:01 -05:00

346 lines
15 KiB
TypeScript

import fetchMock from 'fetch-mock-jest';
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter/types';
import { QueryClient } from '@tanstack/react-query';
import {
initializeMocks,
render,
screen,
waitFor,
within,
} from '../testUtils';
import { mockContentSearchConfig } from '../search-manager/data/api.mock';
import { CourseLibraries } from './CourseLibraries';
import {
mockGetEntityLinks,
mockGetEntityLinksSummaryByDownstreamContext,
mockFetchIndexDocuments,
mockUseLibBlockMetadata,
} from './data/api.mocks';
import { libraryBlockChangesUrl } from '../course-unit/data/api';
import { type ToastActionData } from '../generic/toast-context';
mockContentSearchConfig.applyMock();
mockGetEntityLinks.applyMock();
mockGetEntityLinksSummaryByDownstreamContext.applyMock();
mockUseLibBlockMetadata.applyMock();
const searchParamsGetMock = jest.fn();
let axiosMock: MockAdapter;
let mockShowToast: (message: string, action?: ToastActionData) => void;
let queryClient: QueryClient;
jest.mock('../studio-home/hooks', () => ({
useStudioHome: () => ({
isLoadingPage: false,
isFailedLoadingPage: false,
librariesV2Enabled: true,
}),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
useSearchParams: () => [{
get: searchParamsGetMock,
getAll: () => [],
}],
}));
describe('<CourseLibraries />', () => {
beforeEach(() => {
initializeMocks();
fetchMock.mockReset();
mockFetchIndexDocuments.applyMock();
localStorage.clear();
searchParamsGetMock.mockReturnValue('all');
});
const renderCourseLibrariesPage = async (courseKey?: string) => {
const courseId = courseKey || mockGetEntityLinks.courseKey;
render(<CourseLibraries courseId={courseId} />);
};
it('shows the spinner before the query is complete', async () => {
// This mock will never return data (it loads forever):
await renderCourseLibrariesPage(mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading);
const spinner = await screen.findByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
it('shows empty state when no links are present', async () => {
await renderCourseLibrariesPage(mockGetEntityLinks.courseKeyEmpty);
const emptyMsg = await screen.findByText('This course does not use any content from libraries.');
expect(emptyMsg).toBeInTheDocument();
});
it('shows alert when out of sync components are present', async () => {
const user = userEvent.setup();
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 7' });
// review tab should be open by default as outOfSyncCount is greater than 0
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
await user.click(allTab);
const alert = await screen.findByRole('alert');
expect(await within(alert).findByText(
'7 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
expect(allTab).toHaveAttribute('aria-selected', 'true');
const reviewBtn = await screen.findByRole('button', { name: 'Review' });
await user.click(reviewBtn);
expect(allTab).toHaveAttribute('aria-selected', 'false');
expect(await screen.findByRole('tab', { name: 'Review Content Updates 7' })).toHaveAttribute('aria-selected', 'true');
expect(alert).not.toBeInTheDocument();
});
it('hide alert on dismiss', async () => {
const user = userEvent.setup();
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 7' });
// review tab should be open by default as outOfSyncCount is greater than 0
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
await user.click(allTab);
expect(allTab).toHaveAttribute('aria-selected', 'true');
const alert = await screen.findByRole('alert');
expect(await within(alert).findByText(
'7 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' });
await user.click(dismissBtn);
expect(allTab).toHaveAttribute('aria-selected', 'true');
await waitFor(() => expect(alert).not.toBeInTheDocument());
// review updates button
const reviewActionBtn = await screen.findByRole('button', { name: 'Review Updates' });
await user.click(reviewActionBtn);
expect(await screen.findByRole('tab', { name: 'Review Content Updates 7' })).toHaveAttribute('aria-selected', 'true');
});
it('show alert if max lastPublishedDate is greated than the local storage value', async () => {
const user = userEvent.setup();
const lastPublishedDate = new Date('2025-05-01T22:20:44.989042Z');
localStorage.setItem(
`outOfSyncCountAlert-${mockGetEntityLinks.courseKey}`,
String(lastPublishedDate.getTime() - 1000),
);
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 7' });
// review tab should be open by default as outOfSyncCount is greater than 0
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
await user.click(allTab);
const alert = await screen.findByRole('alert');
expect(await within(alert).findByText(
'7 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
});
it('doesnt show alert if max lastPublishedDate is less than the local storage value', async () => {
const user = userEvent.setup();
const lastPublishedDate = new Date('2025-05-01T22:20:44.989042Z');
localStorage.setItem(
`outOfSyncCountAlert-${mockGetEntityLinks.courseKey}`,
String(lastPublishedDate.getTime() + 1000),
);
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 7' });
// review tab should be open by default as outOfSyncCount is greater than 0
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
await user.click(allTab);
expect(allTab).toHaveAttribute('aria-selected', 'true');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
describe('<CourseLibraries ReviewTab />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
mockShowToast = mocks.mockShowToast;
fetchMock.mockReset();
mockFetchIndexDocuments.applyMock();
localStorage.clear();
searchParamsGetMock.mockReturnValue('review');
queryClient = mocks.queryClient;
});
const renderCourseLibrariesReviewPage = async (courseKey?: string) => {
const courseId = courseKey || mockGetEntityLinks.courseKey;
render(<CourseLibraries courseId={courseId} />);
};
it('shows the spinner before the query is complete', async () => {
// This mock will never return data (it loads forever):
await renderCourseLibrariesReviewPage(mockGetEntityLinks.courseKeyLoading);
const spinner = await screen.findByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
it('shows empty state when no readyToSync links are present', async () => {
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKeyUpToDate);
const emptyMsg = await screen.findByText('All components are up to date');
expect(emptyMsg).toBeInTheDocument();
});
it('shows all readyToSync links', async () => {
await renderCourseLibrariesReviewPage();
const updateBtns = await screen.findAllByRole('button', { name: 'Update' });
expect(updateBtns.length).toEqual(7);
const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' });
expect(ignoreBtns.length).toEqual(7);
});
test.each([
{
label: 'update changes works with components',
itemIndex: 0,
expectedToastMsg: 'Success! "Dropdown" is updated',
},
{
label: 'update changes works with containers',
itemIndex: 5,
expectedToastMsg: 'Success! "Unit 1" is updated',
},
])('$label', async ({ itemIndex, expectedToastMsg }) => {
const user = userEvent.setup();
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response[itemIndex].downstreamUsageKey;
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const updateBtns = await screen.findAllByRole('button', { name: 'Update' });
expect(updateBtns.length).toEqual(7);
await user.click(updateBtns[itemIndex]);
await waitFor(() => {
expect(axiosMock.history.post.length).toEqual(1);
});
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg);
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] });
});
test.each([
{
label: 'update changes works in preview modal with components',
itemIndex: 0,
expectedToastMsg: 'Success! "Dropdown" is updated',
},
{
label: 'update changes works in preview modal with containers',
itemIndex: 5,
expectedToastMsg: 'Success! "Unit 1" is updated',
},
])('$label', async ({ itemIndex, expectedToastMsg }) => {
const user = userEvent.setup();
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response[itemIndex].downstreamUsageKey;
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
expect(previewBtns.length).toEqual(7);
await user.click(previewBtns[itemIndex]);
const dialog = await screen.findByRole('dialog');
const confirmBtn = await within(dialog).findByRole('button', { name: 'Accept changes' });
await user.click(confirmBtn);
await waitFor(() => {
expect(axiosMock.history.post.length).toEqual(1);
});
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg);
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] });
});
test.each([
{
label: 'ignore change works with components',
itemIndex: 0,
expectedToastMsg: '"Dropdown" will remain out of sync with library content. You will be notified when this component is updated again.',
},
{
label: 'ignore change works with containers',
itemIndex: 5,
expectedToastMsg: '"Unit 1" will remain out of sync with library content. You will be notified when this component is updated again.',
},
])('$label', async ({ itemIndex, expectedToastMsg }) => {
const user = userEvent.setup();
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response[itemIndex].downstreamUsageKey;
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' });
expect(ignoreBtns.length).toEqual(7);
// Show confirmation modal on clicking ignore.
await user.click(ignoreBtns[itemIndex]);
const dialog = await screen.findByRole('dialog', { name: 'Ignore these changes?' });
expect(dialog).toBeInTheDocument();
const confirmBtn = await within(dialog).findByRole('button', { name: 'Ignore' });
await user.click(confirmBtn);
await waitFor(() => {
expect(axiosMock.history.delete.length).toEqual(1);
});
expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey));
expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg);
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] });
});
test.each([
{
label: 'ignore change works with components',
itemIndex: 0,
expectedToastMsg: '"Dropdown" will remain out of sync with library content. You will be notified when this component is updated again.',
},
{
label: 'ignore change works with containers',
itemIndex: 5,
expectedToastMsg: '"Unit 1" will remain out of sync with library content. You will be notified when this component is updated again.',
},
])('$label', async ({ itemIndex, expectedToastMsg }) => {
const user = userEvent.setup();
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response[itemIndex].downstreamUsageKey;
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
expect(previewBtns.length).toEqual(7);
await user.click(previewBtns[itemIndex]);
const previewDialog = await screen.findByRole('dialog');
const ignoreBtn = await within(previewDialog).findByRole('button', { name: 'Ignore changes' });
await user.click(ignoreBtn);
// Show confirmation modal on clicking ignore.
const dialog = await screen.findByRole('dialog', { name: 'Ignore these changes?' });
expect(dialog).toBeInTheDocument();
const confirmBtn = await within(dialog).findByRole('button', { name: 'Ignore' });
await user.click(confirmBtn);
await waitFor(() => {
expect(axiosMock.history.delete.length).toEqual(1);
});
expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey));
expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg);
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] });
});
it('should show sync modal with local changes', async () => {
const itemIndex = 3;
const user = userEvent.setup();
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
expect(previewBtns.length).toEqual(7);
await user.click(previewBtns[itemIndex]);
expect(screen.getByText('This library content has local edits.')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /course content/i })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /published library content/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /update to published library content/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /keep course content/i })).toBeInTheDocument();
});
});