Allows frontend-app-learning to be configured at runtime using the LMS's new MFE Configuration API. Part of openedx/frontend-wg#103
462 lines
19 KiB
JavaScript
462 lines
19 KiB
JavaScript
import React from 'react';
|
||
import MockAdapter from 'axios-mock-adapter';
|
||
import { Factory } from 'rosie';
|
||
import { getConfig } from '@edx/frontend-platform';
|
||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||
import { waitFor } from '@testing-library/react';
|
||
|
||
import { fetchCourse } from '../../data';
|
||
import { buildSimpleCourseBlocks } from '../../../shared/data/__factories__/courseBlocks.factory';
|
||
import { buildOutlineFromBlocks } from '../../data/__factories__/learningSequencesOutline.factory';
|
||
import {
|
||
initializeMockApp, logUnhandledRequests, render, screen,
|
||
} from '../../../setupTest';
|
||
import initializeStore from '../../../store';
|
||
import { appendBrowserTimezoneToUrl, executeThunk } from '../../../utils';
|
||
import CourseCelebration from './CourseCelebration';
|
||
import CourseExit from './CourseExit';
|
||
import CourseInProgress from './CourseInProgress';
|
||
import CourseNonPassing from './CourseNonPassing';
|
||
|
||
initializeMockApp();
|
||
jest.mock('@edx/frontend-platform/analytics');
|
||
|
||
describe('Course Exit Pages', () => {
|
||
let axiosMock;
|
||
let store;
|
||
const coursewareMetadata = Factory.build('courseMetadata', {
|
||
user_has_passing_grade: true,
|
||
end: '2014-02-05T05:00:00Z',
|
||
});
|
||
const courseId = coursewareMetadata.id;
|
||
const courseHomeMetadata = Factory.build('courseHomeMetadata');
|
||
const { courseBlocks: defaultCourseBlocks } = buildSimpleCourseBlocks(courseId, courseHomeMetadata.title);
|
||
|
||
let coursewareMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`;
|
||
coursewareMetadataUrl = appendBrowserTimezoneToUrl(coursewareMetadataUrl);
|
||
const courseHomeMetadataUrl = appendBrowserTimezoneToUrl(`${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`);
|
||
const discoveryRecommendationsUrl = new RegExp(`${getConfig().DISCOVERY_API_BASE_URL}/api/v1/course_recommendations/*`);
|
||
const enrollmentsUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment*`);
|
||
const learningSequencesUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/*`);
|
||
const now = new Date();
|
||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||
const overmorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 2);
|
||
|
||
function setMetadata(coursewareAttributes, courseHomeAttributes = {}) {
|
||
const extendedCourseMetadata = { ...coursewareMetadata, ...coursewareAttributes };
|
||
axiosMock.onGet(coursewareMetadataUrl).reply(200, extendedCourseMetadata);
|
||
const extendedCourseHomeMetadata = { ...courseHomeMetadata, ...courseHomeAttributes };
|
||
axiosMock.onGet(courseHomeMetadataUrl).reply(200, extendedCourseHomeMetadata);
|
||
}
|
||
|
||
async function fetchAndRender(component) {
|
||
await executeThunk(fetchCourse(courseId), store.dispatch);
|
||
render(component, { store });
|
||
}
|
||
|
||
beforeEach(() => {
|
||
store = initializeStore();
|
||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||
axiosMock.onGet(coursewareMetadataUrl).reply(200, coursewareMetadata);
|
||
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
|
||
axiosMock.onGet(discoveryRecommendationsUrl).reply(200,
|
||
Factory.build('courseRecommendations', {}, { numRecs: 2 }));
|
||
axiosMock.onGet(enrollmentsUrl).reply(200, []);
|
||
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, buildOutlineFromBlocks(defaultCourseBlocks));
|
||
|
||
logUnhandledRequests(axiosMock);
|
||
});
|
||
|
||
describe('Course Exit routing', () => {
|
||
it('Routes to celebration for a celebration status', async () => {
|
||
setMetadata({
|
||
certificate_data: {
|
||
cert_status: 'downloadable',
|
||
cert_web_view_url: '/certificates/cooluuidgoeshere',
|
||
},
|
||
enrollment: {
|
||
is_active: true,
|
||
},
|
||
});
|
||
await fetchAndRender(<CourseExit />);
|
||
expect(screen.getByText('Congratulations!')).toBeInTheDocument();
|
||
});
|
||
|
||
it('Routes to Non-passing experience for a learner with non-passing grade', async () => {
|
||
setMetadata({
|
||
certificate_data: {
|
||
cert_status: 'unverified',
|
||
},
|
||
enrollment: {
|
||
is_active: true,
|
||
},
|
||
user_has_passing_grade: false,
|
||
});
|
||
await fetchAndRender(<CourseExit />);
|
||
expect(screen.getByText('You’ve reached the end of the course!')).toBeInTheDocument();
|
||
});
|
||
|
||
it('Redirects if it does not match any statuses', async () => {
|
||
setMetadata({
|
||
certificate_data: {
|
||
cert_status: 'bogus_status',
|
||
},
|
||
});
|
||
await fetchAndRender(<CourseExit />);
|
||
expect(global.location.href).toEqual(`http://localhost/course/${courseId}`);
|
||
});
|
||
});
|
||
|
||
describe('Course Celebration Experience', () => {
|
||
it('Displays webview link', async () => {
|
||
setMetadata({
|
||
certificate_data: {
|
||
cert_status: 'downloadable',
|
||
cert_web_view_url: '/certificates/cooluuidgoeshere',
|
||
},
|
||
});
|
||
await fetchAndRender(<CourseCelebration />);
|
||
expect(screen.getByRole('link', { name: 'View my certificate' })).toBeInTheDocument();
|
||
expect(screen.getByRole('img', { name: 'Sample certificate' })).toBeInTheDocument();
|
||
});
|
||
|
||
it('Displays certificate is earned but unavailable message', async () => {
|
||
setMetadata({
|
||
certificate_data: {
|
||
cert_status: 'earned_but_not_available',
|
||
certificate_available_date: '2021-05-21T12:00:00Z',
|
||
},
|
||
});
|
||
await fetchAndRender(<CourseCelebration />);
|
||
expect(screen.getByText('Your grade and certificate status will be available soon.')).toBeInTheDocument();
|
||
});
|
||
|
||
it('Displays request certificate link', async () => {
|
||
setMetadata({ certificate_data: { cert_status: 'requesting' } });
|
||
await fetchAndRender(<CourseCelebration />);
|
||
expect(screen.getByRole('button', { name: 'Request certificate' })).toBeInTheDocument();
|
||
});
|
||
|
||
it('Displays social share icons', async () => {
|
||
setMetadata({ certificate_data: { cert_status: 'unverified' }, marketing_url: 'https://edx.org' });
|
||
await fetchAndRender(<CourseCelebration />);
|
||
expect(screen.getByRole('button', { name: 'linkedin' })).toBeInTheDocument();
|
||
expect(screen.getByRole('button', { name: 'facebook' })).toBeInTheDocument();
|
||
expect(screen.getByRole('button', { name: 'twitter' })).toBeInTheDocument();
|
||
expect(screen.getByRole('button', { name: 'email' })).toBeInTheDocument();
|
||
});
|
||
|
||
it('Does not display social share icons if no marketing URL', async () => {
|
||
setMetadata({ certificate_data: { cert_status: 'unverified' } });
|
||
await fetchAndRender(<CourseCelebration />);
|
||
expect(screen.queryByRole('button', { name: 'linkedin' })).not.toBeInTheDocument();
|
||
expect(screen.queryByRole('button', { name: 'facebook' })).not.toBeInTheDocument();
|
||
expect(screen.queryByRole('button', { name: 'twitter' })).not.toBeInTheDocument();
|
||
expect(screen.queryByRole('button', { name: 'email' })).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('Displays verify identity link', async () => {
|
||
setMetadata({
|
||
certificate_data: { cert_status: 'unverified' },
|
||
verify_identity_url: `${getConfig().LMS_BASE_URL}/verify_student/verify-now/${courseId}/`,
|
||
});
|
||
await fetchAndRender(<CourseCelebration />);
|
||
expect(screen.getByRole('link', { name: 'Verify ID now' })).toBeInTheDocument();
|
||
expect(screen.queryByRole('img', { name: 'Sample certificate' })).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('Displays verification pending message', async () => {
|
||
setMetadata({
|
||
certificate_data: { cert_status: 'unverified' },
|
||
verification_status: 'pending',
|
||
verify_identity_url: `${getConfig().LMS_BASE_URL}/verify_student/verify-now/${courseId}/`,
|
||
});
|
||
await fetchAndRender(<CourseCelebration />);
|
||
expect(screen.getByText('Your ID verification is pending and your certificate will be available once approved.')).toBeInTheDocument();
|
||
expect(screen.queryByRole('link', { name: 'Verify ID now' })).not.toBeInTheDocument();
|
||
expect(screen.queryByRole('img', { name: 'Sample certificate' })).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('Displays upgrade link when available', async () => {
|
||
setMetadata({
|
||
certificate_data: { cert_status: 'audit_passing' },
|
||
},
|
||
{
|
||
verified_mode: {
|
||
access_expiration_date: '9999-08-06T12:00:00Z',
|
||
upgrade_url: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||
price: 600,
|
||
currency_symbol: '€',
|
||
},
|
||
});
|
||
await fetchAndRender(<CourseCelebration />);
|
||
// Keep these text checks in sync with "audit only" test below, so it doesn't end up checking for text that is
|
||
// never actually there, when/if the text changes.
|
||
expect(screen.getByText('Upgrade to pursue a verified certificate')).toBeInTheDocument();
|
||
expect(screen.getByText('For €600 you will unlock access', { exact: false })).toBeInTheDocument();
|
||
expect(screen.getByRole('link', { name: 'Upgrade now' })).toBeInTheDocument();
|
||
const node = screen.getByText('Access to this course and its materials', { exact: false });
|
||
expect(node.textContent).toMatch(/until August 6, 9999\./);
|
||
});
|
||
|
||
it('Displays nothing if audit only', async () => {
|
||
setMetadata({
|
||
certificate_data: { cert_status: 'audit_passing' },
|
||
},
|
||
{
|
||
verified_mode: null,
|
||
});
|
||
await fetchAndRender(<CourseCelebration />);
|
||
// Keep these queries in sync with "upgrade link" test above, so we don't end up checking for text that is
|
||
// never actually there, when/if the text changes.
|
||
expect(screen.queryByText('Upgrade to pursue a verified certificate')).not.toBeInTheDocument();
|
||
expect(screen.queryByRole('button', { name: 'Upgrade now' })).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('Displays LinkedIn Add to Profile button', async () => {
|
||
setMetadata({
|
||
certificate_data: {
|
||
cert_status: 'downloadable',
|
||
cert_web_view_url: '/certificates/cooluuidgoeshere',
|
||
},
|
||
linkedin_add_to_profile_url: 'https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME¶ms',
|
||
});
|
||
await fetchAndRender(<CourseCelebration />);
|
||
expect(screen.getByRole('link', { name: 'View my certificate' })).toBeInTheDocument();
|
||
expect(screen.getByRole('link', { name: 'Add to LinkedIn profile' })).toBeInTheDocument();
|
||
});
|
||
|
||
describe('Program Completion experience', () => {
|
||
beforeEach(() => {
|
||
setMetadata({
|
||
certificate_data: {
|
||
cert_status: 'downloadable',
|
||
cert_web_view_url: '/certificates/cooluuidgoeshere',
|
||
},
|
||
});
|
||
});
|
||
|
||
it('Does not render ProgramCompletion no related programs', async () => {
|
||
await fetchAndRender(<CourseCelebration />);
|
||
expect(screen.queryByTestId('program-completion')).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('Does not render ProgramCompletion if program is incomplete', async () => {
|
||
setMetadata({
|
||
related_programs: [{
|
||
progress: {
|
||
completed: 1,
|
||
in_progress: 1,
|
||
not_started: 1,
|
||
},
|
||
slug: 'micromasters',
|
||
title: 'Example MicroMasters Program',
|
||
uuid: '123456',
|
||
url: 'http://localhost:18000/dashboard/programs/123456',
|
||
}],
|
||
});
|
||
await fetchAndRender(<CourseCelebration />);
|
||
|
||
expect(screen.queryByTestId('program-completion')).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('Renders ProgramCompletion if program is complete', async () => {
|
||
setMetadata({
|
||
related_programs: [{
|
||
progress: {
|
||
completed: 3,
|
||
in_progress: 0,
|
||
not_started: 0,
|
||
},
|
||
slug: 'micromasters',
|
||
title: 'Example MicroMasters Program',
|
||
uuid: '123456',
|
||
url: 'http://localhost:18000/dashboard/programs/123456',
|
||
}],
|
||
});
|
||
await fetchAndRender(<CourseCelebration />);
|
||
|
||
expect(screen.queryByTestId('program-completion')).toBeInTheDocument();
|
||
expect(screen.queryByTestId('micromasters')).toBeInTheDocument();
|
||
});
|
||
|
||
it('Does not render ProgramCompletion if program is an excluded type', async () => {
|
||
setMetadata({
|
||
related_programs: [{
|
||
progress: {
|
||
completed: 3,
|
||
in_progress: 0,
|
||
not_started: 0,
|
||
},
|
||
slug: 'excluded-program-type',
|
||
title: 'Example Excluded Program',
|
||
uuid: '123456',
|
||
url: 'http://localhost:18000/dashboard/programs/123456',
|
||
}],
|
||
});
|
||
await fetchAndRender(<CourseCelebration />);
|
||
|
||
expect(screen.queryByTestId('program-completion')).not.toBeInTheDocument();
|
||
expect(screen.queryByTestId('excluded-program-type')).not.toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
describe('Course recommendations', () => {
|
||
it('Displays recommendations if at least two are available', async () => {
|
||
await fetchAndRender(<CourseCelebration />);
|
||
const recommendationsTable = await screen.findByTestId('course-recommendations');
|
||
expect(recommendationsTable).toBeInTheDocument();
|
||
expect(screen.queryByTestId('catalog-suggestion')).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('Displays the generic catalog suggestion if fewer than two recommendations are available', async () => {
|
||
axiosMock.onGet(discoveryRecommendationsUrl).reply(200,
|
||
Factory.build('courseRecommendations', {}, { numRecs: 1 }));
|
||
await fetchAndRender(<CourseCelebration />);
|
||
const catalogSuggestion = await screen.findByTestId('catalog-suggestion');
|
||
expect(catalogSuggestion).toBeInTheDocument();
|
||
expect(screen.queryByTestId('course-recommendations')).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('Will not recommend a course in which the user is already enrolled', async () => {
|
||
const initialRecommendations = Factory.build('courseRecommendations', {}, { numRecs: 2 });
|
||
initialRecommendations.recommendations.push(
|
||
Factory.build('courseRecommendation', { key: 'edX+EnrolledX', title: 'Already Enrolled' }),
|
||
);
|
||
initialRecommendations.recommendations.push(
|
||
Factory.build('courseRecommendation', { key: 'edX+NotEnrolledX', title: 'Not Already Enrolled' }),
|
||
);
|
||
axiosMock.onGet(discoveryRecommendationsUrl).reply(200, initialRecommendations);
|
||
axiosMock.onGet(enrollmentsUrl).reply(200, [
|
||
Factory.build('userEnrollment', '',
|
||
{
|
||
runKey: 'edX+EnrolledX+1T2021',
|
||
}),
|
||
]);
|
||
await fetchAndRender(<CourseCelebration />);
|
||
const recommendationsTable = await screen.findByTestId('course-recommendations');
|
||
expect(recommendationsTable).toBeInTheDocument();
|
||
expect(screen.queryByText('Already Enrolled')).not.toBeInTheDocument();
|
||
expect(screen.queryByText('Not Already Enrolled')).toBeInTheDocument();
|
||
});
|
||
|
||
it('Will not recommend the same course that the user just finished', async () => {
|
||
// the uuid returned from the call to discovery is the uuid of the current course
|
||
const initialRecommendations = Factory.build('courseRecommendations',
|
||
{ uuid: 'my_uuid' },
|
||
{ numRecs: 2 });
|
||
initialRecommendations.recommendations.push(
|
||
Factory.build('courseRecommendation', { uuid: 'my_uuid', title: 'Same Course' }),
|
||
);
|
||
axiosMock.onGet(discoveryRecommendationsUrl).reply(200, initialRecommendations);
|
||
await fetchAndRender(<CourseCelebration />);
|
||
const recommendationsTable = await screen.findByTestId('course-recommendations');
|
||
expect(recommendationsTable).toBeInTheDocument();
|
||
expect(screen.queryByText('Same Course')).not.toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
it('Shows not available messaging before certificates are available to nonpassing learners when theres no certificate data', async () => {
|
||
setMetadata({
|
||
is_enrolled: true,
|
||
end: tomorrow.toISOString(),
|
||
user_has_passing_grade: false,
|
||
certificate_data: undefined,
|
||
}, {
|
||
can_view_certificate: false,
|
||
});
|
||
await fetchAndRender(<CourseCelebration />);
|
||
expect(screen.getByText(`Final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
})}.`)).toBeInTheDocument();
|
||
});
|
||
|
||
it('Shows not available messaging before certificates are available to passing learners when theres no certificate data', async () => {
|
||
setMetadata({
|
||
is_enrolled: true,
|
||
end: tomorrow.toISOString(),
|
||
user_has_passing_grade: true,
|
||
certificate_data: undefined,
|
||
}, {
|
||
can_view_certificate: false,
|
||
});
|
||
await fetchAndRender(<CourseCelebration />);
|
||
expect(screen.getByText(`Final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
})}.`)).toBeInTheDocument();
|
||
});
|
||
|
||
it('Shows certificate_available_date if learner is passing', async () => {
|
||
setMetadata({
|
||
is_enrolled: true,
|
||
end: tomorrow.toISOString(),
|
||
user_has_passing_grade: true,
|
||
certificate_data: {
|
||
cert_status: 'earned_but_not_available',
|
||
certificate_available_date: overmorrow.toISOString(),
|
||
},
|
||
}, {
|
||
can_view_certificate: false,
|
||
});
|
||
|
||
await fetchAndRender(<CourseCelebration />);
|
||
expect(screen.getByText('Your grade and certificate status will be available soon.'));
|
||
expect(screen.getByText(
|
||
overmorrow.toLocaleDateString('en-us', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
}),
|
||
{ exact: false },
|
||
)).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
describe('Course Non-passing Experience', () => {
|
||
it('Displays link to progress tab', async () => {
|
||
setMetadata({ user_has_passing_grade: false });
|
||
await fetchAndRender(<CourseNonPassing />);
|
||
expect(screen.getByText('You’ve reached the end of the course!')).toBeInTheDocument();
|
||
expect(screen.getByRole('link', { name: 'View grades' })).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
describe('Course in progress experience', () => {
|
||
it('Displays link to dates tab', async () => {
|
||
setMetadata({ user_has_passing_grade: false });
|
||
const { courseBlocks } = buildSimpleCourseBlocks(courseId, courseHomeMetadata.title,
|
||
{ hasScheduledContent: true });
|
||
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, buildOutlineFromBlocks(courseBlocks));
|
||
|
||
await fetchAndRender(<CourseInProgress />);
|
||
expect(screen.getByText('More content is coming soon!')).toBeInTheDocument();
|
||
expect(screen.getByRole('link', { name: 'View course schedule' })).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
it('unsubscribes the user when loading the course exit page', async () => {
|
||
setMetadata({
|
||
enrollment: {
|
||
mode: 'audit',
|
||
courseGoals: {
|
||
goal_options: [],
|
||
selected_goal: {
|
||
days_per_week: 1,
|
||
subscribed_to_reminders: true,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
await fetchAndRender(<CourseExit />);
|
||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
|
||
await waitFor(() => {
|
||
expect(axiosMock.history.post[0].url).toMatch(url);
|
||
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","subscribed_to_reminders":false}`);
|
||
});
|
||
});
|
||
});
|