From 15ae6d498195657594ba2aca0e9edc68d756624f Mon Sep 17 00:00:00 2001 From: Michael Terry Date: Tue, 20 Oct 2020 10:03:30 -0400 Subject: [PATCH] AA-362: Add tests for outline tab alerts (#250) --- .../__factories__/outlineTabData.factory.js | 43 +-- .../data/__snapshots__/redux.test.js.snap | 4 +- .../outline-tab/OutlineTab.test.jsx | 295 ++++++++++-------- 3 files changed, 193 insertions(+), 149 deletions(-) diff --git a/src/course-home/data/__factories__/outlineTabData.factory.js b/src/course-home/data/__factories__/outlineTabData.factory.js index f5da722a..6cc4faf0 100644 --- a/src/course-home/data/__factories__/outlineTabData.factory.js +++ b/src/course-home/data/__factories__/outlineTabData.factory.js @@ -5,7 +5,7 @@ import buildSimpleCourseBlocks from './courseBlocks.factory'; Factory.define('outlineTabData') .option('courseId', 'course-v1:edX+DemoX+Demo_Course') .option('host', 'http://localhost:18000') - .attr('course_expired_html', [], () => '
Course expired
') + .option('dateBlocks', []) .attr('course_tools', ['host', 'courseId'], (host, courseId) => ([{ analytics_id: 'edx.bookmarks', title: 'Bookmarks', @@ -17,27 +17,30 @@ Factory.define('outlineTabData') blocks: courseBlocks.blocks, }; }) - .attr('course_goals', [], () => ({ - goal_options: [], - selected_goal: null, + .attr('dates_widget', ['dateBlocks'], (dateBlocks) => ({ + course_date_blocks: dateBlocks, + user_timezone: 'UTC', })) - .attr('enroll_alert', { - can_enroll: true, - extra_text: 'Contact the administrator.', - }) - .attr('dates_banner_info', { - content_type_gating_enabled: false, - missed_gated_content: false, - missed_deadlines: false, - }) - .attr('dates_widget', { - courseDateBlocks: [], - userTimezone: 'UTC', - }) - .attr('handouts_html', [], () => '') - .attr('offer_html', [], () => '
Great offer here
') .attr('resume_course', ['host', 'courseId'], (host, courseId) => ({ has_visited_course: false, url: `${host}/courses/${courseId}/jump_to/block-v1:edX+Test+Block@12345abcde`, })) - .attr('welcome_message_html', [], () => '

Welcome to this course!

'); + .attrs({ + course_expired_html: null, + course_goals: { + goal_options: [], + selected_goal: null, + }, + dates_banner_info: { + content_type_gating_enabled: false, + missed_gated_content: false, + missed_deadlines: false, + }, + enroll_alert: { + can_enroll: true, + extra_text: 'Contact the administrator.', + }, + handouts_html: '', + offer_html: null, + welcome_message_html: '

Welcome to this course!

', + }); diff --git a/src/course-home/data/__snapshots__/redux.test.js.snap b/src/course-home/data/__snapshots__/redux.test.js.snap index f88dc485..9aec5144 100644 --- a/src/course-home/data/__snapshots__/redux.test.js.snap +++ b/src/course-home/data/__snapshots__/redux.test.js.snap @@ -374,7 +374,7 @@ Object { }, }, }, - "courseExpiredHtml": "
Course expired
", + "courseExpiredHtml": null, "courseGoals": Object { "goalOptions": Array [], "selectedGoal": null, @@ -402,7 +402,7 @@ Object { "handoutsHtml": "", "hasEnded": undefined, "id": "course-v1:edX+DemoX+Demo_Course_1", - "offerHtml": "
Great offer here
", + "offerHtml": null, "resumeCourse": Object { "hasVisitedCourse": false, "url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde", diff --git a/src/course-home/outline-tab/OutlineTab.test.jsx b/src/course-home/outline-tab/OutlineTab.test.jsx index 85b9e798..c3d6998b 100644 --- a/src/course-home/outline-tab/OutlineTab.test.jsx +++ b/src/course-home/outline-tab/OutlineTab.test.jsx @@ -21,63 +21,72 @@ jest.mock('@edx/frontend-platform/analytics'); describe('Outline Tab', () => { let axiosMock; - const courseMetadataUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/*`); - const goalUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`); - const outlineUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/*`); + const courseId = 'course-v1:edX+Test+run'; + const courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`; + const enrollmentUrl = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`; + const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`; + const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`; const store = initializeStore(); + const defaultMetadata = Factory.build('courseHomeMetadata', { courseId }); + const defaultTabData = Factory.build('outlineTabData'); - const courseMetadata = Factory.build('courseHomeMetadata'); - const { courseId } = courseMetadata; - const outlineTabData = Factory.build('outlineTabData'); + function setMetadata(attributes, options) { + const courseMetadata = Factory.build('courseHomeMetadata', { courseId, ...attributes }, options); + axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata); + } + + function setTabData(attributes, options) { + const outlineTabData = Factory.build('outlineTabData', attributes, options); + axiosMock.onGet(outlineUrl).reply(200, outlineTabData); + } + + async function fetchAndRender() { + await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); + render(, { store }); + } beforeEach(async () => { axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata); - axiosMock.onGet(outlineUrl).reply(200, outlineTabData); + + // Set defaults for network requests + axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata); + axiosMock.onPost(enrollmentUrl).reply(200, {}); axiosMock.onPost(goalUrl).reply(200, { header: 'Success' }); + axiosMock.onGet(outlineUrl).reply(200, defaultTabData); + logUnhandledRequests(axiosMock); - await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); }); describe('Course Outline', () => { - it('displays link to start course', () => { - render(, { store }); + it('displays link to start course', async () => { + await fetchAndRender(); expect(screen.getByRole('link', { name: 'Start Course' })).toBeInTheDocument(); }); it('displays link to resume course', async () => { - const outlineTabDataHasVisited = Factory.build('outlineTabData', { - courseId, + setTabData({ resume_course: { has_visited_course: true, url: `${getConfig().LMS_BASE_URL}/courses/${courseId}/jump_to/block-v1:edX+Test+Block@12345abcde`, }, }); - axiosMock.onGet(outlineUrl).reply(200, outlineTabDataHasVisited); - await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); - - render(, { store }); - + await fetchAndRender(); expect(screen.getByRole('link', { name: 'Resume Course' })).toBeInTheDocument(); }); it('expands section that contains resume block', async () => { - const { courseBlocks } = await buildSimpleCourseBlocks(courseId, outlineTabData.title, { resumeBlock: true }); - const outlineTabDataResumeBlock = Factory.build('outlineTabData', { - courseId, + const { courseBlocks } = await buildSimpleCourseBlocks(courseId, 'Title', { resumeBlock: true }); + setTabData({ course_blocks: { blocks: courseBlocks.blocks }, }); - axiosMock.onGet(outlineUrl).reply(200, outlineTabDataResumeBlock); - await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); - - render(, { store }); + await fetchAndRender(); const expandedSectionNode = screen.getByRole('button', { name: /Title of Section/ }); expect(expandedSectionNode).toHaveAttribute('aria-expanded', 'true'); }); - it('handles expand/collapse all button click', () => { - render(, { store }); + it('handles expand/collapse all button click', async () => { + await fetchAndRender(); // Button renders as "Expand All" const expandButton = screen.getByRole('button', { name: 'Expand All' }); expect(expandButton).toBeInTheDocument(); @@ -96,43 +105,34 @@ describe('Outline Tab', () => { }); it('displays correct icon for complete assignment', async () => { - const { courseBlocks } = await buildSimpleCourseBlocks(courseId, outlineTabData.title, { complete: true }); - const outlineTabDataCompleteAssignment = Factory.build('outlineTabData', { - courseId, + const { courseBlocks } = await buildSimpleCourseBlocks(courseId, 'Title', { complete: true }); + setTabData({ course_blocks: { blocks: courseBlocks.blocks }, }); - axiosMock.onGet(outlineUrl).reply(200, outlineTabDataCompleteAssignment); - await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); - - render(, { store }); + await fetchAndRender(); expect(screen.getByTitle('Completed section')).toBeInTheDocument(); }); it('displays correct icon for incomplete assignment', async () => { - const { courseBlocks } = await buildSimpleCourseBlocks(courseId, outlineTabData.title, { complete: false }); - const outlineTabDataIncompleteAssignment = Factory.build('outlineTabData', { - courseId, + const { courseBlocks } = await buildSimpleCourseBlocks(courseId, 'Title', { complete: false }); + setTabData({ course_blocks: { blocks: courseBlocks.blocks }, }); - axiosMock.onGet(outlineUrl).reply(200, outlineTabDataIncompleteAssignment); - await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); - - render(, { store }); + await fetchAndRender(); expect(screen.getByTitle('Incomplete section')).toBeInTheDocument(); }); }); describe('Welcome Message', () => { - it('does not render show more/less button under 100 words', () => { - render(, { store }); + it('does not render show more/less button under 100 words', async () => { + await fetchAndRender(); expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument(); }); describe('over 100 words', () => { beforeEach(async () => { - const outlineTabDataLongMessage = Factory.build('outlineTabData', { - courseId, + setTabData({ welcome_message_html: '

' + 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.' + 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.' @@ -141,10 +141,7 @@ describe('Outline Tab', () => { + 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.' + '

', }); - axiosMock.onGet(outlineUrl).reply(200, outlineTabDataLongMessage); - await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); - - render(, { store }); + await fetchAndRender(); }); it('shortens message', async () => { @@ -172,14 +169,8 @@ describe('Outline Tab', () => { }); it('does not display if no update available', async () => { - const outlineTabDataSansUpdate = Factory.build('outlineTabData', { - courseId, - welcome_message_html: null, - }); - axiosMock.onGet(outlineUrl).reply(200, outlineTabDataSansUpdate); - await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); - - render(, { store }); + setTabData({ welcome_message_html: null }); + await fetchAndRender(); expect(screen.queryByTestId('alert-container-welcome')).not.toBeInTheDocument(); }); }); @@ -192,8 +183,8 @@ describe('Outline Tab', () => { ['unsure', 'Not sure yet'], ]; - it('does not render goal widgets if no goals available', () => { - render(, { store }); + it('does not render goal widgets if no goals available', async () => { + await fetchAndRender(); expect(screen.queryByTestId('course-goal-card')).not.toBeInTheDocument(); expect(screen.queryByLabelText('Goal')).not.toBeInTheDocument(); expect(screen.queryByTestId('edit-goal-selector')).not.toBeInTheDocument(); @@ -201,17 +192,13 @@ describe('Outline Tab', () => { describe('goal is not set', () => { beforeEach(async () => { - const outlineTabDataGoalNotSet = Factory.build('outlineTabData', { - courseId, + setTabData({ course_goals: { goal_options: goalOptions, selected_goal: null, }, }); - axiosMock.onGet(outlineUrl).reply(200, outlineTabDataGoalNotSet); - await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); - - render(, { store }); + await fetchAndRender(); }); it('renders goal card', () => { @@ -234,18 +221,13 @@ describe('Outline Tab', () => { describe('goal is set', () => { beforeEach(async () => { - const outlineTabDataGoalSet = Factory.build('outlineTabData', { - courseId, + setTabData({ course_goals: { goal_options: goalOptions, selected_goal: { text: 'Earn a certificate', key: 'certify' }, }, }); - - axiosMock.onGet(outlineUrl).reply(200, outlineTabDataGoalSet); - await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); - - render(, { store }); + await fetchAndRender(); }); it('renders edit goal selector', () => { @@ -279,67 +261,47 @@ describe('Outline Tab', () => { }); describe('Course Handouts', () => { - it('renders title when handouts are available', () => { - render(, { store }); + it('renders title when handouts are available', async () => { + await fetchAndRender(); expect(screen.queryByRole('heading', { name: 'Course Handouts' })).toBeInTheDocument(); }); it('does not display title if no handouts available', async () => { - const outlineTabDataSansHandout = Factory.build('outlineTabData', { - courseId, - handouts_html: null, - }); - axiosMock.onGet(outlineUrl).reply(200, outlineTabDataSansHandout); - await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); - - render(, { store }); + setTabData({ handouts_html: null }); + await fetchAndRender(); expect(screen.queryByRole('heading', { name: 'Course Handouts' })).not.toBeInTheDocument(); }); }); describe('Alert List', () => { describe('Enrollment Alert', () => { - let extraText; let alertMessage; let staffMessage; beforeEach(() => { - extraText = outlineTabData.enroll_alert.extra_text; + const extraText = defaultTabData.enroll_alert.extra_text; alertMessage = `You must be enrolled in the course to see course content. ${extraText}`; staffMessage = 'You are viewing this course as staff, and are not enrolled.'; }); it('does not display enrollment alert for enrolled user', async () => { - const courseHomeMetadataForEnrolledUser = Factory.build( - 'courseHomeMetadata', { course_id: courseId, is_enrolled: true }, - { courseTabs: courseMetadata.tabs }, - ); - axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadataForEnrolledUser); - await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); - - render(, { store }); - + setMetadata({ is_enrolled: true }); + await fetchAndRender(); expect(screen.queryByText(alertMessage)).not.toBeInTheDocument(); }); it('does not display enrollment button if enrollment is not available', async () => { - const outlineTabDataCannotEnroll = Factory.build('outlineTabData', { - courseId, + setTabData({ enroll_alert: { can_enroll: false, - extra_text: extraText, }, }); - axiosMock.onGet(outlineUrl).reply(200, outlineTabDataCannotEnroll); - await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); - - render(, { store }); - + await fetchAndRender(); expect(screen.queryByRole('button', { name: 'Enroll Now' })).not.toBeInTheDocument(); }); it('displays enrollment alert for unenrolled user', async () => { - render(, { store }); + await fetchAndRender(); const alert = await screen.findByText(alertMessage); expect(alert).toHaveAttribute('role', 'alert'); @@ -350,23 +312,8 @@ describe('Outline Tab', () => { }); it('displays different message for unenrolled staff user', async () => { - const courseHomeMetadataForUnenrolledStaff = Factory.build( - 'courseHomeMetadata', { course_id: courseId, is_staff: true }, - { courseTabs: courseMetadata.tabs }, - ); - axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadataForUnenrolledStaff); - // We need to remove offer_html and course_expired_html to limit the number of alerts we - // show, which makes this test easier to write. If there's only one, it's easy to query - // for below. - const outlineTabDataCannotEnroll = Factory.build('outlineTabData', { - courseId, - offer_html: null, - course_expired_html: null, - }); - axiosMock.onGet(outlineUrl).reply(200, outlineTabDataCannotEnroll); - await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); - - render(, { store }); + setMetadata({ is_staff: true }); + await fetchAndRender(); const alert = await screen.findByText(staffMessage); expect(alert).toHaveAttribute('role', 'alert'); @@ -376,15 +323,12 @@ describe('Outline Tab', () => { }); it('handles button click', async () => { - const enrollmentUrl = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`; - axiosMock.reset(); - axiosMock.onPost(enrollmentUrl).reply(200, { }); const { location } = window; delete window.location; window.location = { reload: jest.fn(), }; - render(, { store }); + await fetchAndRender(); const button = await screen.findByRole('button', { name: 'Enroll Now' }); fireEvent.click(button); @@ -398,19 +342,116 @@ describe('Outline Tab', () => { }); describe('Access Expiration Alert', () => { - // TODO: Test this alert. + // Appears if course_expired_html is provided + it('appears', async () => { + setTabData({ course_expired_html: '

Course Will Expire, Uh Oh

' }); + await fetchAndRender(); + await screen.findByText('Course Will Expire, Uh Oh'); + }); }); describe('Course Start Alert', () => { - // TODO: Test this alert. + // Only appears if enrolled and before start of course + it('appears several days out', async () => { + const startDate = new Date(); + startDate.setDate(startDate.getDate() + 100); + setMetadata({ is_enrolled: true }); + setTabData({}, { + dateBlocks: [ + { + date_type: 'course-start-date', + date: startDate.toISOString(), + title: 'Start', + }, + ], + }); + await fetchAndRender(); + const node = await screen.findByText('Course starts', { exact: false }); + expect(node.textContent).toMatch(/.* on .*/); // several days away uses "on" before date + }); + + it('appears today', async () => { + const startDate = new Date(); + startDate.setHours(startDate.getHours() + 1); + setMetadata({ is_enrolled: true }); + setTabData({}, { + dateBlocks: [ + { + date_type: 'course-start-date', + date: startDate.toISOString(), + title: 'Start', + }, + ], + }); + await fetchAndRender(); + const node = await screen.findByText('Course starts', { exact: false }); + expect(node.textContent).toMatch(/.* at .*/); // same day uses "at" before date + }); }); describe('Course End Alert', () => { - // TODO: Test this alert. + // Only appears if enrolled and within 14 days before the end of course + it('appears several days out', async () => { + const endDate = new Date(); + endDate.setDate(endDate.getDate() + 13); + setMetadata({ is_enrolled: true }); + setTabData({}, { + dateBlocks: [ + { + date_type: 'course-end-date', + date: endDate.toISOString(), + title: 'End', + }, + ], + }); + await fetchAndRender(); + const node = await screen.findByText('This course is ending', { exact: false }); + expect(node.textContent).toMatch(/.* on .*/); // several days away uses "on" before date + }); + + it('appears today', async () => { + const endDate = new Date(); + endDate.setHours(endDate.getHours() + 1); + setMetadata({ is_enrolled: true }); + setTabData({}, { + dateBlocks: [ + { + date_type: 'course-end-date', + date: endDate.toISOString(), + title: 'End', + }, + ], + }); + await fetchAndRender(); + const node = await screen.findByText('This course is ending', { exact: false }); + expect(node.textContent).toMatch(/.* at .*/); // same day uses "at" before date + }); }); describe('Certificate Available Alert', () => { - // TODO: Test this alert. + // Must satisfy two conditions for alert to appear: enrolled and between course end and cert availability + it('appears', async () => { + const now = new Date(); + const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1); + const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + setMetadata({ is_enrolled: true }); + setTabData({}, { + dateBlocks: [ + { + date_type: 'course-end-date', + date: yesterday.toISOString(), + title: 'End', + }, + { + date_type: 'certificate-available-date', + date: tomorrow.toISOString(), + title: 'Cert Available', + }, + ], + }); + await fetchAndRender(); + await screen.findByText('We are working on generating course certificates.'); + }); }); }); });