feat: display detailed error message for 403 catalog visibility errors

This commit is contained in:
Muhammad Anas
2026-03-06 16:49:05 +05:00
committed by Adolfo R. Brandes
parent 2c9a5d09d7
commit d77f39fbdb
8 changed files with 130 additions and 3 deletions

View File

@@ -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}`;

View File

@@ -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;

View File

@@ -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);
}
};

View File

@@ -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', {

View File

@@ -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;

View File

@@ -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 }));
});
};
}

View File

@@ -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 />

View File

@@ -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 });