Skip to content

Commit f4e88ce

Browse files
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>
1 parent fb6ad62 commit f4e88ce

File tree

11 files changed

+1001
-4
lines changed

11 files changed

+1001
-4
lines changed

src/course-home/data/__snapshots__/redux.test.js.snap

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
55
"courseHome": {
66
"courseId": "course-v1:edX+DemoX+Demo_Course",
77
"courseStatus": "loaded",
8+
"examsData": null,
89
"proctoringPanelStatus": "loading",
910
"showSearch": false,
1011
"targetUserId": undefined,
@@ -397,6 +398,7 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
397398
"courseHome": {
398399
"courseId": "course-v1:edX+DemoX+Demo_Course",
399400
"courseStatus": "loaded",
401+
"examsData": null,
400402
"proctoringPanelStatus": "loading",
401403
"showSearch": false,
402404
"targetUserId": undefined,
@@ -670,6 +672,7 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
670672
"courseHome": {
671673
"courseId": "course-v1:edX+DemoX+Demo_Course",
672674
"courseStatus": "loaded",
675+
"examsData": null,
673676
"proctoringPanelStatus": "loading",
674677
"showSearch": false,
675678
"targetUserId": undefined,

src/course-home/data/api.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,3 +472,24 @@ export async function searchCourseContentFromAPI(courseId, searchKeyword, option
472472

473473
return camelCaseObject(response);
474474
}
475+
476+
export async function getExamsData(courseId, sequenceId) {
477+
let url;
478+
479+
if (!getConfig().EXAMS_BASE_URL) {
480+
url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
481+
} else {
482+
url = `${getConfig().EXAMS_BASE_URL}/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}`;
483+
}
484+
485+
try {
486+
const { data } = await getAuthenticatedHttpClient().get(url);
487+
return camelCaseObject(data);
488+
} catch (error) {
489+
const { httpErrorStatus } = error && error.customAttributes;
490+
if (httpErrorStatus === 404) {
491+
return {};
492+
}
493+
throw error;
494+
}
495+
}

src/course-home/data/api.test.js

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { getTimeOffsetMillis } from './api';
1+
import { getConfig, setConfig } from '@edx/frontend-platform';
2+
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
3+
import MockAdapter from 'axios-mock-adapter';
4+
import { getTimeOffsetMillis, getExamsData } from './api';
5+
import { initializeMockApp } from '../../setupTest';
6+
7+
initializeMockApp();
8+
9+
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
210

311
describe('Calculate the time offset properly', () => {
412
it('Should return 0 if the headerDate is not set', async () => {
@@ -14,3 +22,156 @@ describe('Calculate the time offset properly', () => {
1422
expect(offset).toBe(86398750);
1523
});
1624
});
25+
26+
describe('getExamsData', () => {
27+
const courseId = 'course-v1:edX+DemoX+Demo_Course';
28+
const sequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345';
29+
let originalConfig;
30+
31+
beforeEach(() => {
32+
axiosMock.reset();
33+
originalConfig = getConfig();
34+
});
35+
36+
afterEach(() => {
37+
axiosMock.reset();
38+
if (originalConfig) {
39+
setConfig(originalConfig);
40+
}
41+
});
42+
43+
it('should use LMS URL when EXAMS_BASE_URL is not configured', async () => {
44+
setConfig({
45+
...originalConfig,
46+
EXAMS_BASE_URL: undefined,
47+
LMS_BASE_URL: 'http://localhost:18000',
48+
});
49+
50+
const mockExamData = {
51+
exam: {
52+
id: 1,
53+
course_id: courseId,
54+
content_id: sequenceId,
55+
exam_name: 'Test Exam',
56+
attempt_status: 'created',
57+
},
58+
};
59+
60+
const expectedUrl = `http://localhost:18000/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
61+
axiosMock.onGet(expectedUrl).reply(200, mockExamData);
62+
63+
const result = await getExamsData(courseId, sequenceId);
64+
65+
expect(result).toEqual({
66+
exam: {
67+
id: 1,
68+
courseId,
69+
contentId: sequenceId,
70+
examName: 'Test Exam',
71+
attemptStatus: 'created',
72+
},
73+
});
74+
expect(axiosMock.history.get).toHaveLength(1);
75+
expect(axiosMock.history.get[0].url).toBe(expectedUrl);
76+
});
77+
78+
it('should use EXAMS_BASE_URL when configured', async () => {
79+
setConfig({
80+
...originalConfig,
81+
EXAMS_BASE_URL: 'http://localhost:18740',
82+
LMS_BASE_URL: 'http://localhost:18000',
83+
});
84+
85+
const mockExamData = {
86+
exam: {
87+
id: 1,
88+
course_id: courseId,
89+
content_id: sequenceId,
90+
exam_name: 'Test Exam',
91+
attempt_status: 'submitted',
92+
},
93+
};
94+
95+
const expectedUrl = `http://localhost:18740/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}`;
96+
axiosMock.onGet(expectedUrl).reply(200, mockExamData);
97+
98+
const result = await getExamsData(courseId, sequenceId);
99+
100+
expect(result).toEqual({
101+
exam: {
102+
id: 1,
103+
courseId,
104+
contentId: sequenceId,
105+
examName: 'Test Exam',
106+
attemptStatus: 'submitted',
107+
},
108+
});
109+
expect(axiosMock.history.get).toHaveLength(1);
110+
expect(axiosMock.history.get[0].url).toBe(expectedUrl);
111+
});
112+
113+
it('should return empty object when API returns 404', async () => {
114+
setConfig({
115+
...originalConfig,
116+
EXAMS_BASE_URL: undefined,
117+
LMS_BASE_URL: 'http://localhost:18000',
118+
});
119+
120+
const expectedUrl = `http://localhost:18000/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
121+
122+
// Mock a 404 error with the custom error response function to add customAttributes
123+
axiosMock.onGet(expectedUrl).reply(() => {
124+
const error = new Error('Request failed with status code 404');
125+
error.response = { status: 404, data: {} };
126+
error.customAttributes = { httpErrorStatus: 404 };
127+
return Promise.reject(error);
128+
});
129+
130+
const result = await getExamsData(courseId, sequenceId);
131+
132+
expect(result).toEqual({});
133+
expect(axiosMock.history.get).toHaveLength(1);
134+
});
135+
136+
it('should throw error for non-404 HTTP errors', async () => {
137+
setConfig({
138+
...originalConfig,
139+
EXAMS_BASE_URL: undefined,
140+
LMS_BASE_URL: 'http://localhost:18000',
141+
});
142+
143+
const expectedUrl = `http://localhost:18000/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
144+
145+
// Mock a 500 error with custom error response
146+
axiosMock.onGet(expectedUrl).reply(() => {
147+
const error = new Error('Request failed with status code 500');
148+
error.response = { status: 500, data: { error: 'Server Error' } };
149+
error.customAttributes = { httpErrorStatus: 500 };
150+
return Promise.reject(error);
151+
});
152+
153+
await expect(getExamsData(courseId, sequenceId)).rejects.toThrow();
154+
expect(axiosMock.history.get).toHaveLength(1);
155+
});
156+
157+
it('should properly encode URL parameters', async () => {
158+
setConfig({
159+
...originalConfig,
160+
EXAMS_BASE_URL: 'http://localhost:18740',
161+
LMS_BASE_URL: 'http://localhost:18000',
162+
});
163+
164+
const specialCourseId = 'course-v1:edX+Demo X+Demo Course';
165+
const specialSequenceId = 'block-v1:edX+Demo X+Demo Course+type@sequential+block@test sequence';
166+
167+
const mockExamData = { exam: { id: 1 } };
168+
const expectedUrl = `http://localhost:18740/api/v1/student/exam/attempt/course_id/${encodeURIComponent(specialCourseId)}/content_id/${encodeURIComponent(specialSequenceId)}`;
169+
axiosMock.onGet(expectedUrl).reply(200, mockExamData);
170+
171+
await getExamsData(specialCourseId, specialSequenceId);
172+
173+
expect(axiosMock.history.get[0].url).toBe(expectedUrl);
174+
expect(axiosMock.history.get[0].url).toContain('course-v1%3AedX%2BDemo%20X%2BDemo%20Course');
175+
expect(axiosMock.history.get[0].url).toContain('block-v1%3AedX%2BDemo%20X%2BDemo%20Course%2Btype%40sequential%2Bblock%40test%20sequence');
176+
});
177+
});

src/course-home/data/redux.test.js

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,4 +297,178 @@ describe('Data layer integration tests', () => {
297297
expect(enabled).toBe(false);
298298
});
299299
});
300+
301+
describe('Test fetchExamAttemptsData', () => {
302+
const sequenceIds = [
303+
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
304+
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@67890',
305+
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@abcde',
306+
];
307+
308+
beforeEach(() => {
309+
// Mock individual exam endpoints with different responses
310+
sequenceIds.forEach((sequenceId, index) => {
311+
// Handle both LMS and EXAMS service URL patterns
312+
const lmsExamUrl = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceId)}.*`);
313+
const examsServiceUrl = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}.*`);
314+
315+
let attemptStatus = 'ready_to_start';
316+
if (index === 0) {
317+
attemptStatus = 'created';
318+
} else if (index === 1) {
319+
attemptStatus = 'submitted';
320+
}
321+
322+
const mockExamData = {
323+
exam: {
324+
id: index + 1,
325+
course_id: courseId,
326+
content_id: sequenceId,
327+
exam_name: `Test Exam ${index + 1}`,
328+
attempt_status: attemptStatus,
329+
time_remaining_seconds: 3600,
330+
},
331+
};
332+
333+
// Mock both URL patterns
334+
axiosMock.onGet(lmsExamUrl).reply(200, mockExamData);
335+
axiosMock.onGet(examsServiceUrl).reply(200, mockExamData);
336+
});
337+
});
338+
339+
it('should fetch exam data for all sequence IDs and dispatch setExamsData', async () => {
340+
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
341+
342+
const state = store.getState();
343+
344+
// Verify the examsData was set in the store
345+
expect(state.courseHome.examsData).toHaveLength(3);
346+
expect(state.courseHome.examsData).toEqual([
347+
{
348+
id: 1,
349+
courseId,
350+
contentId: sequenceIds[0],
351+
examName: 'Test Exam 1',
352+
attemptStatus: 'created',
353+
timeRemainingSeconds: 3600,
354+
},
355+
{
356+
id: 2,
357+
courseId,
358+
contentId: sequenceIds[1],
359+
examName: 'Test Exam 2',
360+
attemptStatus: 'submitted',
361+
timeRemainingSeconds: 3600,
362+
},
363+
{
364+
id: 3,
365+
courseId,
366+
contentId: sequenceIds[2],
367+
examName: 'Test Exam 3',
368+
attemptStatus: 'ready_to_start',
369+
timeRemainingSeconds: 3600,
370+
},
371+
]);
372+
373+
// Verify all API calls were made
374+
expect(axiosMock.history.get).toHaveLength(3);
375+
});
376+
377+
it('should handle 404 responses and include empty objects in results', async () => {
378+
// Override one endpoint to return 404 for both URL patterns
379+
const examUrl404LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[1])}.*`);
380+
const examUrl404Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[1])}.*`);
381+
axiosMock.onGet(examUrl404LMS).reply(404);
382+
axiosMock.onGet(examUrl404Exams).reply(404);
383+
384+
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
385+
386+
const state = store.getState();
387+
388+
// Verify the examsData includes empty object for 404 response
389+
expect(state.courseHome.examsData).toHaveLength(3);
390+
expect(state.courseHome.examsData[1]).toEqual({});
391+
});
392+
393+
it('should handle API errors and log them while continuing with other requests', async () => {
394+
// Override one endpoint to return 500 error for both URL patterns
395+
const examUrl500LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[0])}.*`);
396+
const examUrl500Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[0])}.*`);
397+
axiosMock.onGet(examUrl500LMS).reply(500, { error: 'Server Error' });
398+
axiosMock.onGet(examUrl500Exams).reply(500, { error: 'Server Error' });
399+
400+
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
401+
402+
const state = store.getState();
403+
404+
// Verify error was logged for the failed request
405+
expect(loggingService.logError).toHaveBeenCalled();
406+
407+
// Verify the examsData still includes results for successful requests
408+
expect(state.courseHome.examsData).toHaveLength(3);
409+
// First item should be the error result (just empty object for API errors)
410+
expect(state.courseHome.examsData[0]).toEqual({});
411+
});
412+
413+
it('should handle empty sequence IDs array', async () => {
414+
await executeThunk(thunks.fetchExamAttemptsData(courseId, []), store.dispatch);
415+
416+
const state = store.getState();
417+
418+
expect(state.courseHome.examsData).toEqual([]);
419+
expect(axiosMock.history.get).toHaveLength(0);
420+
});
421+
422+
it('should handle mixed success and error responses', async () => {
423+
// Setup mixed responses
424+
const examUrl1LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[0])}.*`);
425+
const examUrl1Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[0])}.*`);
426+
const examUrl2LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[1])}.*`);
427+
const examUrl2Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[1])}.*`);
428+
const examUrl3LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[2])}.*`);
429+
const examUrl3Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[2])}.*`);
430+
431+
axiosMock.onGet(examUrl1LMS).reply(200, {
432+
exam: {
433+
id: 1,
434+
exam_name: 'Success Exam',
435+
course_id: courseId,
436+
content_id: sequenceIds[0],
437+
attempt_status: 'created',
438+
time_remaining_seconds: 3600,
439+
},
440+
});
441+
axiosMock.onGet(examUrl1Exams).reply(200, {
442+
exam: {
443+
id: 1,
444+
exam_name: 'Success Exam',
445+
course_id: courseId,
446+
content_id: sequenceIds[0],
447+
attempt_status: 'created',
448+
time_remaining_seconds: 3600,
449+
},
450+
});
451+
axiosMock.onGet(examUrl2LMS).reply(404);
452+
axiosMock.onGet(examUrl2Exams).reply(404);
453+
axiosMock.onGet(examUrl3LMS).reply(500, { error: 'Server Error' });
454+
axiosMock.onGet(examUrl3Exams).reply(500, { error: 'Server Error' });
455+
456+
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
457+
458+
const state = store.getState();
459+
460+
expect(state.courseHome.examsData).toHaveLength(3);
461+
expect(state.courseHome.examsData[0]).toMatchObject({
462+
id: 1,
463+
examName: 'Success Exam',
464+
courseId,
465+
contentId: sequenceIds[0],
466+
});
467+
expect(state.courseHome.examsData[1]).toEqual({});
468+
expect(state.courseHome.examsData[2]).toEqual({});
469+
470+
// Verify error was logged for the 500 error (may be called more than once due to multiple URL patterns)
471+
expect(loggingService.logError).toHaveBeenCalled();
472+
});
473+
});
300474
});

0 commit comments

Comments
 (0)