diff --git a/src/course-home/data/redux.test.js b/src/course-home/data/redux.test.js index 2e40e382..ee395a6f 100644 --- a/src/course-home/data/redux.test.js +++ b/src/course-home/data/redux.test.js @@ -55,6 +55,32 @@ describe('Data layer integration tests', () => { expect(store.getState().courseHome.courseStatus).toEqual('failed'); }); + it('should store errorMessage and errorCode from a 403 catalog visibility response', async () => { + const errorDetail = 'This course is not currently accessible. The course team has restricted access to this content.'; + const errorCode = 'not_visible_in_catalog'; + axiosMock.onGet(courseMetadataUrl).reply(403, { detail: errorDetail, error_code: errorCode }); + axiosMock.onGet(`${datesBaseUrl}/${courseId}`).reply(200, Factory.build('datesTabData')); + + await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch); + + const { courseHome } = store.getState(); + expect(courseHome.courseStatus).toEqual('failed'); + expect(courseHome.errorMessage).toEqual(errorDetail); + expect(courseHome.errorCode).toEqual(errorCode); + }); + + it('should not store errorMessage for non-403 errors', async () => { + axiosMock.onGet(courseMetadataUrl).networkError(); + axiosMock.onGet(`${datesBaseUrl}/${courseId}`).networkError(); + + await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch); + + const { courseHome } = store.getState(); + expect(courseHome.courseStatus).toEqual('failed'); + expect(courseHome.errorMessage).toBeNull(); + expect(courseHome.errorCode).toBeNull(); + }); + it('should result in fetch failed if course metadata call errored', async () => { const datesTabData = Factory.build('datesTabData'); const datesUrl = `${datesBaseUrl}/${courseId}`; diff --git a/src/course-home/data/slice.js b/src/course-home/data/slice.js index 86179f8a..629ed2aa 100644 --- a/src/course-home/data/slice.js +++ b/src/course-home/data/slice.js @@ -19,6 +19,8 @@ const slice = createSlice({ toastHeader: '', showSearch: false, examsData: null, + errorMessage: null, + errorCode: null, }, reducers: { fetchProctoringInfoResolved: (state) => { @@ -31,10 +33,14 @@ const slice = createSlice({ fetchTabFailure: (state, { payload }) => { state.courseId = payload.courseId; state.courseStatus = FAILED; + state.errorMessage = payload.errorMessage || null; + state.errorCode = payload.errorCode || null; }, fetchTabRequest: (state, { payload }) => { state.courseId = payload.courseId; state.courseStatus = LOADING; + state.errorMessage = null; + state.errorCode = null; }, fetchTabSuccess: (state, { payload }) => { state.courseId = payload.courseId; diff --git a/src/course-home/data/thunks.js b/src/course-home/data/thunks.js index 497e472c..838dc4c0 100644 --- a/src/course-home/data/thunks.js +++ b/src/course-home/data/thunks.js @@ -81,7 +81,14 @@ export function fetchTab(courseId, tab, getTabData, targetUserId) { })); } } catch (e) { - dispatch(fetchTabFailure({ courseId })); + // Extract error details from 403 responses + let errorMessage = null; + let errorCode = null; + if (e?.response?.status === 403 && e?.response?.data) { + errorMessage = e.response.data.detail || null; + errorCode = e.response.data.error_code || null; + } + dispatch(fetchTabFailure({ courseId, errorMessage, errorCode })); logError(e); } }; diff --git a/src/courseware/data/redux.test.js b/src/courseware/data/redux.test.js index 5a2c54c8..2dc89bb6 100644 --- a/src/courseware/data/redux.test.js +++ b/src/courseware/data/redux.test.js @@ -76,6 +76,37 @@ describe('Data layer integration tests', () => { })); }); + it('should store errorMessage and errorCode when course_home metadata returns 403', async () => { + const errorDetail = 'This course is not currently accessible. The course team has restricted access to this content.'; + const errorCode = 'not_visible_in_catalog'; + + axiosMock.onGet(courseUrl).reply(200, courseMetadata); + axiosMock.onGet(courseHomeMetadataUrl).reply(403, { detail: errorDetail, error_code: errorCode }); + axiosMock.onGet(learningSequencesUrlRegExp).reply(200, buildOutlineFromBlocks(courseBlocks)); + axiosMock.onGet(coursewareSidebarSettingsUrl).reply(200, { enable_completion_tracking: true }); + + await executeThunk(thunks.fetchCourse(courseId), store.dispatch); + + const { courseware } = store.getState(); + expect(courseware.courseStatus).toEqual(FAILED); + expect(courseware.errorMessage).toEqual(errorDetail); + expect(courseware.errorCode).toEqual(errorCode); + }); + + it('should not store errorMessage for non-403 network errors', async () => { + axiosMock.onGet(courseUrl).networkError(); + axiosMock.onGet(courseHomeMetadataUrl).networkError(); + axiosMock.onGet(learningSequencesUrlRegExp).networkError(); + axiosMock.onGet(coursewareSidebarSettingsUrl).networkError(); + + await executeThunk(thunks.fetchCourse(courseId), store.dispatch); + + const { courseware } = store.getState(); + expect(courseware.courseStatus).toEqual(FAILED); + expect(courseware.errorMessage).toBeNull(); + expect(courseware.errorCode).toBeNull(); + }); + it('Should fetch, normalize, and save metadata, but with denied status', async () => { const forbiddenCourseMetadata = Factory.build('courseMetadata'); const forbiddenCourseHomeMetadata = Factory.build('courseHomeMetadata', { diff --git a/src/courseware/data/slice.js b/src/courseware/data/slice.js index 61783398..d1cb2031 100644 --- a/src/courseware/data/slice.js +++ b/src/courseware/data/slice.js @@ -20,11 +20,15 @@ const slice = createSlice({ coursewareOutlineSidebarSettings: {}, courseOutlineStatus: LOADING, courseOutlineShouldUpdate: false, + errorMessage: null, + errorCode: null, }, reducers: { fetchCourseRequest: (state, { payload }) => { state.courseId = payload.courseId; state.courseStatus = LOADING; + state.errorMessage = null; + state.errorCode = null; }, fetchCourseSuccess: (state, { payload }) => { state.courseId = payload.courseId; @@ -33,6 +37,8 @@ const slice = createSlice({ fetchCourseFailure: (state, { payload }) => { state.courseId = payload.courseId; state.courseStatus = FAILED; + state.errorMessage = payload.errorMessage || null; + state.errorCode = payload.errorCode || null; }, fetchCourseDenied: (state, { payload }) => { state.courseId = payload.courseId; diff --git a/src/courseware/data/thunks.js b/src/courseware/data/thunks.js index 2312cf4d..165f2a4a 100644 --- a/src/courseware/data/thunks.js +++ b/src/courseware/data/thunks.js @@ -129,7 +129,17 @@ export function fetchCourse(courseId) { } // Definitely an error happening - dispatch(fetchCourseFailure({ courseId })); + // Extract error details from 403 responses + let errorMessage = null; + let errorCode = null; + if (!fetchedCourseHomeMetadata) { + const error = courseHomeMetadataResult.reason; + if (error?.response?.status === 403 && error?.response?.data) { + errorMessage = error.response.data.detail || null; + errorCode = error.response.data.error_code || null; + } + } + dispatch(fetchCourseFailure({ courseId, errorMessage, errorCode })); }); }; } diff --git a/src/tab-page/TabPage.jsx b/src/tab-page/TabPage.jsx index caf43998..32c9095b 100644 --- a/src/tab-page/TabPage.jsx +++ b/src/tab-page/TabPage.jsx @@ -29,7 +29,12 @@ const TabPage = (props) => { toastBodyLink, toastBodyText, toastHeader, + errorMessage: courseHomeErrorMessage, } = useSelector(state => state.courseHome); + const { + errorMessage: coursewareErrorMessage, + } = useSelector(state => state.courseware); + const errorMessage = courseHomeErrorMessage || coursewareErrorMessage; const dispatch = useDispatch(); const { courseAccess, @@ -78,7 +83,7 @@ const TabPage = (props) => { {/* courseStatus 'failed' and any other unexpected course status. */} {(!['loading', 'loaded', 'denied'].includes(courseStatus)) && (
- {intl.formatMessage(messages.failure)} + {errorMessage || intl.formatMessage(messages.failure)}
)}