Files
frontend-app-authoring/src/optimizer-page/CourseOptimizerPage.test.js
Pandi Ganesh 472d77823f feat: Enhance Course Optimizer Page with Previous Run Links and Improved UI (#2356)
* feat: enhance course optimizer page design in studio

* feat: enhance course optimizer with prev run links update

* fix: increase container size and resolve style issues

* fix: enhance code structure and i18n support
2025-09-02 17:01:28 +05:00

527 lines
21 KiB
JavaScript

/* eslint-disable @typescript-eslint/no-shadow */
/* eslint-disable react/jsx-filename-extension */
import {
fireEvent, render, waitFor, screen,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import initializeStore from '../store';
import messages from './messages';
import generalMessages from '../messages';
import scanResultsMessages from './scan-results/messages';
import CourseOptimizerPage, { pollLinkCheckDuringScan, pollRerunLinkUpdateDuringUpdate, pollRerunLinkUpdateStatus } from './CourseOptimizerPage';
import { postLinkCheckCourseApiUrl, getLinkCheckStatusApiUrl } from './data/api';
import {
mockApiResponse,
mockApiResponseForNoResultFound,
mockApiResponseWithPreviousRunLinks,
mockApiResponseEmpty,
} from './mocks/mockApiResponse';
import * as thunks from './data/thunks';
import { useWaffleFlags } from '../data/apiHooks';
let store;
let axiosMock;
const courseId = '123';
const courseName = 'About Node JS';
jest.mock('../generic/model-store', () => ({
useModel: jest.fn().mockReturnValue({
name: courseName,
}),
}));
// Mock the waffle flags hook
jest.mock('../data/apiHooks', () => ({
useWaffleFlags: jest.fn(() => ({
enableCourseOptimizerCheckPrevRunLinks: false,
})),
}));
jest.mock('../generic/model-store', () => ({
useModel: jest.fn().mockReturnValue({
name: 'About Node JS',
}),
}));
const OptimizerPage = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<CourseOptimizerPage courseId={courseId} />
</IntlProvider>
</AppProvider>
);
const setupOptimizerPage = async (apiResponse = mockApiResponse) => {
axiosMock.onGet(getLinkCheckStatusApiUrl(courseId)).reply(200, apiResponse);
const optimizerPage = render(<OptimizerPage />);
// Click the scan button
fireEvent.click(optimizerPage.getByText(messages.buttonTitle.defaultMessage));
// Wait for the scan results to load
await waitFor(() => {
expect(optimizerPage.getByText('Introduction to Programming')).toBeInTheDocument();
});
// Click on filters button
fireEvent.click(optimizerPage.getByText(scanResultsMessages.filterButtonLabel.defaultMessage));
return optimizerPage;
};
describe('CourseOptimizerPage', () => {
describe('pollLinkCheckDuringScan', () => {
let mockFetchLinkCheckStatus;
beforeEach(() => {
mockFetchLinkCheckStatus = jest.fn();
jest.spyOn(thunks, 'fetchLinkCheckStatus').mockImplementation(mockFetchLinkCheckStatus);
jest.useFakeTimers();
jest.spyOn(global, 'setInterval').mockImplementation((cb) => { cb(); return true; });
});
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
jest.restoreAllMocks();
});
it('should start polling if linkCheckInProgress has never been started (is null)', () => {
const linkCheckInProgress = null;
const interval = { current: null };
const dispatch = jest.fn();
const courseId = 'course-123';
pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId);
expect(interval.current).toBeTruthy();
expect(mockFetchLinkCheckStatus).toHaveBeenCalled();
});
it('should start polling if link check is in progress', () => {
const linkCheckInProgress = true;
const interval = { current: null };
const dispatch = jest.fn();
const courseId = 'course-123';
pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId);
expect(interval.current).toBeTruthy();
});
it('should not start polling if link check is not in progress', () => {
const linkCheckInProgress = false;
const interval = { current: null };
const dispatch = jest.fn();
const courseId = 'course-123';
pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId);
expect(interval.current).toBeFalsy();
});
it('should clear the interval if link check is finished', () => {
const linkCheckInProgress = false;
const interval = { current: 1 };
const dispatch = jest.fn();
const courseId = 'course-123';
pollLinkCheckDuringScan(linkCheckInProgress, interval, dispatch, courseId);
expect(interval.current).toBeUndefined();
});
});
describe('CourseOptimizerPage component', () => {
beforeEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onPost(postLinkCheckCourseApiUrl(courseId))
.reply(200, { LinkCheckStatus: 'In-Progress' });
axiosMock
.onGet(getLinkCheckStatusApiUrl(courseId))
.reply(200, mockApiResponse);
});
it('should render the component', () => {
const { getByText, queryByText } = render(<OptimizerPage />);
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.buttonTitle.defaultMessage)).toBeInTheDocument();
expect(queryByText(messages.preparingStepTitle)).not.toBeInTheDocument();
});
it('should start scan after clicking the scan button', async () => {
const { getByText } = render(<OptimizerPage />);
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
await waitFor(() => {
expect(getByText(messages.preparingStepTitle.defaultMessage)).toBeInTheDocument();
});
});
it('should show no broken links found message', async () => {
axiosMock
.onGet(getLinkCheckStatusApiUrl(courseId))
.reply(200, { LinkCheckStatus: 'Succeeded' });
const { getByText } = render(<OptimizerPage />);
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
await waitFor(() => {
expect(getByText(scanResultsMessages.noResultsFound.defaultMessage)).toBeInTheDocument();
});
});
it('should show an error state in the scan stepper if request does not go through', async () => {
axiosMock
.onPost(postLinkCheckCourseApiUrl(courseId))
.reply(500);
render(<OptimizerPage />);
expect(screen.getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
fireEvent.click(screen.getByText(messages.buttonTitle.defaultMessage));
await waitFor(() => {
expect(screen.getByText(generalMessages.supportText.defaultMessage)).toBeInTheDocument();
});
});
it('should show only locked links when lockedLinks filter is selected', async () => {
const {
getByText,
getByLabelText,
queryByText,
container,
} = await setupOptimizerPage();
// Check if the modal is opened
expect(getByText('Locked')).toBeInTheDocument();
// Select the locked links checkbox
fireEvent.click(getByLabelText(scanResultsMessages.lockedLabel.defaultMessage));
const collapsibleTrigger = container.querySelector('.collapsible-trigger');
expect(collapsibleTrigger).toBeInTheDocument();
fireEvent.click(collapsibleTrigger);
await waitFor(() => {
expect(getByText('https://example.com/locked-link')).toBeInTheDocument();
expect(queryByText('https://example.com/broken-link')).not.toBeInTheDocument();
expect(queryByText('https://outsider.com/forbidden-link')).not.toBeInTheDocument();
});
});
it('should show only broken links when brokenLinks filter is selected', async () => {
const {
getByText,
getByLabelText,
queryByText,
container,
} = await setupOptimizerPage();
// Check if the modal is opened
expect(getByText('Broken')).toBeInTheDocument();
// Select the broken links checkbox
fireEvent.click(getByLabelText(scanResultsMessages.brokenLabel.defaultMessage));
const collapsibleTrigger = container.querySelector('.collapsible-trigger');
expect(collapsibleTrigger).toBeInTheDocument();
fireEvent.click(collapsibleTrigger);
await waitFor(() => {
expect(getByText('https://example.com/broken-link')).toBeInTheDocument();
expect(queryByText('https://example.com/locked-link')).not.toBeInTheDocument();
expect(queryByText('https://outsider.com/forbidden-link')).not.toBeInTheDocument();
});
});
it('should show only manual links when manualLinks filter is selected and show all links when clicked again', async () => {
const {
getByText,
getByLabelText,
queryByText,
container,
} = await setupOptimizerPage();
// Check if the modal is opened
expect(getByText('Manual')).toBeInTheDocument();
// Select the manual links checkbox
fireEvent.click(getByLabelText(scanResultsMessages.manualLabel.defaultMessage));
const collapsibleTrigger = container.querySelector('.collapsible-trigger');
expect(collapsibleTrigger).toBeInTheDocument();
fireEvent.click(collapsibleTrigger);
await waitFor(() => {
expect(getByText('https://outsider.com/forbidden-link')).toBeInTheDocument();
expect(queryByText('https://example.com/broken-link')).not.toBeInTheDocument();
expect(queryByText('https://example.com/locked-link')).not.toBeInTheDocument();
});
// Click the manual links checkbox again to clear the filter
fireEvent.click(getByLabelText(scanResultsMessages.manualLabel.defaultMessage));
// Assert that all links are displayed after clearing the filter
await waitFor(() => {
expect(getByText('https://example.com/broken-link')).toBeInTheDocument();
expect(getByText('https://outsider.com/forbidden-link')).toBeInTheDocument();
expect(getByText('https://example.com/locked-link')).toBeInTheDocument();
});
});
it('should show only manual & locked links when manual & locked Links filters are selected, ignore broken links', async () => {
const {
getByText,
getByLabelText,
queryByText,
container,
} = await setupOptimizerPage();
// Check if the modal is opened
expect(getByText('Manual')).toBeInTheDocument();
expect(getByText('Locked')).toBeInTheDocument();
// Select the manual & locked links checkbox
fireEvent.click(getByLabelText(scanResultsMessages.manualLabel.defaultMessage));
fireEvent.click(getByLabelText(scanResultsMessages.lockedLabel.defaultMessage));
const collapsibleTrigger = container.querySelector('.collapsible-trigger');
expect(collapsibleTrigger).toBeInTheDocument();
fireEvent.click(collapsibleTrigger);
await waitFor(() => {
expect(getByText('https://outsider.com/forbidden-link')).toBeInTheDocument();
expect(getByText('https://example.com/locked-link')).toBeInTheDocument();
expect(queryByText('https://example.com/broken-link')).not.toBeInTheDocument();
});
});
it('should show all links when all filters are selected', async () => {
const {
getByText,
getByLabelText,
container,
} = await setupOptimizerPage();
// Check if the modal is opened
expect(getByText('Broken')).toBeInTheDocument();
expect(getByText('Manual')).toBeInTheDocument();
expect(getByText('Locked')).toBeInTheDocument();
// Select the all checkboxes
fireEvent.click(getByLabelText(scanResultsMessages.brokenLabel.defaultMessage));
fireEvent.click(getByLabelText(scanResultsMessages.lockedLabel.defaultMessage));
fireEvent.click(getByLabelText(scanResultsMessages.manualLabel.defaultMessage));
const collapsibleTrigger = container.querySelector('.collapsible-trigger');
expect(collapsibleTrigger).toBeInTheDocument();
fireEvent.click(collapsibleTrigger);
await waitFor(() => {
expect(getByText('https://example.com/broken-link')).toBeInTheDocument();
expect(getByText('https://outsider.com/forbidden-link')).toBeInTheDocument();
expect(getByText('https://example.com/locked-link')).toBeInTheDocument();
});
});
it('should show only manual links when the broken chip is clicked and show all links when clear filters button is clicked', async () => {
const {
getByText,
getByLabelText,
getByTestId,
queryByText,
container,
} = await setupOptimizerPage();
// Select broken & manual link checkboxes
fireEvent.click(getByLabelText(scanResultsMessages.brokenLabel.defaultMessage));
fireEvent.click(getByLabelText(scanResultsMessages.manualLabel.defaultMessage));
const collapsibleTrigger = container.querySelector('.collapsible-trigger');
expect(collapsibleTrigger).toBeInTheDocument();
fireEvent.click(collapsibleTrigger);
// Assert that both links are displayed
await waitFor(() => {
expect(getByText('https://example.com/broken-link')).toBeInTheDocument();
expect(getByText('https://outsider.com/forbidden-link')).toBeInTheDocument();
expect(queryByText('https://example.com/locked-link')).not.toBeInTheDocument();
});
// Click on the "Broken" chip to remove the broken filter (should leave only manual)
const brokenChip = getByTestId('chip-brokenLinks');
fireEvent.click(brokenChip);
// Assert that only manual links are displayed
await waitFor(() => {
expect(queryByText('https://example.com/broken-link')).not.toBeInTheDocument();
expect(getByText('https://outsider.com/forbidden-link')).toBeInTheDocument();
expect(queryByText('https://example.com/locked-link')).not.toBeInTheDocument();
});
// Click the "Clear filters" button
const clearFiltersButton = getByText(scanResultsMessages.clearFilters.defaultMessage);
fireEvent.click(clearFiltersButton);
// Assert that all links are displayed after clearing filters
await waitFor(() => {
expect(getByText('https://example.com/broken-link')).toBeInTheDocument();
expect(getByText('https://outsider.com/forbidden-link')).toBeInTheDocument();
expect(getByText('https://example.com/locked-link')).toBeInTheDocument();
});
});
it('should show no results found message when filter with no links is selected', async () => {
const {
getByText,
getByLabelText,
} = await setupOptimizerPage(mockApiResponseForNoResultFound);
// Check if the modal is opened
expect(getByText('Locked')).toBeInTheDocument();
// Select the broken links checkbox
fireEvent.click(getByLabelText(scanResultsMessages.lockedLabel.defaultMessage));
await waitFor(() => {
expect(getByText(scanResultsMessages.noResultsFound.defaultMessage)).toBeInTheDocument();
});
});
it('should always show no scan data message when data is empty', async () => {
axiosMock.onGet(getLinkCheckStatusApiUrl(courseId)).reply(200, mockApiResponseEmpty);
const { getByText } = render(<OptimizerPage />);
fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
await waitFor(() => {
expect(getByText(scanResultsMessages.noResultsFound.defaultMessage)).toBeInTheDocument();
});
});
describe('Previous Run Links Feature', () => {
beforeEach(() => {
// Enable the waffle flag for previous run links
useWaffleFlags.mockReturnValue({
enableCourseOptimizerCheckPrevRunLinks: true,
});
});
afterEach(() => {
// Reset to default (disabled)
useWaffleFlags.mockReturnValue({
enableCourseOptimizerCheckPrevRunLinks: false,
});
});
it('should show previous run links section when waffle flag is enabled and links exist', async () => {
axiosMock.onGet(getLinkCheckStatusApiUrl(courseId)).reply(200, mockApiResponseWithPreviousRunLinks);
const { getByText } = render(<OptimizerPage />);
fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
await waitFor(() => {
expect(getByText(scanResultsMessages.linkToPrevCourseRun.defaultMessage)).toBeInTheDocument();
});
});
it('should show no results found for previous run links when flag is enabled but no links exist', async () => {
axiosMock.onGet(getLinkCheckStatusApiUrl(courseId)).reply(200, mockApiResponseForNoResultFound);
const { getByText, getAllByText } = render(<OptimizerPage />);
fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
await waitFor(() => {
expect(getByText(scanResultsMessages.linkToPrevCourseRun.defaultMessage)).toBeInTheDocument();
// Should show "No results found" for previous run section
const noResultsElements = getAllByText(scanResultsMessages.noResultsFound.defaultMessage);
expect(noResultsElements.length).toBeGreaterThan(0);
});
});
it('should not show previous run links section when waffle flag is disabled', async () => {
// Disable the flag
useWaffleFlags.mockReturnValue({
enableCourseOptimizerCheckPrevRunLinks: false,
});
axiosMock.onGet(getLinkCheckStatusApiUrl(courseId)).reply(200, mockApiResponseWithPreviousRunLinks);
const { getByText, queryByText } = render(<OptimizerPage />);
fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
await waitFor(() => {
expect(queryByText(scanResultsMessages.linkToPrevCourseRun.defaultMessage)).not.toBeInTheDocument();
});
});
it('should handle previous run links in course updates and custom pages', async () => {
axiosMock.onGet(getLinkCheckStatusApiUrl(courseId)).reply(200, mockApiResponseWithPreviousRunLinks);
const { getByText, container } = render(<OptimizerPage />);
fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
await waitFor(() => {
expect(getByText(scanResultsMessages.linkToPrevCourseRun.defaultMessage)).toBeInTheDocument();
const prevRunSections = container.querySelectorAll('.scan-results');
expect(prevRunSections.length).toBeGreaterThan(1);
});
});
});
describe('CourseOptimizerPage polling helpers - rerun', () => {
beforeEach(() => {
jest.restoreAllMocks();
});
it('starts polling when shouldPoll is true', () => {
const mockDispatch = jest.fn();
const courseId = 'course-v1:Test+001';
// Mock setInterval to return a sentinel id
const intervalId = 123;
const setIntervalSpy = jest.spyOn(global, 'setInterval').mockImplementation(() => intervalId);
const intervalRef = { current: undefined };
// Call with rerunLinkUpdateInProgress true so shouldPoll === true
pollRerunLinkUpdateDuringUpdate(true, null, intervalRef, mockDispatch, courseId);
expect(setIntervalSpy).toHaveBeenCalled();
expect(intervalRef.current).toBe(intervalId);
});
it('clears existing interval when shouldPoll is false', () => {
const mockDispatch = jest.fn();
const courseId = 'course-v1:Test+002';
const clearIntervalSpy = jest.spyOn(global, 'clearInterval').mockImplementation(() => {});
const setIntervalSpy = jest.spyOn(global, 'setInterval').mockImplementation(() => 456);
const intervalRef = { current: 456 };
pollRerunLinkUpdateDuringUpdate(false, { status: 'Succeeded' }, intervalRef, mockDispatch, courseId);
expect(clearIntervalSpy).toHaveBeenCalledWith(456);
expect(intervalRef.current).toBeUndefined();
setIntervalSpy.mockRestore();
clearIntervalSpy.mockRestore();
});
it('pollRerunLinkUpdateStatus schedules dispatch at provided delay', () => {
jest.useFakeTimers();
const mockDispatch = jest.fn();
const courseId = 'course-v1:Test+003';
let capturedFn = null;
jest.spyOn(global, 'setInterval').mockImplementation((fn) => {
capturedFn = fn;
return 789;
});
const id = pollRerunLinkUpdateStatus(mockDispatch, courseId, 1000);
expect(id).toBe(789);
if (capturedFn) {
capturedFn();
}
expect(mockDispatch).toHaveBeenCalledWith(expect.any(Function));
jest.useRealTimers();
});
});
});
});