diff --git a/src/CourseAuthoringPage.test.jsx b/src/CourseAuthoringPage.test.jsx index 6122056c23..e3ca1f314f 100644 --- a/src/CourseAuthoringPage.test.jsx +++ b/src/CourseAuthoringPage.test.jsx @@ -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'; @@ -43,8 +43,7 @@ describe('Editor Pages Load no header', () => { const wrapper = render( - - , + , ); expect(wrapper.queryByRole('status')).not.toBeInTheDocument(); }); @@ -54,8 +53,7 @@ describe('Editor Pages Load no header', () => { const wrapper = render( - - , + , ); expect(wrapper.queryByRole('status')).toBeInTheDocument(); }); @@ -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`, @@ -72,6 +79,7 @@ describe('Course authoring page', () => { }); await executeThunk(fetchCourseDetail(courseId), store.dispatch); }; + const mockStoreError = async () => { axiosMock.onGet( `${courseDetailApiUrl}/${courseId}?username=abc123`, @@ -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(); 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 @@ -95,12 +105,12 @@ describe('Course authoring page', () => { const wrapper = render(
- - , + , ); 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`; @@ -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(); @@ -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; + }); +}); diff --git a/src/course-outline/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx index d5003f9eab..6492d28a58 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -27,6 +27,7 @@ import { getXBlockBaseApiUrl, exportTags, createDiscussionsTopicsUrl, + getCourseOutlineIndex, } from './data/api'; import { fetchCourseBestPracticesQuery, @@ -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)); @@ -2484,3 +2489,52 @@ describe('', () => { 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(); + }); +}); diff --git a/src/course-outline/data/thunk.ts b/src/course-outline/data/thunk.ts index 71157d11c1..d4cfdcda2e 100644 --- a/src/course-outline/data/thunk.ts +++ b/src/course-outline/data/thunk.ts @@ -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( + apiCall: () => Promise, + maxRetries: number = 5, + delayMs: number = 2000, + attempt: number = 1, +): Promise { + 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. * @@ -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: { @@ -77,6 +112,7 @@ export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any) actions, }, } = outlineIndex; + dispatch(fetchOutlineIndexSuccess(outlineIndex)); dispatch(updateStatusBar({ courseReleaseDate, @@ -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) { diff --git a/src/data/thunks.ts b/src/data/thunks.ts index bbfc86340b..1d0901ca45 100644 --- a/src/data/thunks.ts +++ b/src/data/thunks.ts @@ -7,20 +7,76 @@ import { } from './slice'; import { RequestStatus } from './constants'; -export function fetchCourseDetail(courseId) { - return async (dispatch) => { +/** + * Retry configuration - can be overridden in tests + */ +export const retryConfig = { + maxRetries: 10, + initialDelay: 2000, + backoffMultiplier: 1.5, + enabled: true, +}; + +/** + * Retry an API call if the course is not ready yet (202 or 404 status). + * Uses exponential backoff for retries. + */ +async function retryOnNotReady( + apiCall: () => Promise, + maxRetries: number = retryConfig.maxRetries, + delayMs: number = retryConfig.initialDelay, + attempt: number = 1, + backoffMultiplier: number = retryConfig.backoffMultiplier, +): Promise { + // Skip retries if disabled (useful for tests) + if (!retryConfig.enabled) { + return apiCall(); + } + + try { + return await apiCall(); + } catch (error: any) { + const isNotReady = error?.response?.status === 202 + || error?.response?.status === 404; + const canRetry = attempt < maxRetries; + + if (isNotReady && canRetry) { + const nextDelay = delayMs * backoffMultiplier ** (attempt - 1); + + await new Promise((resolve) => { + setTimeout(resolve, nextDelay); + }); + + return retryOnNotReady( + apiCall, + maxRetries, + delayMs, + attempt + 1, + backoffMultiplier, + ); + } + + throw error; + } +} + +export function fetchCourseDetail(courseId: string) { + return async (dispatch: any) => { dispatch(updateStatus({ courseId, status: RequestStatus.IN_PROGRESS })); try { - const courseDetail = await getCourseDetail(courseId, getAuthenticatedUser().username); - dispatch(updateStatus({ courseId, status: RequestStatus.SUCCESSFUL })); + const courseDetail = await retryOnNotReady( + () => getCourseDetail(courseId, getAuthenticatedUser().username), + ); + dispatch(updateStatus({ courseId, status: RequestStatus.SUCCESSFUL })); dispatch(addModel({ modelType: 'courseDetails', model: courseDetail })); dispatch(updateCanChangeProviders({ - canChangeProviders: getAuthenticatedUser().administrator || new Date(courseDetail.start) > new Date(), + canChangeProviders: getAuthenticatedUser().administrator + || new Date(courseDetail.start) > new Date(), })); - } catch (error) { - if ((error as any).response && (error as any).response.status === 404) { + } catch (error: any) { + if (error?.response?.status === 404) { dispatch(updateStatus({ courseId, status: RequestStatus.NOT_FOUND })); } else { dispatch(updateStatus({ courseId, status: RequestStatus.FAILED }));