Feat course optimizer page (#1533)

Course Optimizer is a feature approved by the Openedx community that adds a "Course Optimizer" page to studio where users can run a scan of a course for broken links - links that point to pages that have a 404.

Depends on backend: openedx/edx-platform#35887 - test together.

This also requires adding a nav menu item to edx-platform legacy studio. That should be implemented before enabling the waffle flag on prod.

Links:
- [Internal JIRA ticket](https://2u-internal.atlassian.net/browse/TNL-11809)
- [Course Optimizer Discovery](https://2u-internal.atlassian.net/wiki/spaces/TNL/pages/1426587703/TNL-11744+Course+Optimizer+Discovery)
- [Openedx community proposal](https://github.com/openedx/platform-roadmap/issues/388)
This commit is contained in:
Jesper Hodge
2025-01-13 11:44:25 -05:00
committed by GitHub
parent e6bce560bc
commit 8385c4e8ed
29 changed files with 1724 additions and 8 deletions

View File

@@ -0,0 +1,193 @@
import { startLinkCheck, fetchLinkCheckStatus } from './thunks';
import * as api from './api';
import { LINK_CHECK_STATUSES } from './constants';
import { RequestStatus } from '../../data/constants';
import mockApiResponse from '../mocks/mockApiResponse';
describe('startLinkCheck thunk', () => {
const dispatch = jest.fn();
const getState = jest.fn();
const courseId = 'course-123';
let mockGetStartLinkCheck;
beforeEach(() => {
jest.clearAllMocks();
mockGetStartLinkCheck = jest.spyOn(api, 'postLinkCheck').mockResolvedValue({
linkCheckStatus: LINK_CHECK_STATUSES.IN_PROGRESS,
});
});
describe('successful request', () => {
it('should set link check stage and request statuses to their in-progress states', async () => {
const inProgressStageId = 1;
await startLinkCheck(courseId)(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({
payload: { status: RequestStatus.PENDING },
type: 'courseOptimizer/updateSavingStatus',
});
expect(dispatch).toHaveBeenCalledWith({
payload: true,
type: 'courseOptimizer/updateLinkCheckInProgress',
});
expect(dispatch).toHaveBeenCalledWith({
payload: { status: RequestStatus.SUCCESSFUL },
type: 'courseOptimizer/updateSavingStatus',
});
expect(dispatch).toHaveBeenCalledWith({
payload: inProgressStageId,
type: 'courseOptimizer/updateCurrentStage',
});
});
});
describe('failed request should set stage and request ', () => {
it('should set request status to failed', async () => {
mockGetStartLinkCheck.mockRejectedValue(new Error('error'));
await startLinkCheck(courseId)(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({
payload: { status: RequestStatus.FAILED },
type: 'courseOptimizer/updateSavingStatus',
});
expect(dispatch).toHaveBeenCalledWith({
payload: false,
type: 'courseOptimizer/updateLinkCheckInProgress',
});
expect(dispatch).toHaveBeenCalledWith({
payload: -1,
type: 'courseOptimizer/updateCurrentStage',
});
});
});
});
describe('fetchLinkCheckStatus thunk', () => {
const dispatch = jest.fn();
const getState = jest.fn();
const courseId = 'course-123';
beforeEach(() => {
jest.clearAllMocks();
});
describe('successful request', () => {
it('should return scan result', async () => {
jest
.spyOn(api, 'getLinkCheckStatus')
.mockResolvedValue({
linkCheckStatus: mockApiResponse.LinkCheckStatus,
linkCheckOutput: mockApiResponse.LinkCheckOutput,
linkCheckCreatedAt: mockApiResponse.LinkCheckCreatedAt,
});
await fetchLinkCheckStatus(courseId)(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({
payload: false,
type: 'courseOptimizer/updateLinkCheckInProgress',
});
expect(dispatch).toHaveBeenCalledWith({
payload: 2,
type: 'courseOptimizer/updateCurrentStage',
});
expect(dispatch).toHaveBeenCalledWith({
payload: mockApiResponse.LinkCheckOutput,
type: 'courseOptimizer/updateLinkCheckResult',
});
expect(dispatch).toHaveBeenCalledWith({
payload: { status: RequestStatus.SUCCESSFUL },
type: 'courseOptimizer/updateLoadingStatus',
});
});
it('with link check in progress should set current stage to 1', async () => {
jest
.spyOn(api, 'getLinkCheckStatus')
.mockResolvedValue({
linkCheckStatus: LINK_CHECK_STATUSES.IN_PROGRESS,
});
await fetchLinkCheckStatus(courseId)(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({
payload: 1,
type: 'courseOptimizer/updateCurrentStage',
});
});
});
describe('failed request', () => {
it('should set request status to failed', async () => {
jest
.spyOn(api, 'getLinkCheckStatus')
.mockRejectedValue(new Error('error'));
await fetchLinkCheckStatus(courseId)(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({
payload: { status: RequestStatus.FAILED },
type: 'courseOptimizer/updateLoadingStatus',
});
});
});
describe('unauthorized request', () => {
it('should set request status to denied', async () => {
jest.spyOn(api, 'getLinkCheckStatus').mockRejectedValue({ response: { status: 403 } });
await fetchLinkCheckStatus(courseId)(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({
payload: { status: RequestStatus.DENIED },
type: 'courseOptimizer/updateLoadingStatus',
});
});
});
describe('failed scan', () => {
it('should set error message', async () => {
jest
.spyOn(api, 'getLinkCheckStatus')
.mockResolvedValue({
linkCheckStatus: LINK_CHECK_STATUSES.FAILED,
linkCheckOutput: mockApiResponse.LinkCheckOutput,
linkCheckCreatedAt: mockApiResponse.LinkCheckCreatedAt,
});
await fetchLinkCheckStatus(courseId)(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({
payload: true,
type: 'courseOptimizer/updateIsErrorModalOpen',
});
expect(dispatch).toHaveBeenCalledWith({
payload: { msg: 'Link Check Failed' },
type: 'courseOptimizer/updateError',
});
expect(dispatch).toHaveBeenCalledWith({
payload: { status: RequestStatus.SUCCESSFUL },
type: 'courseOptimizer/updateLoadingStatus',
});
expect(dispatch).toHaveBeenCalledWith({
payload: -1,
type: 'courseOptimizer/updateCurrentStage',
});
expect(dispatch).not.toHaveBeenCalledWith({
payload: expect.anything(),
type: 'courseOptimizer/updateLinkCheckResult',
});
});
});
});