From d13e7520f07096e8ce1e5a34ccba12219141f058 Mon Sep 17 00:00:00 2001 From: Michael Roytman Date: Fri, 21 Nov 2025 06:48:18 -0500 Subject: [PATCH 1/2] feat: fetch exams data on the progress page 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. --- src/course-home/data/api.js | 21 ++++++++++++++++++++ src/course-home/data/slice.js | 5 +++++ src/course-home/data/thunks.js | 18 +++++++++++++++++ src/course-home/progress-tab/ProgressTab.jsx | 13 +++++++++--- src/course-home/progress-tab/hooks.jsx | 12 +++++++++++ 5 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 src/course-home/progress-tab/hooks.jsx 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/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/thunks.js b/src/course-home/data/thunks.js index 4dd3658e53..44b60fdc26 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 [sequenceId, {}]; + } + })); + + 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/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]); +} From 3f50a73d7bafb190e391fedfff3dcbac6e94847a Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Tue, 9 Dec 2025 15:38:03 -0500 Subject: [PATCH 2/2] test: add exams data to progress page --- src/course-home/data/api.test.js | 163 +++++++++- src/course-home/data/redux.test.js | 174 +++++++++++ src/course-home/data/slice.test.js | 145 +++++++++ src/course-home/data/thunks.js | 2 +- .../progress-tab/ProgressTab.test.jsx | 283 ++++++++++++++++++ src/course-home/progress-tab/hooks.test.jsx | 168 +++++++++++ 6 files changed, 933 insertions(+), 2 deletions(-) create mode 100644 src/course-home/data/slice.test.js create mode 100644 src/course-home/progress-tab/hooks.test.jsx 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.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 44b60fdc26..497e472c3b 100644 --- a/src/course-home/data/thunks.js +++ b/src/course-home/data/thunks.js @@ -234,7 +234,7 @@ export function fetchExamAttemptsData(courseId, sequenceIds) { return response.exam || {}; } catch (e) { logError(e); - return [sequenceId, {}]; + return {}; } })); 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.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); + }); +});