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 { 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 defaultMetadata = Factory.build('courseMetadata', { user_has_passing_grade: true, end: '2014-02-05T05:00:00Z', }); const defaultCourseBlocks = buildSimpleCourseBlocks(defaultMetadata.id, defaultMetadata.name); let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${defaultMetadata.id}`; courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl); const courseBlocksUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`); 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/*`); function setMetadata(attributes) { const courseMetadata = { ...defaultMetadata, ...attributes }; axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata); } async function fetchAndRender(component) { await executeThunk(fetchCourse(defaultMetadata.id), store.dispatch); render(component, { store }); } beforeEach(() => { store = initializeStore(); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata); axiosMock.onGet(courseBlocksUrlRegExp).reply(200, defaultCourseBlocks); axiosMock.onGet(discoveryRecommendationsUrl).reply(200, Factory.build('courseRecommendations', {}, { numRecs: 2 })); axiosMock.onGet(enrollmentsUrl).reply(200, []); axiosMock.onGet(learningSequencesUrlRegExp).reply(403, {}); 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(); 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(); 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(); expect(global.location.href).toEqual(`http://localhost/course/${defaultMetadata.id}`); }); }); describe('Course Celebration Experience', () => { it('Displays download link', async () => { setMetadata({ certificate_data: { cert_status: 'downloadable', download_url: 'fake.download.url', }, }); await fetchAndRender(); expect(screen.getByRole('link', { name: 'Download my certificate' })).toBeInTheDocument(); }); it('Displays webview link', async () => { setMetadata({ certificate_data: { cert_status: 'downloadable', cert_web_view_url: '/certificates/cooluuidgoeshere', }, }); await fetchAndRender(); 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(); expect(screen.getByText('Your grade and certificate will be ready soon!')).toBeInTheDocument(); }); it('Displays request certificate link', async () => { setMetadata({ certificate_data: { cert_status: 'requesting' } }); await fetchAndRender(); 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(); 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(); 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/${defaultMetadata.id}/`, }); await fetchAndRender(); 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/${defaultMetadata.id}/`, }); await fetchAndRender(); 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(); // 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(); // 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); const recommendationsTable = await screen.findByTestId('course-recommendations'); expect(recommendationsTable).toBeInTheDocument(); expect(screen.queryByText('Same Course')).not.toBeInTheDocument(); }); }); }); describe('Course Non-passing Experience', () => { it('Displays link to progress tab', async () => { setMetadata({ user_has_passing_grade: false }); await fetchAndRender(); 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(defaultMetadata.id, defaultMetadata.name, { hasScheduledContent: true }); axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks); axiosMock.onGet(learningSequencesUrlRegExp).reply(403, {}); await fetchAndRender(); 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(); 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":"${defaultMetadata.id}","subscribed_to_reminders":false}`); }); }); });