Skip to content

Commit 630e843

Browse files
feat: fetch exams data on the progress page (#1829)
* 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. --------- Co-authored-by: nsprenkle <[email protected]>
1 parent 9c5ac6a commit 630e843

File tree

10 files changed

+998
-4
lines changed

10 files changed

+998
-4
lines changed

src/course-home/data/api.js

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

380380
return camelCaseObject(response);
381381
}
382+
383+
export async function getExamsData(courseId, sequenceId) {
384+
let url;
385+
386+
if (!getConfig().EXAMS_BASE_URL) {
387+
url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
388+
} else {
389+
url = `${getConfig().EXAMS_BASE_URL}/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}`;
390+
}
391+
392+
try {
393+
const { data } = await getAuthenticatedHttpClient().get(url);
394+
return camelCaseObject(data);
395+
} catch (error) {
396+
const { httpErrorStatus } = error && error.customAttributes;
397+
if (httpErrorStatus === 404) {
398+
return {};
399+
}
400+
throw error;
401+
}
402+
}

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)