From 8aba6be132405a86165f3083b1f3ae17accab7a2 Mon Sep 17 00:00:00 2001 From: Andres Espinel Date: Sun, 26 Oct 2025 19:28:58 -0500 Subject: [PATCH 01/13] fix: added try mechanism --- src/course-outline/data/thunk.ts | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/course-outline/data/thunk.ts b/src/course-outline/data/thunk.ts index 71157d11c1..8e8881ee02 100644 --- a/src/course-outline/data/thunk.ts +++ b/src/course-outline/data/thunk.ts @@ -62,12 +62,36 @@ import { * @param {string} courseId - ID of the course * @returns {Object} - Object containing fetch course outline index query success or failure status */ +async function retryOnNotReady( + apiCall: () => Promise, + maxRetries: number = 5, + delay: number = 2000 +): Promise { + for (let i = 0; i < maxRetries; i++) { + try { + return await apiCall(); + } catch (error: any) { + // Check if it's a 202 "not ready" response + if (error?.response?.status === 202 && i < maxRetries - 1) { + console.log(`Course not ready, retrying in ${delay}ms... (attempt ${i + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + continue; + } + throw error; // Re-throw if not a retry-able error or out of retries + } + } + throw new Error('Max retries exceeded'); +} + +// Then modify your function to wrap the API call export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any) => Promise { + alert("allllllllllllllllll"); return async (dispatch) => { dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS })); - try { - const outlineIndex = await getCourseOutlineIndex(courseId); + // Wrap the API call with retry logic + const outlineIndex = await retryOnNotReady(() => getCourseOutlineIndex(courseId)); + const { courseReleaseDate, courseStructure: { @@ -85,7 +109,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) { From 2133e0b40abe668c08c51e94d8938aae0da77fe8 Mon Sep 17 00:00:00 2001 From: Andres Espinel Date: Sun, 26 Oct 2025 21:55:32 -0500 Subject: [PATCH 02/13] fix: added retry to course details fetch --- src/course-outline/data/thunk.ts | 29 +++--------------------- src/data/thunks.ts | 39 +++++++++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/src/course-outline/data/thunk.ts b/src/course-outline/data/thunk.ts index 8e8881ee02..71157d11c1 100644 --- a/src/course-outline/data/thunk.ts +++ b/src/course-outline/data/thunk.ts @@ -62,36 +62,12 @@ import { * @param {string} courseId - ID of the course * @returns {Object} - Object containing fetch course outline index query success or failure status */ -async function retryOnNotReady( - apiCall: () => Promise, - maxRetries: number = 5, - delay: number = 2000 -): Promise { - for (let i = 0; i < maxRetries; i++) { - try { - return await apiCall(); - } catch (error: any) { - // Check if it's a 202 "not ready" response - if (error?.response?.status === 202 && i < maxRetries - 1) { - console.log(`Course not ready, retrying in ${delay}ms... (attempt ${i + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - continue; - } - throw error; // Re-throw if not a retry-able error or out of retries - } - } - throw new Error('Max retries exceeded'); -} - -// Then modify your function to wrap the API call export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any) => Promise { - alert("allllllllllllllllll"); return async (dispatch) => { dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS })); - try { - // Wrap the API call with retry logic - const outlineIndex = await retryOnNotReady(() => getCourseOutlineIndex(courseId)); + try { + const outlineIndex = await getCourseOutlineIndex(courseId); const { courseReleaseDate, courseStructure: { @@ -109,6 +85,7 @@ 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..ab53034bbc 100644 --- a/src/data/thunks.ts +++ b/src/data/thunks.ts @@ -7,19 +7,52 @@ import { } from './slice'; import { RequestStatus } from './constants'; +// Función helper para reintentar cuando el curso no está listo +async function retryOnNotReady( + apiCall: () => Promise, + maxRetries: number = 10, + initialDelay: number = 2000, + backoffMultiplier: number = 1.5 +): Promise { + let delay = initialDelay; + + for (let i = 0; i < maxRetries; i++) { + try { + return await apiCall(); + } catch (error: any) { + const isNotReady = error?.response?.status === 202 || + (error?.response?.status === 404 && i < 3); + if (isNotReady && i < maxRetries - 1) { + console.log(`[CourseDetail] Course not ready, retrying in ${delay}ms... (attempt ${i + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + delay *= backoffMultiplier; // Incrementar el delay exponencialmente + continue; + } + throw error; + } + } + throw new Error('Max retries exceeded while waiting for course to be ready'); +} + export function fetchCourseDetail(courseId) { return async (dispatch) => { dispatch(updateStatus({ courseId, status: RequestStatus.IN_PROGRESS })); try { - const courseDetail = await getCourseDetail(courseId, getAuthenticatedUser().username); + // Envolver la llamada API con retry logic + const courseDetail = await retryOnNotReady( + () => getCourseDetail(courseId, getAuthenticatedUser().username), + 10, // maxRetries + 2000 // initialDelay (2 segundos) + ); dispatch(updateStatus({ courseId, status: RequestStatus.SUCCESSFUL })); - dispatch(addModel({ modelType: 'courseDetails', model: courseDetail })); dispatch(updateCanChangeProviders({ canChangeProviders: getAuthenticatedUser().administrator || new Date(courseDetail.start) > new Date(), })); } catch (error) { + console.error('[CourseDetail] Error fetching course detail:', error); + if ((error as any).response && (error as any).response.status === 404) { dispatch(updateStatus({ courseId, status: RequestStatus.NOT_FOUND })); } else { @@ -27,4 +60,4 @@ export function fetchCourseDetail(courseId) { } } }; -} +} \ No newline at end of file From b2c96ec7192493fe910baa7cc26247d15d401bdf Mon Sep 17 00:00:00 2001 From: Andres Espinel Date: Sun, 26 Oct 2025 23:19:51 -0500 Subject: [PATCH 03/13] chore: increased retries when 404 error is found --- src/data/thunks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/thunks.ts b/src/data/thunks.ts index ab53034bbc..0699242dad 100644 --- a/src/data/thunks.ts +++ b/src/data/thunks.ts @@ -21,7 +21,7 @@ async function retryOnNotReady( return await apiCall(); } catch (error: any) { const isNotReady = error?.response?.status === 202 || - (error?.response?.status === 404 && i < 3); + (error?.response?.status === 404 && i < 10); if (isNotReady && i < maxRetries - 1) { console.log(`[CourseDetail] Course not ready, retrying in ${delay}ms... (attempt ${i + 1}/${maxRetries})`); await new Promise(resolve => setTimeout(resolve, delay)); From 817ce4d92e4490cdcf97e11ca646e2c60483a86e Mon Sep 17 00:00:00 2001 From: Andres Espinel Date: Sun, 26 Oct 2025 23:52:45 -0500 Subject: [PATCH 04/13] chore: incresead initial delay --- src/data/thunks.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/data/thunks.ts b/src/data/thunks.ts index 0699242dad..3a2842a9f8 100644 --- a/src/data/thunks.ts +++ b/src/data/thunks.ts @@ -11,7 +11,7 @@ import { RequestStatus } from './constants'; async function retryOnNotReady( apiCall: () => Promise, maxRetries: number = 10, - initialDelay: number = 2000, + initialDelay: number = 10000, backoffMultiplier: number = 1.5 ): Promise { let delay = initialDelay; @@ -43,7 +43,7 @@ export function fetchCourseDetail(courseId) { const courseDetail = await retryOnNotReady( () => getCourseDetail(courseId, getAuthenticatedUser().username), 10, // maxRetries - 2000 // initialDelay (2 segundos) + 10000 // initialDelay (2 segundos) ); dispatch(updateStatus({ courseId, status: RequestStatus.SUCCESSFUL })); dispatch(addModel({ modelType: 'courseDetails', model: courseDetail })); From 6f0b9630512768b45aec2364fa18eae0de9406f2 Mon Sep 17 00:00:00 2001 From: Andres Espinel Date: Tue, 28 Oct 2025 17:30:24 -0500 Subject: [PATCH 05/13] feat: added helper function to course outline --- src/course-outline/data/thunk.ts | 57 ++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/src/course-outline/data/thunk.ts b/src/course-outline/data/thunk.ts index 71157d11c1..63300611c1 100644 --- a/src/course-outline/data/thunk.ts +++ b/src/course-outline/data/thunk.ts @@ -56,6 +56,48 @@ 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 = 10, + initialDelay: number = 2000, + backoffMultiplier: number = 1.5 +): Promise { + let delay = initialDelay; + + for (let i = 0; i < maxRetries; i++) { + try { + return await apiCall(); + } catch (error: any) { + // Solo reintentar si recibimos 202 (Accepted - still processing) + const isProcessing = error?.response?.status === 202; + + // También reintentar si obtenemos error de "course_does_not_exist" en los primeros intentos + const isCourseNotExist = error?.response?.data?.error_code === 'course_does_not_exist' && i < 5; + + if ((isProcessing || isCourseNotExist) && i < maxRetries - 1) { + console.log(`[CourseOutline] Course still processing, retrying in ${delay}ms... (attempt ${i + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + delay *= backoffMultiplier; + continue; + } + + // Para cualquier otro error, lanzar inmediatamente + throw error; + } + } + throw new Error('Max retries exceeded while waiting for course outline'); +} + /** * Action to fetch course outline. * @@ -67,7 +109,14 @@ export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any) dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS })); try { - const outlineIndex = await getCourseOutlineIndex(courseId); + // Usar retry logic para esperar a que el curso esté listo + const outlineIndex = await retryOnNotReady( + () => getCourseOutlineIndex(courseId), + 10, // maxRetries + 2000, // initialDelay (2 segundos) + 1.5 // backoffMultiplier + ); + const { courseReleaseDate, courseStructure: { @@ -77,6 +126,7 @@ export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any) actions, }, } = outlineIndex; + dispatch(fetchOutlineIndexSuccess(outlineIndex)); dispatch(updateStatusBar({ courseReleaseDate, @@ -85,9 +135,12 @@ export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any) videoSharingEnabled, })); dispatch(updateCourseActions(actions)); - dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.SUCCESSFUL })); + + console.log('[CourseOutline] Successfully loaded course outline'); } catch (error: any) { + console.error('[CourseOutline] Failed to fetch course outline:', error); + if (error.response && error.response.status === 403) { dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.DENIED, From 22b3fbbedafed54dd7fb24bf1398efffcc649cf5 Mon Sep 17 00:00:00 2001 From: Andres Espinel Date: Tue, 28 Oct 2025 18:49:11 -0500 Subject: [PATCH 06/13] chore: adressing linting issues --- src/course-outline/data/thunk.ts | 21 +++++++-------------- src/data/thunks.ts | 12 ++++++------ 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/src/course-outline/data/thunk.ts b/src/course-outline/data/thunk.ts index 63300611c1..1ac8aafa6f 100644 --- a/src/course-outline/data/thunk.ts +++ b/src/course-outline/data/thunk.ts @@ -73,25 +73,20 @@ async function retryOnNotReady( backoffMultiplier: number = 1.5 ): Promise { let delay = initialDelay; - + for (let i = 0; i < maxRetries; i++) { try { return await apiCall(); } catch (error: any) { - // Solo reintentar si recibimos 202 (Accepted - still processing) const isProcessing = error?.response?.status === 202; - - // También reintentar si obtenemos error de "course_does_not_exist" en los primeros intentos + const isCourseNotExist = error?.response?.data?.error_code === 'course_does_not_exist' && i < 5; - if ((isProcessing || isCourseNotExist) && i < maxRetries - 1) { console.log(`[CourseOutline] Course still processing, retrying in ${delay}ms... (attempt ${i + 1}/${maxRetries})`); await new Promise(resolve => setTimeout(resolve, delay)); delay *= backoffMultiplier; continue; } - - // Para cualquier otro error, lanzar inmediatamente throw error; } } @@ -109,14 +104,13 @@ export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any) dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS })); try { - // Usar retry logic para esperar a que el curso esté listo const outlineIndex = await retryOnNotReady( () => getCourseOutlineIndex(courseId), - 10, // maxRetries + 10, // maxRetries 2000, // initialDelay (2 segundos) - 1.5 // backoffMultiplier + 1.5, // backoffMultiplier ); - + const { courseReleaseDate, courseStructure: { @@ -126,7 +120,7 @@ export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any) actions, }, } = outlineIndex; - + dispatch(fetchOutlineIndexSuccess(outlineIndex)); dispatch(updateStatusBar({ courseReleaseDate, @@ -136,11 +130,10 @@ export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any) })); dispatch(updateCourseActions(actions)); dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.SUCCESSFUL })); - + console.log('[CourseOutline] Successfully loaded course outline'); } catch (error: any) { console.error('[CourseOutline] Failed to fetch course outline:', error); - if (error.response && error.response.status === 403) { dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.DENIED, diff --git a/src/data/thunks.ts b/src/data/thunks.ts index 3a2842a9f8..ebf3ec6ac0 100644 --- a/src/data/thunks.ts +++ b/src/data/thunks.ts @@ -7,11 +7,11 @@ import { } from './slice'; import { RequestStatus } from './constants'; -// Función helper para reintentar cuando el curso no está listo +// Helper function to retry API calls when course is not ready yet async function retryOnNotReady( apiCall: () => Promise, maxRetries: number = 10, - initialDelay: number = 10000, + initialDelay: number = 2000, backoffMultiplier: number = 1.5 ): Promise { let delay = initialDelay; @@ -25,7 +25,7 @@ async function retryOnNotReady( if (isNotReady && i < maxRetries - 1) { console.log(`[CourseDetail] Course not ready, retrying in ${delay}ms... (attempt ${i + 1}/${maxRetries})`); await new Promise(resolve => setTimeout(resolve, delay)); - delay *= backoffMultiplier; // Incrementar el delay exponencialmente + delay *= backoffMultiplier; continue; } throw error; @@ -42,8 +42,8 @@ export function fetchCourseDetail(courseId) { // Envolver la llamada API con retry logic const courseDetail = await retryOnNotReady( () => getCourseDetail(courseId, getAuthenticatedUser().username), - 10, // maxRetries - 10000 // initialDelay (2 segundos) + 10, // maxRetries + 2000, // initialDelay (2 segundos) ); dispatch(updateStatus({ courseId, status: RequestStatus.SUCCESSFUL })); dispatch(addModel({ modelType: 'courseDetails', model: courseDetail })); @@ -52,7 +52,7 @@ export function fetchCourseDetail(courseId) { })); } catch (error) { console.error('[CourseDetail] Error fetching course detail:', error); - + if ((error as any).response && (error as any).response.status === 404) { dispatch(updateStatus({ courseId, status: RequestStatus.NOT_FOUND })); } else { From 59c46ff67890bd3e336cf1427c399ccbcdffdf80 Mon Sep 17 00:00:00 2001 From: Andres Espinel Date: Tue, 28 Oct 2025 21:36:14 -0500 Subject: [PATCH 07/13] chore: adressed linting issues --- src/course-outline/data/thunk.ts | 47 +++++++++-------------- src/data/thunks.ts | 66 ++++++++++++++++++-------------- 2 files changed, 55 insertions(+), 58 deletions(-) diff --git a/src/course-outline/data/thunk.ts b/src/course-outline/data/thunk.ts index 1ac8aafa6f..d4cfdcda2e 100644 --- a/src/course-outline/data/thunk.ts +++ b/src/course-outline/data/thunk.ts @@ -68,29 +68,26 @@ import { */ async function retryOnNotReady( apiCall: () => Promise, - maxRetries: number = 10, - initialDelay: number = 2000, - backoffMultiplier: number = 1.5 + maxRetries: number = 5, + delayMs: number = 2000, + attempt: number = 1, ): Promise { - let delay = initialDelay; - - for (let i = 0; i < maxRetries; i++) { - try { - return await apiCall(); - } catch (error: any) { - const isProcessing = error?.response?.status === 202; - - const isCourseNotExist = error?.response?.data?.error_code === 'course_does_not_exist' && i < 5; - if ((isProcessing || isCourseNotExist) && i < maxRetries - 1) { - console.log(`[CourseOutline] Course still processing, retrying in ${delay}ms... (attempt ${i + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - delay *= backoffMultiplier; - continue; - } - throw error; + 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; } - throw new Error('Max retries exceeded while waiting for course outline'); } /** @@ -104,12 +101,7 @@ export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any) dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS })); try { - const outlineIndex = await retryOnNotReady( - () => getCourseOutlineIndex(courseId), - 10, // maxRetries - 2000, // initialDelay (2 segundos) - 1.5, // backoffMultiplier - ); + const outlineIndex = await retryOnNotReady(() => getCourseOutlineIndex(courseId)); const { courseReleaseDate, @@ -130,10 +122,7 @@ export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any) })); dispatch(updateCourseActions(actions)); dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.SUCCESSFUL })); - - console.log('[CourseOutline] Successfully loaded course outline'); } catch (error: any) { - console.error('[CourseOutline] Failed to fetch course outline:', error); if (error.response && error.response.status === 403) { dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.DENIED, diff --git a/src/data/thunks.ts b/src/data/thunks.ts index ebf3ec6ac0..a8e5e5974d 100644 --- a/src/data/thunks.ts +++ b/src/data/thunks.ts @@ -7,57 +7,65 @@ import { } from './slice'; import { RequestStatus } from './constants'; -// Helper function to retry API calls when course is not ready yet +/** + * 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 = 10, - initialDelay: number = 2000, - backoffMultiplier: number = 1.5 + delayMs: number = 2000, + attempt: number = 1, + backoffMultiplier: number = 1.5, ): Promise { - let delay = initialDelay; + try { + return await apiCall(); + } catch (error: any) { + const isNotReady = error?.response?.status === 202 + || error?.response?.status === 404; + const canRetry = attempt < maxRetries; - for (let i = 0; i < maxRetries; i++) { - try { - return await apiCall(); - } catch (error: any) { - const isNotReady = error?.response?.status === 202 || - (error?.response?.status === 404 && i < 10); - if (isNotReady && i < maxRetries - 1) { - console.log(`[CourseDetail] Course not ready, retrying in ${delay}ms... (attempt ${i + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - delay *= backoffMultiplier; - continue; - } - throw error; + 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; } - throw new Error('Max retries exceeded while waiting for course to be ready'); } -export function fetchCourseDetail(courseId) { - return async (dispatch) => { +export function fetchCourseDetail(courseId: string) { + return async (dispatch: any) => { dispatch(updateStatus({ courseId, status: RequestStatus.IN_PROGRESS })); try { - // Envolver la llamada API con retry logic const courseDetail = await retryOnNotReady( () => getCourseDetail(courseId, getAuthenticatedUser().username), - 10, // maxRetries - 2000, // initialDelay (2 segundos) ); + 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) { - console.error('[CourseDetail] Error fetching course detail:', 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 })); } } }; -} \ No newline at end of file +} From 793a93cbb6ad075eb0d7ae2b4e98373270652a35 Mon Sep 17 00:00:00 2001 From: Andres Espinel Date: Tue, 28 Oct 2025 22:10:49 -0500 Subject: [PATCH 08/13] feat: added timeout to test --- src/CourseAuthoringPage.test.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CourseAuthoringPage.test.jsx b/src/CourseAuthoringPage.test.jsx index 6122056c23..6ae9c61bdc 100644 --- a/src/CourseAuthoringPage.test.jsx +++ b/src/CourseAuthoringPage.test.jsx @@ -84,7 +84,7 @@ describe('Course authoring page', () => { await mockStoreNotFound(); const wrapper = render(); expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument(); - }); + }, 30000); 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 From 1b526f8e42fca8f998af496cb923882c8e7acc01 Mon Sep 17 00:00:00 2001 From: Andres Espinel Date: Tue, 28 Oct 2025 22:31:59 -0500 Subject: [PATCH 09/13] fix: fixed unit test --- src/CourseAuthoringPage.test.jsx | 21 +++++++++++++++++---- src/data/thunks.ts | 21 ++++++++++++++++++--- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/CourseAuthoringPage.test.jsx b/src/CourseAuthoringPage.test.jsx index 6ae9c61bdc..0153849c6c 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'; @@ -64,6 +64,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 +81,7 @@ describe('Course authoring page', () => { }); await executeThunk(fetchCourseDetail(courseId), store.dispatch); }; + const mockStoreError = async () => { axiosMock.onGet( `${courseDetailApiUrl}/${courseId}?username=abc123`, @@ -80,11 +90,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(); - }, 30000); + }); // Removido el timeout de 30000, ya no es necesario + 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 +107,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 +122,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(); diff --git a/src/data/thunks.ts b/src/data/thunks.ts index a8e5e5974d..1d0901ca45 100644 --- a/src/data/thunks.ts +++ b/src/data/thunks.ts @@ -7,17 +7,32 @@ import { } from './slice'; import { RequestStatus } from './constants'; +/** + * 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 = 10, - delayMs: number = 2000, + maxRetries: number = retryConfig.maxRetries, + delayMs: number = retryConfig.initialDelay, attempt: number = 1, - backoffMultiplier: number = 1.5, + backoffMultiplier: number = retryConfig.backoffMultiplier, ): Promise { + // Skip retries if disabled (useful for tests) + if (!retryConfig.enabled) { + return apiCall(); + } + try { return await apiCall(); } catch (error: any) { From 5d64fb2231b7c2cf76f1a3fc8896eb4d505eef60 Mon Sep 17 00:00:00 2001 From: Andres Espinel Date: Wed, 29 Oct 2025 08:24:53 -0500 Subject: [PATCH 10/13] chore: added tests for retry logic --- src/CourseAuthoringPage.test.jsx | 108 +++++++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 5 deletions(-) diff --git a/src/CourseAuthoringPage.test.jsx b/src/CourseAuthoringPage.test.jsx index 0153849c6c..e3ca1f314f 100644 --- a/src/CourseAuthoringPage.test.jsx +++ b/src/CourseAuthoringPage.test.jsx @@ -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(); }); @@ -95,7 +93,7 @@ describe('Course authoring page', () => { await mockStoreNotFound(); const wrapper = render(); expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument(); - }); // Removido el timeout de 30000, ya no es necesario + }); test('does not render not found page on other kinds of error', async () => { await mockStoreError(); @@ -131,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; + }); +}); From 2d9a0302108173d999e6c96ba91f3a3bd213522c Mon Sep 17 00:00:00 2001 From: Andres Espinel Date: Wed, 29 Oct 2025 08:55:41 -0500 Subject: [PATCH 11/13] chore: added test for course outline --- src/course-outline/CourseOutline.test.tsx | 36 +++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/course-outline/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx index d5003f9eab..225065018f 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, @@ -2484,3 +2485,38 @@ describe('', () => { expect(axiosMock.history.delete[0].url).toBe(getDownstreamApiUrl(courseSectionMock.id)); }); }); + +describe('retryOnNotReady coverage', () => { + it('retries on 202 and eventually 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: {}, + }, + }); + + await fetchCourseOutlineIndexQuery(courseId)(mockDispatch); + + expect(getCourseOutlineIndex).toHaveBeenCalledTimes(3); + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: expect.stringContaining('SUCCESS') }), + ); + }); + + it('stops retrying after maxRetries', async () => { + const mockDispatch = jest.fn(); + (getCourseOutlineIndex as jest.Mock).mockRejectedValue({ response: { status: 202 } }); + + await expect( + fetchCourseOutlineIndexQuery(courseId)(mockDispatch), + ).rejects.toBeDefined(); + }); +}); From dbe15f6401b752322067b1f1ccc758163615844a Mon Sep 17 00:00:00 2001 From: Andres Espinel Date: Wed, 29 Oct 2025 09:08:48 -0500 Subject: [PATCH 12/13] chore: added mock for course outline --- src/course-outline/CourseOutline.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/course-outline/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx index 225065018f..83dd232a07 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -133,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)); From f9e16730ce3fa2c74b7473a40cbb9d8818799e1c Mon Sep 17 00:00:00 2001 From: Andres Espinel Date: Wed, 29 Oct 2025 09:33:08 -0500 Subject: [PATCH 13/13] chore: modify test --- src/course-outline/CourseOutline.test.tsx | 30 +++++++++++++++++------ 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/course-outline/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx index 83dd232a07..6492d28a58 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -2490,8 +2490,13 @@ describe('', () => { }); }); -describe('retryOnNotReady coverage', () => { - it('retries on 202 and eventually succeeds', async () => { +describe('retryOnNotReady lightweight coverage', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + it('retries on 202 then succeeds', async () => { const mockDispatch = jest.fn(); (getCourseOutlineIndex as jest.Mock) @@ -2507,20 +2512,29 @@ describe('retryOnNotReady coverage', () => { }, }); - await fetchCourseOutlineIndexQuery(courseId)(mockDispatch); + 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({ type: expect.stringContaining('SUCCESS') }), + expect.objectContaining({ + payload: expect.objectContaining({ status: RequestStatus.SUCCESSFUL }), + }), ); }); - it('stops retrying after maxRetries', async () => { + it('throws after max retries', async () => { const mockDispatch = jest.fn(); (getCourseOutlineIndex as jest.Mock).mockRejectedValue({ response: { status: 202 } }); - await expect( - fetchCourseOutlineIndexQuery(courseId)(mockDispatch), - ).rejects.toBeDefined(); + const thunk = fetchCourseOutlineIndexQuery(courseId); + const promise = thunk(mockDispatch); + + jest.runAllTimers(); + await expect(promise).rejects.toBeDefined(); }); });