feat: fetch exams data on the progress page (openedx#1829) (#38)
This commit adds changes to fetch the exams data associated with all subsections relevant to the progress page. Exams data is relevant to the progress page because the status of a learner's exam attempt may influence the state of their grade. This allows children of the root ProgressPage or downstream plugin slots to access this data from the Redux store. --------- Co-authored-by: nsprenkle <nsprenkle@2u.com>, Michael Roytman <mroytman@2u.com>
This commit is contained in:
@@ -5,6 +5,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"courseHome": {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"examsData": null,
|
||||
"proctoringPanelStatus": "loading",
|
||||
"showSearch": false,
|
||||
"targetUserId": undefined,
|
||||
@@ -397,6 +398,7 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
|
||||
"courseHome": {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"examsData": null,
|
||||
"proctoringPanelStatus": "loading",
|
||||
"showSearch": false,
|
||||
"targetUserId": undefined,
|
||||
@@ -670,6 +672,7 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
|
||||
"courseHome": {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"examsData": null,
|
||||
"proctoringPanelStatus": "loading",
|
||||
"showSearch": false,
|
||||
"targetUserId": undefined,
|
||||
|
||||
@@ -472,3 +472,24 @@ export async function searchCourseContentFromAPI(courseId, searchKeyword, option
|
||||
|
||||
return camelCaseObject(response);
|
||||
}
|
||||
|
||||
export async function getExamsData(courseId, sequenceId) {
|
||||
let url;
|
||||
|
||||
if (!getConfig().EXAMS_BASE_URL) {
|
||||
url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
|
||||
} else {
|
||||
url = `${getConfig().EXAMS_BASE_URL}/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return camelCaseObject(data);
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { getTimeOffsetMillis } from './api';
|
||||
import { getConfig, setConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getTimeOffsetMillis, getExamsData } from './api';
|
||||
import { initializeMockApp } from '../../setupTest';
|
||||
|
||||
initializeMockApp();
|
||||
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
describe('Calculate the time offset properly', () => {
|
||||
it('Should return 0 if the headerDate is not set', async () => {
|
||||
@@ -14,3 +22,156 @@ describe('Calculate the time offset properly', () => {
|
||||
expect(offset).toBe(86398750);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExamsData', () => {
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const sequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345';
|
||||
let originalConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock.reset();
|
||||
originalConfig = getConfig();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
if (originalConfig) {
|
||||
setConfig(originalConfig);
|
||||
}
|
||||
});
|
||||
|
||||
it('should use LMS URL when EXAMS_BASE_URL is not configured', async () => {
|
||||
setConfig({
|
||||
...originalConfig,
|
||||
EXAMS_BASE_URL: undefined,
|
||||
LMS_BASE_URL: 'http://localhost:18000',
|
||||
});
|
||||
|
||||
const mockExamData = {
|
||||
exam: {
|
||||
id: 1,
|
||||
course_id: courseId,
|
||||
content_id: sequenceId,
|
||||
exam_name: 'Test Exam',
|
||||
attempt_status: 'created',
|
||||
},
|
||||
};
|
||||
|
||||
const expectedUrl = `http://localhost:18000/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
|
||||
axiosMock.onGet(expectedUrl).reply(200, mockExamData);
|
||||
|
||||
const result = await getExamsData(courseId, sequenceId);
|
||||
|
||||
expect(result).toEqual({
|
||||
exam: {
|
||||
id: 1,
|
||||
courseId,
|
||||
contentId: sequenceId,
|
||||
examName: 'Test Exam',
|
||||
attemptStatus: 'created',
|
||||
},
|
||||
});
|
||||
expect(axiosMock.history.get).toHaveLength(1);
|
||||
expect(axiosMock.history.get[0].url).toBe(expectedUrl);
|
||||
});
|
||||
|
||||
it('should use EXAMS_BASE_URL when configured', async () => {
|
||||
setConfig({
|
||||
...originalConfig,
|
||||
EXAMS_BASE_URL: 'http://localhost:18740',
|
||||
LMS_BASE_URL: 'http://localhost:18000',
|
||||
});
|
||||
|
||||
const mockExamData = {
|
||||
exam: {
|
||||
id: 1,
|
||||
course_id: courseId,
|
||||
content_id: sequenceId,
|
||||
exam_name: 'Test Exam',
|
||||
attempt_status: 'submitted',
|
||||
},
|
||||
};
|
||||
|
||||
const expectedUrl = `http://localhost:18740/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}`;
|
||||
axiosMock.onGet(expectedUrl).reply(200, mockExamData);
|
||||
|
||||
const result = await getExamsData(courseId, sequenceId);
|
||||
|
||||
expect(result).toEqual({
|
||||
exam: {
|
||||
id: 1,
|
||||
courseId,
|
||||
contentId: sequenceId,
|
||||
examName: 'Test Exam',
|
||||
attemptStatus: 'submitted',
|
||||
},
|
||||
});
|
||||
expect(axiosMock.history.get).toHaveLength(1);
|
||||
expect(axiosMock.history.get[0].url).toBe(expectedUrl);
|
||||
});
|
||||
|
||||
it('should return empty object when API returns 404', async () => {
|
||||
setConfig({
|
||||
...originalConfig,
|
||||
EXAMS_BASE_URL: undefined,
|
||||
LMS_BASE_URL: 'http://localhost:18000',
|
||||
});
|
||||
|
||||
const expectedUrl = `http://localhost:18000/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
|
||||
|
||||
// Mock a 404 error with the custom error response function to add customAttributes
|
||||
axiosMock.onGet(expectedUrl).reply(() => {
|
||||
const error = new Error('Request failed with status code 404');
|
||||
error.response = { status: 404, data: {} };
|
||||
error.customAttributes = { httpErrorStatus: 404 };
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
const result = await getExamsData(courseId, sequenceId);
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(axiosMock.history.get).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should throw error for non-404 HTTP errors', async () => {
|
||||
setConfig({
|
||||
...originalConfig,
|
||||
EXAMS_BASE_URL: undefined,
|
||||
LMS_BASE_URL: 'http://localhost:18000',
|
||||
});
|
||||
|
||||
const expectedUrl = `http://localhost:18000/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
|
||||
|
||||
// Mock a 500 error with custom error response
|
||||
axiosMock.onGet(expectedUrl).reply(() => {
|
||||
const error = new Error('Request failed with status code 500');
|
||||
error.response = { status: 500, data: { error: 'Server Error' } };
|
||||
error.customAttributes = { httpErrorStatus: 500 };
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
await expect(getExamsData(courseId, sequenceId)).rejects.toThrow();
|
||||
expect(axiosMock.history.get).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should properly encode URL parameters', async () => {
|
||||
setConfig({
|
||||
...originalConfig,
|
||||
EXAMS_BASE_URL: 'http://localhost:18740',
|
||||
LMS_BASE_URL: 'http://localhost:18000',
|
||||
});
|
||||
|
||||
const specialCourseId = 'course-v1:edX+Demo X+Demo Course';
|
||||
const specialSequenceId = 'block-v1:edX+Demo X+Demo Course+type@sequential+block@test sequence';
|
||||
|
||||
const mockExamData = { exam: { id: 1 } };
|
||||
const expectedUrl = `http://localhost:18740/api/v1/student/exam/attempt/course_id/${encodeURIComponent(specialCourseId)}/content_id/${encodeURIComponent(specialSequenceId)}`;
|
||||
axiosMock.onGet(expectedUrl).reply(200, mockExamData);
|
||||
|
||||
await getExamsData(specialCourseId, specialSequenceId);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toBe(expectedUrl);
|
||||
expect(axiosMock.history.get[0].url).toContain('course-v1%3AedX%2BDemo%20X%2BDemo%20Course');
|
||||
expect(axiosMock.history.get[0].url).toContain('block-v1%3AedX%2BDemo%20X%2BDemo%20Course%2Btype%40sequential%2Bblock%40test%20sequence');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -297,4 +297,178 @@ describe('Data layer integration tests', () => {
|
||||
expect(enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test fetchExamAttemptsData', () => {
|
||||
const sequenceIds = [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@67890',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@abcde',
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock individual exam endpoints with different responses
|
||||
sequenceIds.forEach((sequenceId, index) => {
|
||||
// Handle both LMS and EXAMS service URL patterns
|
||||
const lmsExamUrl = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceId)}.*`);
|
||||
const examsServiceUrl = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}.*`);
|
||||
|
||||
let attemptStatus = 'ready_to_start';
|
||||
if (index === 0) {
|
||||
attemptStatus = 'created';
|
||||
} else if (index === 1) {
|
||||
attemptStatus = 'submitted';
|
||||
}
|
||||
|
||||
const mockExamData = {
|
||||
exam: {
|
||||
id: index + 1,
|
||||
course_id: courseId,
|
||||
content_id: sequenceId,
|
||||
exam_name: `Test Exam ${index + 1}`,
|
||||
attempt_status: attemptStatus,
|
||||
time_remaining_seconds: 3600,
|
||||
},
|
||||
};
|
||||
|
||||
// Mock both URL patterns
|
||||
axiosMock.onGet(lmsExamUrl).reply(200, mockExamData);
|
||||
axiosMock.onGet(examsServiceUrl).reply(200, mockExamData);
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch exam data for all sequence IDs and dispatch setExamsData', async () => {
|
||||
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
|
||||
// Verify the examsData was set in the store
|
||||
expect(state.courseHome.examsData).toHaveLength(3);
|
||||
expect(state.courseHome.examsData).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
courseId,
|
||||
contentId: sequenceIds[0],
|
||||
examName: 'Test Exam 1',
|
||||
attemptStatus: 'created',
|
||||
timeRemainingSeconds: 3600,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
courseId,
|
||||
contentId: sequenceIds[1],
|
||||
examName: 'Test Exam 2',
|
||||
attemptStatus: 'submitted',
|
||||
timeRemainingSeconds: 3600,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
courseId,
|
||||
contentId: sequenceIds[2],
|
||||
examName: 'Test Exam 3',
|
||||
attemptStatus: 'ready_to_start',
|
||||
timeRemainingSeconds: 3600,
|
||||
},
|
||||
]);
|
||||
|
||||
// Verify all API calls were made
|
||||
expect(axiosMock.history.get).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should handle 404 responses and include empty objects in results', async () => {
|
||||
// Override one endpoint to return 404 for both URL patterns
|
||||
const examUrl404LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[1])}.*`);
|
||||
const examUrl404Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[1])}.*`);
|
||||
axiosMock.onGet(examUrl404LMS).reply(404);
|
||||
axiosMock.onGet(examUrl404Exams).reply(404);
|
||||
|
||||
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
|
||||
// Verify the examsData includes empty object for 404 response
|
||||
expect(state.courseHome.examsData).toHaveLength(3);
|
||||
expect(state.courseHome.examsData[1]).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle API errors and log them while continuing with other requests', async () => {
|
||||
// Override one endpoint to return 500 error for both URL patterns
|
||||
const examUrl500LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[0])}.*`);
|
||||
const examUrl500Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[0])}.*`);
|
||||
axiosMock.onGet(examUrl500LMS).reply(500, { error: 'Server Error' });
|
||||
axiosMock.onGet(examUrl500Exams).reply(500, { error: 'Server Error' });
|
||||
|
||||
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
|
||||
// Verify error was logged for the failed request
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
|
||||
// Verify the examsData still includes results for successful requests
|
||||
expect(state.courseHome.examsData).toHaveLength(3);
|
||||
// First item should be the error result (just empty object for API errors)
|
||||
expect(state.courseHome.examsData[0]).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle empty sequence IDs array', async () => {
|
||||
await executeThunk(thunks.fetchExamAttemptsData(courseId, []), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
|
||||
expect(state.courseHome.examsData).toEqual([]);
|
||||
expect(axiosMock.history.get).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle mixed success and error responses', async () => {
|
||||
// Setup mixed responses
|
||||
const examUrl1LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[0])}.*`);
|
||||
const examUrl1Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[0])}.*`);
|
||||
const examUrl2LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[1])}.*`);
|
||||
const examUrl2Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[1])}.*`);
|
||||
const examUrl3LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[2])}.*`);
|
||||
const examUrl3Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[2])}.*`);
|
||||
|
||||
axiosMock.onGet(examUrl1LMS).reply(200, {
|
||||
exam: {
|
||||
id: 1,
|
||||
exam_name: 'Success Exam',
|
||||
course_id: courseId,
|
||||
content_id: sequenceIds[0],
|
||||
attempt_status: 'created',
|
||||
time_remaining_seconds: 3600,
|
||||
},
|
||||
});
|
||||
axiosMock.onGet(examUrl1Exams).reply(200, {
|
||||
exam: {
|
||||
id: 1,
|
||||
exam_name: 'Success Exam',
|
||||
course_id: courseId,
|
||||
content_id: sequenceIds[0],
|
||||
attempt_status: 'created',
|
||||
time_remaining_seconds: 3600,
|
||||
},
|
||||
});
|
||||
axiosMock.onGet(examUrl2LMS).reply(404);
|
||||
axiosMock.onGet(examUrl2Exams).reply(404);
|
||||
axiosMock.onGet(examUrl3LMS).reply(500, { error: 'Server Error' });
|
||||
axiosMock.onGet(examUrl3Exams).reply(500, { error: 'Server Error' });
|
||||
|
||||
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
|
||||
expect(state.courseHome.examsData).toHaveLength(3);
|
||||
expect(state.courseHome.examsData[0]).toMatchObject({
|
||||
id: 1,
|
||||
examName: 'Success Exam',
|
||||
courseId,
|
||||
contentId: sequenceIds[0],
|
||||
});
|
||||
expect(state.courseHome.examsData[1]).toEqual({});
|
||||
expect(state.courseHome.examsData[2]).toEqual({});
|
||||
|
||||
// Verify error was logged for the 500 error (may be called more than once due to multiple URL patterns)
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ const slice = createSlice({
|
||||
toastBodyLink: null,
|
||||
toastHeader: '',
|
||||
showSearch: false,
|
||||
examsData: null,
|
||||
},
|
||||
reducers: {
|
||||
fetchProctoringInfoResolved: (state) => {
|
||||
@@ -53,6 +54,9 @@ const slice = createSlice({
|
||||
setShowSearch: (state, { payload }) => {
|
||||
state.showSearch = payload;
|
||||
},
|
||||
setExamsData: (state, { payload }) => {
|
||||
state.examsData = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -64,6 +68,7 @@ export const {
|
||||
fetchTabSuccess,
|
||||
setCallToActionToast,
|
||||
setShowSearch,
|
||||
setExamsData,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
145
src/course-home/data/slice.test.js
Normal file
145
src/course-home/data/slice.test.js
Normal file
@@ -0,0 +1,145 @@
|
||||
import { reducer, setExamsData } from './slice';
|
||||
|
||||
describe('course home data slice', () => {
|
||||
describe('setExamsData reducer', () => {
|
||||
it('should set examsData in state', () => {
|
||||
const initialState = {
|
||||
courseStatus: 'loading',
|
||||
courseId: null,
|
||||
metadataModel: 'courseHomeCourseMetadata',
|
||||
proctoringPanelStatus: 'loading',
|
||||
tabFetchStates: {},
|
||||
toastBodyText: '',
|
||||
toastBodyLink: null,
|
||||
toastHeader: '',
|
||||
showSearch: false,
|
||||
examsData: null,
|
||||
};
|
||||
|
||||
const mockExamsData = [
|
||||
{
|
||||
id: 1,
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
examName: 'Midterm Exam',
|
||||
attemptStatus: 'created',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
examName: 'Final Exam',
|
||||
attemptStatus: 'submitted',
|
||||
},
|
||||
];
|
||||
|
||||
const action = setExamsData(mockExamsData);
|
||||
const newState = reducer(initialState, action);
|
||||
|
||||
expect(newState.examsData).toEqual(mockExamsData);
|
||||
expect(newState).toEqual({
|
||||
...initialState,
|
||||
examsData: mockExamsData,
|
||||
});
|
||||
});
|
||||
|
||||
it('should update examsData when state already has data', () => {
|
||||
const initialState = {
|
||||
courseStatus: 'loaded',
|
||||
courseId: 'test-course',
|
||||
metadataModel: 'courseHomeCourseMetadata',
|
||||
proctoringPanelStatus: 'loading',
|
||||
tabFetchStates: {},
|
||||
toastBodyText: '',
|
||||
toastBodyLink: null,
|
||||
toastHeader: '',
|
||||
showSearch: false,
|
||||
examsData: [{ id: 1, examName: 'Old Exam' }],
|
||||
};
|
||||
|
||||
const newExamsData = [
|
||||
{
|
||||
id: 2,
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
examName: 'New Exam',
|
||||
attemptStatus: 'ready_to_start',
|
||||
},
|
||||
];
|
||||
|
||||
const action = setExamsData(newExamsData);
|
||||
const newState = reducer(initialState, action);
|
||||
|
||||
expect(newState.examsData).toEqual(newExamsData);
|
||||
expect(newState.examsData).not.toEqual(initialState.examsData);
|
||||
});
|
||||
|
||||
it('should set examsData to empty array', () => {
|
||||
const initialState = {
|
||||
courseStatus: 'loaded',
|
||||
courseId: 'test-course',
|
||||
metadataModel: 'courseHomeCourseMetadata',
|
||||
proctoringPanelStatus: 'loading',
|
||||
tabFetchStates: {},
|
||||
toastBodyText: '',
|
||||
toastBodyLink: null,
|
||||
toastHeader: '',
|
||||
showSearch: false,
|
||||
examsData: [{ id: 1, examName: 'Some Exam' }],
|
||||
};
|
||||
|
||||
const action = setExamsData([]);
|
||||
const newState = reducer(initialState, action);
|
||||
|
||||
expect(newState.examsData).toEqual([]);
|
||||
});
|
||||
|
||||
it('should set examsData to null', () => {
|
||||
const initialState = {
|
||||
courseStatus: 'loaded',
|
||||
courseId: 'test-course',
|
||||
metadataModel: 'courseHomeCourseMetadata',
|
||||
proctoringPanelStatus: 'loading',
|
||||
tabFetchStates: {},
|
||||
toastBodyText: '',
|
||||
toastBodyLink: null,
|
||||
toastHeader: '',
|
||||
showSearch: false,
|
||||
examsData: [{ id: 1, examName: 'Some Exam' }],
|
||||
};
|
||||
|
||||
const action = setExamsData(null);
|
||||
const newState = reducer(initialState, action);
|
||||
|
||||
expect(newState.examsData).toBeNull();
|
||||
});
|
||||
|
||||
it('should not affect other state properties when setting examsData', () => {
|
||||
const initialState = {
|
||||
courseStatus: 'loaded',
|
||||
courseId: 'test-course-id',
|
||||
metadataModel: 'courseHomeCourseMetadata',
|
||||
proctoringPanelStatus: 'complete',
|
||||
tabFetchStates: { progress: 'loaded' },
|
||||
toastBodyText: 'Toast message',
|
||||
toastBodyLink: 'http://example.com',
|
||||
toastHeader: 'Toast Header',
|
||||
showSearch: true,
|
||||
examsData: null,
|
||||
};
|
||||
|
||||
const mockExamsData = [{ id: 1, examName: 'Test Exam' }];
|
||||
const action = setExamsData(mockExamsData);
|
||||
const newState = reducer(initialState, action);
|
||||
|
||||
// Verify that only examsData changed
|
||||
expect(newState).toEqual({
|
||||
...initialState,
|
||||
examsData: mockExamsData,
|
||||
});
|
||||
|
||||
// Verify other properties remain unchanged
|
||||
expect(newState.courseStatus).toBe(initialState.courseStatus);
|
||||
expect(newState.courseId).toBe(initialState.courseId);
|
||||
expect(newState.showSearch).toBe(initialState.showSearch);
|
||||
expect(newState.toastBodyText).toBe(initialState.toastBodyText);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
executePostFromPostEvent,
|
||||
getCourseHomeCourseMetadata,
|
||||
getDatesTabData,
|
||||
getExamsData,
|
||||
getOutlineTabData,
|
||||
getProgressTabData,
|
||||
postCourseDeadlines,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
setCallToActionToast,
|
||||
setExamsData,
|
||||
} from './slice';
|
||||
|
||||
import mapSearchResponse from '../courseware-search/map-search-response';
|
||||
@@ -223,3 +225,19 @@ export function searchCourseContent(courseId, searchKeyword) {
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchExamAttemptsData(courseId, sequenceIds) {
|
||||
return async (dispatch) => {
|
||||
const results = await Promise.all(sequenceIds.map(async (sequenceId) => {
|
||||
try {
|
||||
const response = await getExamsData(courseId, sequenceId);
|
||||
return response.exam || {};
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
return {};
|
||||
}
|
||||
}));
|
||||
|
||||
dispatch(setExamsData(results));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useWindowSize } from '@openedx/paragon';
|
||||
import { useContextId } from '../../data/hooks';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import ProgressTabCertificateStatusSidePanelSlot from '../../plugin-slots/ProgressTabCertificateStatusSidePanelSlot';
|
||||
|
||||
import CourseCompletion from './course-completion/CourseCompletion';
|
||||
@@ -10,11 +11,17 @@ import ProgressTabCertificateStatusMainBodySlot from '../../plugin-slots/Progres
|
||||
import ProgressTabCourseGradeSlot from '../../plugin-slots/ProgressTabCourseGradeSlot';
|
||||
import ProgressTabGradeBreakdownSlot from '../../plugin-slots/ProgressTabGradeBreakdownSlot';
|
||||
import ProgressTabRelatedLinksSlot from '../../plugin-slots/ProgressTabRelatedLinksSlot';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { useGetExamsData } from './hooks';
|
||||
|
||||
const ProgressTab = () => {
|
||||
const courseId = useContextId();
|
||||
const { disableProgressGraph } = useModel('progress', courseId);
|
||||
const { disableProgressGraph, sectionScores } = useModel('progress', courseId);
|
||||
|
||||
const sequenceIds = useMemo(() => (
|
||||
sectionScores.flatMap((section) => (section.subsections)).map((subsection) => subsection.blockKey)
|
||||
), [sectionScores]);
|
||||
|
||||
useGetExamsData(courseId, sequenceIds);
|
||||
|
||||
const windowWidth = useWindowSize().width;
|
||||
if (windowWidth === undefined) {
|
||||
|
||||
@@ -1500,4 +1500,287 @@ describe('Progress Tab', () => {
|
||||
expect(screen.getByText('Course progress for otherstudent')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Exam data fetching integration', () => {
|
||||
const mockSectionScores = [
|
||||
{
|
||||
display_name: 'Section 1',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Exam',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@exam1',
|
||||
display_name: 'Midterm Exam',
|
||||
learner_has_access: true,
|
||||
has_graded_assignment: true,
|
||||
percent_graded: 0.8,
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: '/mock-url',
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@homework1',
|
||||
display_name: 'Homework 1',
|
||||
learner_has_access: true,
|
||||
has_graded_assignment: true,
|
||||
percent_graded: 0.9,
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: '/mock-url',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
display_name: 'Section 2',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Exam',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@final_exam',
|
||||
display_name: 'Final Exam',
|
||||
learner_has_access: true,
|
||||
has_graded_assignment: true,
|
||||
percent_graded: 0.85,
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: '/mock-url',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset any existing handlers to avoid conflicts
|
||||
axiosMock.reset();
|
||||
|
||||
// Re-add the base mocks that other tests expect
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
axiosMock.onGet(progressUrl).reply(200, defaultTabData);
|
||||
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
|
||||
|
||||
// Mock exam data endpoints using specific GET handlers
|
||||
axiosMock.onGet(/.*exam1.*/).reply(200, {
|
||||
exam: {
|
||||
id: 1,
|
||||
course_id: courseId,
|
||||
content_id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@exam1',
|
||||
exam_name: 'Midterm Exam',
|
||||
attempt_status: 'submitted',
|
||||
time_remaining_seconds: 0,
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock.onGet(/.*homework1.*/).reply(404);
|
||||
|
||||
axiosMock.onGet(/.*final_exam.*/).reply(200, {
|
||||
exam: {
|
||||
id: 2,
|
||||
course_id: courseId,
|
||||
content_id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@final_exam',
|
||||
exam_name: 'Final Exam',
|
||||
attempt_status: 'ready_to_start',
|
||||
time_remaining_seconds: 7200,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch exam data for all subsections when ProgressTab renders', async () => {
|
||||
setTabData({ section_scores: mockSectionScores });
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
// Verify exam API calls were made for all subsections
|
||||
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(3);
|
||||
|
||||
// Verify the exam data is in the Redux store
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.examsData).toHaveLength(3);
|
||||
|
||||
// Check the exam data structure
|
||||
expect(state.courseHome.examsData[0]).toEqual({
|
||||
id: 1,
|
||||
courseId,
|
||||
contentId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@exam1',
|
||||
examName: 'Midterm Exam',
|
||||
attemptStatus: 'submitted',
|
||||
timeRemainingSeconds: 0,
|
||||
});
|
||||
|
||||
expect(state.courseHome.examsData[1]).toEqual({}); // 404 response for homework
|
||||
|
||||
expect(state.courseHome.examsData[2]).toEqual({
|
||||
id: 2,
|
||||
courseId,
|
||||
contentId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@final_exam',
|
||||
examName: 'Final Exam',
|
||||
attemptStatus: 'ready_to_start',
|
||||
timeRemainingSeconds: 7200,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty section scores gracefully', async () => {
|
||||
setTabData({ section_scores: [] });
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
// Verify no exam API calls were made
|
||||
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(0);
|
||||
|
||||
// Verify empty exam data in Redux store
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.examsData).toEqual([]);
|
||||
});
|
||||
|
||||
it('should re-fetch exam data when section scores change', async () => {
|
||||
// Initial render with limited section scores
|
||||
setTabData({
|
||||
section_scores: [mockSectionScores[0]], // Only first section
|
||||
});
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
// Verify initial API calls (2 subsections in first section)
|
||||
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(2);
|
||||
|
||||
// Clear axios history to track new calls
|
||||
axiosMock.resetHistory();
|
||||
|
||||
// Update with full section scores and re-render
|
||||
setTabData({ section_scores: mockSectionScores });
|
||||
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
|
||||
|
||||
// Verify additional API calls for all subsections
|
||||
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should handle exam API errors gracefully without breaking ProgressTab', async () => {
|
||||
// Clear existing mocks and setup specific error scenario
|
||||
axiosMock.reset();
|
||||
|
||||
// Re-add base mocks
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
axiosMock.onGet(progressUrl).reply(200, defaultTabData);
|
||||
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
|
||||
|
||||
// Mock first exam to return 500 error
|
||||
axiosMock.onGet(/.*exam1.*/).reply(500, { error: 'Server Error' });
|
||||
|
||||
// Mock other exams to succeed
|
||||
axiosMock.onGet(/.*homework1.*/).reply(404, { customAttributes: { httpErrorStatus: 404 } });
|
||||
axiosMock.onGet(/.*final_exam.*/).reply(200, {
|
||||
exam: {
|
||||
id: 2,
|
||||
course_id: courseId,
|
||||
content_id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@final_exam',
|
||||
exam_name: 'Final Exam',
|
||||
attempt_status: 'ready_to_start',
|
||||
time_remaining_seconds: 7200,
|
||||
},
|
||||
});
|
||||
|
||||
setTabData({ section_scores: mockSectionScores });
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
// Verify ProgressTab still renders successfully despite API error
|
||||
expect(screen.getByText('Grades')).toBeInTheDocument();
|
||||
|
||||
// Verify the exam data includes error placeholder for failed request
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.examsData).toHaveLength(3);
|
||||
expect(state.courseHome.examsData[0]).toEqual({}); // Failed request returns empty object
|
||||
});
|
||||
|
||||
it('should use EXAMS_BASE_URL when configured for exam API calls', async () => {
|
||||
// Configure EXAMS_BASE_URL
|
||||
const originalConfig = getConfig();
|
||||
setConfig({
|
||||
...originalConfig,
|
||||
EXAMS_BASE_URL: 'http://localhost:18740',
|
||||
});
|
||||
|
||||
// Override mock to use new base URL
|
||||
const examUrlWithExamsBase = /http:\/\/localhost:18740\/api\/v1\/student\/exam\/attempt\/course_id.*/;
|
||||
axiosMock.onGet(examUrlWithExamsBase).reply(200, {
|
||||
exam: {
|
||||
id: 1,
|
||||
course_id: courseId,
|
||||
exam_name: 'Test Exam',
|
||||
attempt_status: 'created',
|
||||
},
|
||||
});
|
||||
|
||||
setTabData({ section_scores: [mockSectionScores[0]] });
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
// Verify API calls use EXAMS_BASE_URL
|
||||
const examApiCalls = axiosMock.history.get.filter(req => req.url.includes('localhost:18740'));
|
||||
expect(examApiCalls.length).toBeGreaterThan(0);
|
||||
|
||||
// Restore original config
|
||||
setConfig(originalConfig);
|
||||
});
|
||||
|
||||
it('should extract sequence IDs correctly from nested section scores structure', async () => {
|
||||
const complexSectionScores = [
|
||||
{
|
||||
display_name: 'Introduction',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Lecture',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@intro',
|
||||
display_name: 'Course Introduction',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
display_name: 'Assessments',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Exam',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@quiz1',
|
||||
display_name: 'Quiz 1',
|
||||
},
|
||||
{
|
||||
assignment_type: 'Exam',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@quiz2',
|
||||
display_name: 'Quiz 2',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Mock all the expected sequence IDs
|
||||
const expectedSequenceIds = [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@intro',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@quiz1',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@quiz2',
|
||||
];
|
||||
|
||||
expectedSequenceIds.forEach((sequenceId, index) => {
|
||||
const examUrl = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}.*`);
|
||||
axiosMock.onGet(examUrl).reply(index === 0 ? 404 : 200, {
|
||||
exam: {
|
||||
id: index,
|
||||
course_id: courseId,
|
||||
content_id: sequenceId,
|
||||
exam_name: `Test ${index}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
setTabData({ section_scores: complexSectionScores });
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
// Verify API calls were made for all extracted sequence IDs
|
||||
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(3);
|
||||
|
||||
// Verify correct sequence IDs were used in API calls
|
||||
const apiCalls = axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'));
|
||||
expectedSequenceIds.forEach(sequenceId => {
|
||||
expect(apiCalls.some(call => call.url.includes(encodeURIComponent(sequenceId)))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
12
src/course-home/progress-tab/hooks.jsx
Normal file
12
src/course-home/progress-tab/hooks.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { fetchExamAttemptsData } from '../data/thunks';
|
||||
|
||||
export function useGetExamsData(courseId, sequenceIds) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchExamAttemptsData(courseId, sequenceIds));
|
||||
}, [dispatch, courseId, sequenceIds]);
|
||||
}
|
||||
168
src/course-home/progress-tab/hooks.test.jsx
Normal file
168
src/course-home/progress-tab/hooks.test.jsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useGetExamsData } from './hooks';
|
||||
import { fetchExamAttemptsData } from '../data/thunks';
|
||||
|
||||
// Mock the dependencies
|
||||
jest.mock('react-redux', () => ({
|
||||
useDispatch: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../data/thunks', () => ({
|
||||
fetchExamAttemptsData: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('useGetExamsData hook', () => {
|
||||
const mockDispatch = jest.fn();
|
||||
const mockFetchExamAttemptsData = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
useDispatch.mockReturnValue(mockDispatch);
|
||||
fetchExamAttemptsData.mockReturnValue(mockFetchExamAttemptsData);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should dispatch fetchExamAttemptsData on mount', () => {
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const sequenceIds = [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@67890',
|
||||
];
|
||||
|
||||
renderHook(() => useGetExamsData(courseId, sequenceIds));
|
||||
|
||||
expect(fetchExamAttemptsData).toHaveBeenCalledWith(courseId, sequenceIds);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
|
||||
});
|
||||
|
||||
it('should re-dispatch when courseId changes', () => {
|
||||
const initialCourseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const newCourseId = 'course-v1:edX+NewCourse+Demo';
|
||||
const sequenceIds = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345'];
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ courseId: cId, sequenceIds: sIds }) => useGetExamsData(cId, sIds),
|
||||
{
|
||||
initialProps: { courseId: initialCourseId, sequenceIds },
|
||||
},
|
||||
);
|
||||
|
||||
// Verify initial call
|
||||
expect(fetchExamAttemptsData).toHaveBeenCalledWith(initialCourseId, sequenceIds);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
|
||||
|
||||
// Clear mocks to isolate the re-render call
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Re-render with new courseId
|
||||
rerender({ courseId: newCourseId, sequenceIds });
|
||||
|
||||
expect(fetchExamAttemptsData).toHaveBeenCalledWith(newCourseId, sequenceIds);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
|
||||
});
|
||||
|
||||
it('should re-dispatch when sequenceIds changes', () => {
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const initialSequenceIds = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345'];
|
||||
const newSequenceIds = [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@67890',
|
||||
];
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ courseId: cId, sequenceIds: sIds }) => useGetExamsData(cId, sIds),
|
||||
{
|
||||
initialProps: { courseId, sequenceIds: initialSequenceIds },
|
||||
},
|
||||
);
|
||||
|
||||
// Verify initial call
|
||||
expect(fetchExamAttemptsData).toHaveBeenCalledWith(courseId, initialSequenceIds);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
|
||||
|
||||
// Clear mocks to isolate the re-render call
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Re-render with new sequenceIds
|
||||
rerender({ courseId, sequenceIds: newSequenceIds });
|
||||
|
||||
expect(fetchExamAttemptsData).toHaveBeenCalledWith(courseId, newSequenceIds);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
|
||||
});
|
||||
|
||||
it('should not re-dispatch when neither courseId nor sequenceIds changes', () => {
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const sequenceIds = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345'];
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ courseId: cId, sequenceIds: sIds }) => useGetExamsData(cId, sIds),
|
||||
{
|
||||
initialProps: { courseId, sequenceIds },
|
||||
},
|
||||
);
|
||||
|
||||
// Verify initial call
|
||||
expect(fetchExamAttemptsData).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Clear mocks to isolate the re-render call
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Re-render with same props
|
||||
rerender({ courseId, sequenceIds });
|
||||
|
||||
// Should not dispatch again
|
||||
expect(fetchExamAttemptsData).not.toHaveBeenCalled();
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle empty sequenceIds array', () => {
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const sequenceIds = [];
|
||||
|
||||
renderHook(() => useGetExamsData(courseId, sequenceIds));
|
||||
|
||||
expect(fetchExamAttemptsData).toHaveBeenCalledWith(courseId, []);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
|
||||
});
|
||||
|
||||
it('should handle null/undefined courseId', () => {
|
||||
const sequenceIds = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345'];
|
||||
|
||||
renderHook(() => useGetExamsData(null, sequenceIds));
|
||||
|
||||
expect(fetchExamAttemptsData).toHaveBeenCalledWith(null, sequenceIds);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
|
||||
});
|
||||
|
||||
it('should handle sequenceIds reference change but same content', () => {
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const sequenceIds1 = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345'];
|
||||
const sequenceIds2 = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345']; // Same content, different reference
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ courseId: cId, sequenceIds: sIds }) => useGetExamsData(cId, sIds),
|
||||
{
|
||||
initialProps: { courseId, sequenceIds: sequenceIds1 },
|
||||
},
|
||||
);
|
||||
|
||||
// Verify initial call
|
||||
expect(fetchExamAttemptsData).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Clear mocks to isolate the re-render call
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Re-render with different reference but same content
|
||||
rerender({ courseId, sequenceIds: sequenceIds2 });
|
||||
|
||||
// Should dispatch again because the reference changed (useEffect dependency)
|
||||
expect(fetchExamAttemptsData).toHaveBeenCalledWith(courseId, sequenceIds2);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user