diff --git a/src/course-home/data/api.js b/src/course-home/data/api.js index 8254d4ef1e..b2ec2d3a9f 100644 --- a/src/course-home/data/api.js +++ b/src/course-home/data/api.js @@ -379,3 +379,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; + } +} diff --git a/src/course-home/data/api.test.js b/src/course-home/data/api.test.js index 865967e774..dc40bf1946 100644 --- a/src/course-home/data/api.test.js +++ b/src/course-home/data/api.test.js @@ -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'); + }); +}); diff --git a/src/course-home/data/redux.test.js b/src/course-home/data/redux.test.js index 2054f5e3c2..2e40e38278 100644 --- a/src/course-home/data/redux.test.js +++ b/src/course-home/data/redux.test.js @@ -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(); + }); + }); }); diff --git a/src/course-home/data/slice.js b/src/course-home/data/slice.js index 21c804d3f3..86179f8aa7 100644 --- a/src/course-home/data/slice.js +++ b/src/course-home/data/slice.js @@ -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 { diff --git a/src/course-home/data/slice.test.js b/src/course-home/data/slice.test.js new file mode 100644 index 0000000000..76084fbddc --- /dev/null +++ b/src/course-home/data/slice.test.js @@ -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); + }); + }); +}); diff --git a/src/course-home/data/thunks.js b/src/course-home/data/thunks.js index 4dd3658e53..497e472c3b 100644 --- a/src/course-home/data/thunks.js +++ b/src/course-home/data/thunks.js @@ -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)); + }; +} diff --git a/src/course-home/progress-tab/ProgressTab.jsx b/src/course-home/progress-tab/ProgressTab.jsx index a0d86a288b..32506930bf 100644 --- a/src/course-home/progress-tab/ProgressTab.jsx +++ b/src/course-home/progress-tab/ProgressTab.jsx @@ -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) { diff --git a/src/course-home/progress-tab/ProgressTab.test.jsx b/src/course-home/progress-tab/ProgressTab.test.jsx index be99cab11d..9195823c04 100644 --- a/src/course-home/progress-tab/ProgressTab.test.jsx +++ b/src/course-home/progress-tab/ProgressTab.test.jsx @@ -1490,4 +1490,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); + }); + }); + }); }); diff --git a/src/course-home/progress-tab/hooks.jsx b/src/course-home/progress-tab/hooks.jsx new file mode 100644 index 0000000000..d1b707bc80 --- /dev/null +++ b/src/course-home/progress-tab/hooks.jsx @@ -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]); +} diff --git a/src/course-home/progress-tab/hooks.test.jsx b/src/course-home/progress-tab/hooks.test.jsx new file mode 100644 index 0000000000..1b2630889c --- /dev/null +++ b/src/course-home/progress-tab/hooks.test.jsx @@ -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); + }); +});