feat: display detailed error message for 403 catalog visibility errors
This commit is contained in:
committed by
Adolfo R. Brandes
parent
2c9a5d09d7
commit
d77f39fbdb
@@ -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}`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)) && (
|
||||
<p className="text-center py-5 mx-auto" style={{ maxWidth: '30em' }}>
|
||||
{intl.formatMessage(messages.failure)}
|
||||
{errorMessage || intl.formatMessage(messages.failure)}
|
||||
</p>
|
||||
)}
|
||||
<FooterSlot />
|
||||
|
||||
@@ -33,6 +33,42 @@ describe('Tab Page', () => {
|
||||
expect(screen.getByText('There was an error loading this course.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays custom error message from courseHome state when available', async () => {
|
||||
const customErrorMessage = 'This course is not currently accessible. The course team has restricted access to this content.';
|
||||
const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
|
||||
// Manually dispatch a failure with the custom error message
|
||||
testStore.dispatch({
|
||||
type: 'course-home/fetchTabFailure',
|
||||
payload: { courseId: 'test-course', errorMessage: customErrorMessage, errorCode: 'not_visible_in_catalog' },
|
||||
});
|
||||
render(<TabPage {...mockData} courseStatus="failed" />, { store: testStore });
|
||||
expect(screen.getByText(customErrorMessage)).toBeInTheDocument();
|
||||
expect(screen.queryByText('There was an error loading this course.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays custom error message from courseware state when available', async () => {
|
||||
const customErrorMessage = 'This course is not currently accessible. The course team has restricted access to this content.';
|
||||
const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
|
||||
// Manually dispatch a courseware failure with the custom error message
|
||||
testStore.dispatch({
|
||||
type: 'courseware/fetchCourseFailure',
|
||||
payload: { courseId: 'test-course', errorMessage: customErrorMessage, errorCode: 'not_visible_in_catalog' },
|
||||
});
|
||||
render(<TabPage {...mockData} courseStatus="failed" />, { store: testStore });
|
||||
expect(screen.getByText(customErrorMessage)).toBeInTheDocument();
|
||||
expect(screen.queryByText('There was an error loading this course.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays generic error message when no custom error message is available', async () => {
|
||||
const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
|
||||
testStore.dispatch({
|
||||
type: 'course-home/fetchTabFailure',
|
||||
payload: { courseId: 'test-course' },
|
||||
});
|
||||
render(<TabPage {...mockData} courseStatus="failed" />, { store: testStore });
|
||||
expect(screen.getByText('There was an error loading this course.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays Learning Toast', async () => {
|
||||
const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
|
||||
render(<TabPage {...mockData} />, { store: testStore });
|
||||
|
||||
Reference in New Issue
Block a user