Skip to content
Open
125 changes: 118 additions & 7 deletions src/CourseAuthoringPage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import CourseAuthoringPage from './CourseAuthoringPage';
import PagesAndResources from './pages-and-resources/PagesAndResources';
import { executeThunk } from './utils';
import { fetchCourseApps } from './pages-and-resources/data/thunks';
import { fetchCourseDetail } from './data/thunks';
import { fetchCourseDetail, retryConfig } from './data/thunks';
import { getApiWaffleFlagsUrl } from './data/api';
import { initializeMocks, render } from './testUtils';

Expand Down Expand Up @@ -43,8 +43,7 @@ describe('Editor Pages Load no header', () => {
const wrapper = render(
<CourseAuthoringPage courseId={courseId}>
<PagesAndResources courseId={courseId} />
</CourseAuthoringPage>
,
</CourseAuthoringPage>,
);
expect(wrapper.queryByRole('status')).not.toBeInTheDocument();
});
Expand All @@ -54,8 +53,7 @@ describe('Editor Pages Load no header', () => {
const wrapper = render(
<CourseAuthoringPage courseId={courseId}>
<PagesAndResources courseId={courseId} />
</CourseAuthoringPage>
,
</CourseAuthoringPage>,
);
expect(wrapper.queryByRole('status')).toBeInTheDocument();
});
Expand All @@ -64,6 +62,15 @@ describe('Editor Pages Load no header', () => {
describe('Course authoring page', () => {
const lmsApiBaseUrl = getConfig().LMS_BASE_URL;
const courseDetailApiUrl = `${lmsApiBaseUrl}/api/courses/v1/courses`;

beforeAll(() => {
retryConfig.enabled = false;
});

afterAll(() => {
retryConfig.enabled = true;
});

const mockStoreNotFound = async () => {
axiosMock.onGet(
`${courseDetailApiUrl}/${courseId}?username=abc123`,
Expand All @@ -72,6 +79,7 @@ describe('Course authoring page', () => {
});
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
};

const mockStoreError = async () => {
axiosMock.onGet(
`${courseDetailApiUrl}/${courseId}?username=abc123`,
Expand All @@ -80,11 +88,13 @@ describe('Course authoring page', () => {
});
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
};

test('renders not found page on non-existent course key', async () => {
await mockStoreNotFound();
const wrapper = render(<CourseAuthoringPage courseId={courseId} />);
expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument();
});

test('does not render not found page on other kinds of error', async () => {
await mockStoreError();
// Currently, loading errors are not handled, so we wait for the child
Expand All @@ -95,12 +105,12 @@ describe('Course authoring page', () => {
const wrapper = render(
<CourseAuthoringPage courseId={courseId}>
<div data-testid={contentTestId} />
</CourseAuthoringPage>
,
</CourseAuthoringPage>,
);
expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument();
expect(wrapper.queryByTestId('notFoundAlert')).not.toBeInTheDocument();
});

const mockStoreDenied = async () => {
const studioApiBaseUrl = getConfig().STUDIO_BASE_URL;
const courseAppsApiUrl = `${studioApiBaseUrl}/api/course_apps/v1/apps`;
Expand All @@ -110,6 +120,7 @@ describe('Course authoring page', () => {
).reply(403);
await executeThunk(fetchCourseApps(courseId), store.dispatch);
};

test('renders PermissionDeniedAlert when courseAppsApiStatus is DENIED', async () => {
mockPathname = '/editor/';
await mockStoreDenied();
Expand All @@ -118,3 +129,103 @@ describe('Course authoring page', () => {
expect(await wrapper.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
});
});

// New test suite for retry logic
describe('fetchCourseDetail retry logic', () => {
const lmsApiBaseUrl = getConfig().LMS_BASE_URL;
const courseDetailApiUrl = `${lmsApiBaseUrl}/api/courses/v1/courses`;

beforeAll(() => {
retryConfig.enabled = true;
retryConfig.maxRetries = 3;
retryConfig.initialDelay = 10;
});

afterAll(() => {
retryConfig.enabled = false;
});

test('retries on 404 and eventually succeeds', async () => {
const courseDetail = {
id: courseId,
name: 'Test Course',
start: new Date().toISOString(),
};

axiosMock
.onGet(`${courseDetailApiUrl}/${courseId}?username=abc123`)
.replyOnce(404)
.onGet(`${courseDetailApiUrl}/${courseId}?username=abc123`)
.replyOnce(200, courseDetail);

await executeThunk(fetchCourseDetail(courseId), store.dispatch);

const state = store.getState();
expect(state.courseDetail.courseId).toBe(courseId);
expect(state.courseDetail.status).toBe('successful');
});

test('retries on 202 and eventually succeeds', async () => {
const courseDetail = {
id: courseId,
name: 'Test Course',
start: new Date().toISOString(),
};

axiosMock
.onGet(`${courseDetailApiUrl}/${courseId}?username=abc123`)
.replyOnce(202, { error: 'course_not_ready' })
.onGet(`${courseDetailApiUrl}/${courseId}?username=abc123`)
.replyOnce(200, courseDetail);

await executeThunk(fetchCourseDetail(courseId), store.dispatch);

const state = store.getState();
expect(state.courseDetail.status).toBe('successful');
});

test('gives up after max retries on persistent 404', async () => {
axiosMock
.onGet(`${courseDetailApiUrl}/${courseId}?username=abc123`)
.reply(404);

await executeThunk(fetchCourseDetail(courseId), store.dispatch);

const state = store.getState();
expect(state.courseDetail.status).toBe('not-found');
});

test('does not retry on 500 errors', async () => {
axiosMock
.onGet(`${courseDetailApiUrl}/${courseId}?username=abc123`)
.reply(500);

await executeThunk(fetchCourseDetail(courseId), store.dispatch);

const state = store.getState();
expect(state.courseDetail.status).toBe('failed');

expect(axiosMock.history.get.filter(
req => req.url.includes(courseId),
).length).toBe(1);
});

test('respects retryConfig.enabled flag', async () => {
retryConfig.enabled = false;

axiosMock
.onGet(`${courseDetailApiUrl}/${courseId}?username=abc123`)
.reply(404);

await executeThunk(fetchCourseDetail(courseId), store.dispatch);

const state = store.getState();
expect(state.courseDetail.status).toBe('not-found');

expect(axiosMock.history.get.filter(
req => req.url.includes(courseId),
).length).toBe(1);

retryConfig.enabled = true;
});
});
54 changes: 54 additions & 0 deletions src/course-outline/CourseOutline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
getXBlockBaseApiUrl,
exportTags,
createDiscussionsTopicsUrl,
getCourseOutlineIndex,
} from './data/api';
import {
fetchCourseBestPracticesQuery,
Expand Down Expand Up @@ -132,6 +133,10 @@ jest.mock('@src/studio-home/data/selectors', () => ({
}),
}));

jest.mock('./data/api', () => ({
getCourseOutlineIndex: jest.fn(),
}));

// eslint-disable-next-line no-promise-executor-return
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

Expand Down Expand Up @@ -2484,3 +2489,52 @@ describe('<CourseOutline />', () => {
expect(axiosMock.history.delete[0].url).toBe(getDownstreamApiUrl(courseSectionMock.id));
});
});

describe('retryOnNotReady lightweight coverage', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});

it('retries on 202 then succeeds', async () => {
const mockDispatch = jest.fn();

(getCourseOutlineIndex as jest.Mock)
.mockRejectedValueOnce({ response: { status: 202 } })
.mockRejectedValueOnce({ response: { status: 404 } })
.mockResolvedValueOnce({
courseReleaseDate: '2025-10-29',
courseStructure: {
highlightsEnabledForMessaging: true,
videoSharingEnabled: true,
videoSharingOptions: [],
actions: {},
},
});

const thunk = fetchCourseOutlineIndexQuery(courseId);
const promise = thunk(mockDispatch);

jest.runAllTimers();
await promise;

expect(getCourseOutlineIndex).toHaveBeenCalledTimes(3);
// Verifica que terminó con éxito
expect(mockDispatch).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({ status: RequestStatus.SUCCESSFUL }),
}),
);
});

it('throws after max retries', async () => {
const mockDispatch = jest.fn();
(getCourseOutlineIndex as jest.Mock).mockRejectedValue({ response: { status: 202 } });

const thunk = fetchCourseOutlineIndexQuery(courseId);
const promise = thunk(mockDispatch);

jest.runAllTimers();
await expect(promise).rejects.toBeDefined();
});
});
39 changes: 37 additions & 2 deletions src/course-outline/data/thunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,40 @@ import {
updateCourseLaunchQueryStatus,
} from './slice';

/**
* Action to fetch course outline.
*
* @param {string} courseId - ID of the course
* @returns {Object} - Object containing fetch course outline index query success or failure status
*/

/**
* Helper function to retry API calls when course is not ready yet
*/
async function retryOnNotReady<T>(
apiCall: () => Promise<T>,
maxRetries: number = 5,
delayMs: number = 2000,
attempt: number = 1,
): Promise<T> {
try {
return await apiCall();
} catch (error: any) {
const isNotReady = error?.response?.status === 202
|| error?.response?.status === 404;
const canRetry = attempt < maxRetries;

if (isNotReady && canRetry) {
await new Promise((resolve) => {
setTimeout(resolve, delayMs);
});
return retryOnNotReady(apiCall, maxRetries, delayMs, attempt + 1);
}

throw error;
}
}

/**
* Action to fetch course outline.
*
Expand All @@ -67,7 +101,8 @@ export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any)
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS }));

try {
const outlineIndex = await getCourseOutlineIndex(courseId);
const outlineIndex = await retryOnNotReady(() => getCourseOutlineIndex(courseId));

const {
courseReleaseDate,
courseStructure: {
Expand All @@ -77,6 +112,7 @@ export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any)
actions,
},
} = outlineIndex;

dispatch(fetchOutlineIndexSuccess(outlineIndex));
dispatch(updateStatusBar({
courseReleaseDate,
Expand All @@ -85,7 +121,6 @@ export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any)
videoSharingEnabled,
}));
dispatch(updateCourseActions(actions));

dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error: any) {
if (error.response && error.response.status === 403) {
Expand Down
Loading
Loading