diff --git a/common/test/acceptance/pages/lms/course_about.py b/common/test/acceptance/pages/lms/course_about.py index 6290229a81..83ec4ffcd4 100644 --- a/common/test/acceptance/pages/lms/course_about.py +++ b/common/test/acceptance/pages/lms/course_about.py @@ -3,7 +3,7 @@ Course about page (with registration button) """ from .course_page import CoursePage -from .register import RegisterPage +from .login_and_register import RegisterPage class CourseAboutPage(CoursePage): diff --git a/common/test/acceptance/pages/lms/login_and_register.py b/common/test/acceptance/pages/lms/login_and_register.py new file mode 100644 index 0000000000..1de17aa61d --- /dev/null +++ b/common/test/acceptance/pages/lms/login_and_register.py @@ -0,0 +1,223 @@ +"""Login and Registration pages """ + +from urllib import urlencode +from bok_choy.page_object import PageObject, unguarded +from bok_choy.promise import Promise, EmptyPromise +from . import BASE_URL +from .dashboard import DashboardPage + + +class RegisterPage(PageObject): + """ + Registration page (create a new account) + """ + + def __init__(self, browser, course_id): + """ + Course ID is currently of the form "edx/999/2013_Spring" + but this format could change. + """ + super(RegisterPage, self).__init__(browser) + self._course_id = course_id + + @property + def url(self): + """ + URL for the registration page of a course. + """ + return "{base}/register?course_id={course_id}&enrollment_action={action}".format( + base=BASE_URL, + course_id=self._course_id, + action="enroll", + ) + + def is_browser_on_page(self): + return any([ + 'register' in title.lower() + for title in self.q(css='span.title-sub').text + ]) + + def provide_info(self, email, password, username, full_name): + """ + Fill in registration info. + `email`, `password`, `username`, and `full_name` are the user's credentials. + """ + self.q(css='input#email').fill(email) + self.q(css='input#password').fill(password) + self.q(css='input#username').fill(username) + self.q(css='input#name').fill(full_name) + self.q(css='input#tos-yes').first.click() + self.q(css='input#honorcode-yes').first.click() + self.q(css="#country option[value='US']").first.click() + + def submit(self): + """ + Submit registration info to create an account. + """ + self.q(css='button#submit').first.click() + + # The next page is the dashboard; make sure it loads + dashboard = DashboardPage(self.browser) + dashboard.wait_for_page() + return dashboard + + +class CombinedLoginAndRegisterPage(PageObject): + """Interact with combined login and registration page. + + This page is currently hidden behind the feature flag + `ENABLE_COMBINED_LOGIN_REGISTRATION`, which is enabled + in the bok choy settings. + + When enabled, the new page is available from either + `/account/login` or `/account/register`. + + Users can reach this page while attempting to enroll + in a course, in which case users will be auto-enrolled + when they successfully authenticate (unless the course + has been paywalled). + + """ + def __init__(self, browser, start_page="register", course_id=None): + """Initialize the page. + + Arguments: + browser (Browser): The browser instance. + + Keyword Args: + start_page (str): Whether to start on the login or register page. + course_id (unicode): If provided, load the page as if the user + is trying to enroll in a course. + + """ + super(CombinedLoginAndRegisterPage, self).__init__(browser) + self._course_id = course_id + + if start_page not in ["register", "login"]: + raise ValueError("Start page must be either 'register' or 'login'") + self._start_page = start_page + + @property + def url(self): + """Return the URL for the combined login/registration page. """ + url = "{base}/account/{login_or_register}".format( + base=BASE_URL, + login_or_register=self._start_page + ) + + # These are the parameters that would be included if the user + # were trying to enroll in a course. + if self._course_id is not None: + url += "?{params}".format( + params=urlencode({ + "course_id": self._course_id, + "enrollment_action": "enroll" + }) + ) + + return url + + def is_browser_on_page(self): + """Check whether the combined login/registration page has loaded. """ + return ( + self.q(css="#register-option").is_present() and + self.q(css="#login-option").is_present() and + self.current_form is not None + ) + + def toggle_form(self): + """Toggle between the login and registration forms. """ + old_form = self.current_form + + # Toggle the form + self.q(css=".form-toggle:not(:checked)").click() + + # Wait for the form to change before returning + EmptyPromise( + lambda: self.current_form != old_form, + "Finish toggling to the other form" + ).fulfill() + + def register(self, email="", password="", username="", full_name="", country="", terms_of_service=False): + """Fills in and submits the registration form. + + Requires that the "register" form is visible. + This does NOT wait for the next page to load, + so the caller should wait for the next page + (or errors if that's the expected behavior.) + + Keyword Arguments: + email (unicode): The user's email address. + password (unicode): The user's password. + username (unicode): The user's username. + full_name (unicode): The user's full name. + country (unicode): Two-character country code. + terms_of_service (boolean): If True, agree to the terms of service and honor code. + + """ + # Fill in the form + self.q(css="#register-email").fill(email) + self.q(css="#register-password").fill(password) + self.q(css="#register-username").fill(username) + self.q(css="#register-name").fill(full_name) + if country: + self.q(css="#register-country option[value='{country}']".format(country=country)).click() + if (terms_of_service): + self.q(css="#register-honor_code").click() + + # Submit it + self.q(css=".register-button").click() + + def login(self, email="", password="", remember_me=True): + """Fills in and submits the login form. + + Requires that the "login" form is visible. + This does NOT wait for the next page to load, + so the caller should wait for the next page + (or errors if that's the expected behavior). + + Keyword Arguments: + email (unicode): The user's email address. + password (unicode): The user's password. + remember_me (boolean): If True, check the "remember me" box. + + """ + # Fill in the form + self.q(css="#login-email").fill(email) + self.q(css="#login-password").fill(password) + if remember_me: + self.q(css="#login-remember").click() + + # Submit it + self.q(css=".login-button").click() + + @property + @unguarded + def current_form(self): + """Return the form that is currently visible to the user. + + Returns: + Either "register", "login", or "password-reset" if a valid + form is loaded. + + If we can't find any of these forms on the page, return None. + + """ + if self.q(css=".register-button").visible: + return "register" + elif self.q(css=".login-button").visible: + return "login" + elif self.q(css=".js-reset").visible: + return "password-reset" + + @property + def errors(self): + """Return a list of errors displayed to the user. """ + return self.q(css=".submission-error li").text + + def wait_for_errors(self): + """Wait for errors to be visible, then return them. """ + def _check_func(): + errors = self.errors + return (bool(errors), errors) + return Promise(_check_func, "Errors are visible").fulfill() diff --git a/common/test/acceptance/pages/lms/register.py b/common/test/acceptance/pages/lms/register.py deleted file mode 100644 index e2711c1cd1..0000000000 --- a/common/test/acceptance/pages/lms/register.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -Registration page (create a new account) -""" - -from bok_choy.page_object import PageObject -from . import BASE_URL -from .dashboard import DashboardPage - - -class RegisterPage(PageObject): - """ - Registration page (create a new account) - """ - - def __init__(self, browser, course_id): - """ - Course ID is currently of the form "edx/999/2013_Spring" - but this format could change. - """ - super(RegisterPage, self).__init__(browser) - self._course_id = course_id - - @property - def url(self): - """ - URL for the registration page of a course. - """ - return "{base}/register?course_id={course_id}&enrollment_action={action}".format( - base=BASE_URL, - course_id=self._course_id, - action="enroll", - ) - - def is_browser_on_page(self): - return any([ - 'register' in title.lower() - for title in self.q(css='span.title-sub').text - ]) - - def provide_info(self, email, password, username, full_name): - """ - Fill in registration info. - `email`, `password`, `username`, and `full_name` are the user's credentials. - """ - self.q(css='input#email').fill(email) - self.q(css='input#password').fill(password) - self.q(css='input#username').fill(username) - self.q(css='input#name').fill(full_name) - self.q(css='input#tos-yes').first.click() - self.q(css='input#honorcode-yes').first.click() - - def submit(self): - """ - Submit registration info to create an account. - """ - self.q(css='button#submit').first.click() - - # The next page is the dashboard; make sure it loads - dashboard = DashboardPage(self.browser) - dashboard.wait_for_page() - return dashboard diff --git a/common/test/acceptance/tests/lms/test_lms.py b/common/test/acceptance/tests/lms/test_lms.py index 54f9aae3b5..9aa35186ff 100644 --- a/common/test/acceptance/tests/lms/test_lms.py +++ b/common/test/acceptance/tests/lms/test_lms.py @@ -5,10 +5,12 @@ End-to-end tests for the LMS. from textwrap import dedent from unittest import skip +from nose.plugins.attrib import attr from bok_choy.web_app_test import WebAppTest from ..helpers import UniqueCourseTest, load_data_str from ...pages.lms.auto_auth import AutoAuthPage +from ...pages.common.logout import LogoutPage from ...pages.lms.find_courses import FindCoursesPage from ...pages.lms.course_about import CourseAboutPage from ...pages.lms.course_info import CourseInfoPage @@ -19,6 +21,7 @@ from ...pages.lms.dashboard import DashboardPage from ...pages.lms.problem import ProblemPage from ...pages.lms.video.video import VideoPage from ...pages.lms.courseware import CoursewarePage +from ...pages.lms.login_and_register import CombinedLoginAndRegisterPage from ...fixtures.course import CourseFixture, XBlockFixtureDesc, CourseUpdateDesc @@ -64,6 +67,138 @@ class RegistrationTest(UniqueCourseTest): self.assertIn(self.course_info['display_name'], course_names) +@attr('shard_1') +class LoginFromCombinedPageTest(UniqueCourseTest): + """Test that we can log in using the combined login/registration page. """ + + def setUp(self): + """Initialize the page objects and create a test course. """ + super(LoginFromCombinedPageTest, self).setUp() + self.login_page = CombinedLoginAndRegisterPage( + self.browser, + start_page="login", + course_id=self.course_id + ) + self.dashboard_page = DashboardPage(self.browser) + + # Create a course to enroll in + CourseFixture( + self.course_info['org'], self.course_info['number'], + self.course_info['run'], self.course_info['display_name'] + ).install() + + def test_login_success(self): + # Create a user account + email, password = self._create_unique_user() + + # Navigate to the login page and try to log in + self.login_page.visit().login(email=email, password=password) + + # Expect that we reach the dashboard and we're auto-enrolled in the course + course_names = self.dashboard_page.wait_for_page().available_courses + self.assertIn(self.course_info["display_name"], course_names) + + def test_login_failure(self): + # Navigate to the login page + self.login_page.visit() + + # User account does not exist + self.login_page.login(email="nobody@nowhere.com", password="password") + + # Verify that an error is displayed + self.assertIn("Email or password is incorrect.", self.login_page.wait_for_errors()) + + def test_toggle_to_register_form(self): + self.login_page.visit().toggle_form() + self.assertEqual(self.login_page.current_form, "register") + + def _create_unique_user(self): + username = "test_{uuid}".format(uuid=self.unique_id[0:6]) + email = "{user}@example.com".format(user=username) + password = "password" + + # Create the user (automatically logs us in) + AutoAuthPage( + self.browser, + username=username, + email=email, + password=password + ).visit() + + # Log out + LogoutPage(self.browser).visit() + + return (email, password) + + +@attr('shard_1') +class RegisterFromCombinedPageTest(UniqueCourseTest): + """Test that we can register a new user from the combined login/registration page. """ + + def setUp(self): + """Initialize the page objects and create a test course. """ + super(RegisterFromCombinedPageTest, self).setUp() + self.register_page = CombinedLoginAndRegisterPage( + self.browser, + start_page="register", + course_id=self.course_id + ) + self.dashboard_page = DashboardPage(self.browser) + + # Create a course to enroll in + CourseFixture( + self.course_info['org'], self.course_info['number'], + self.course_info['run'], self.course_info['display_name'] + ).install() + + def test_register_success(self): + # Navigate to the registration page + self.register_page.visit() + + # Fill in the form and submit it + username = "test_{uuid}".format(uuid=self.unique_id[0:6]) + email = "{user}@example.com".format(user=username) + self.register_page.register( + email=email, + password="password", + username=username, + full_name="Test User", + country="US", + terms_of_service=True + ) + + # Expect that we reach the dashboard and we're auto-enrolled in the course + course_names = self.dashboard_page.wait_for_page().available_courses + self.assertIn(self.course_info["display_name"], course_names) + + def test_register_failure(self): + # Navigate to the registration page + self.register_page.visit() + + # Enter a blank for the username field, which is required + # Don't agree to the terms of service / honor code. + # Don't specify a country code, which is required. + username = "test_{uuid}".format(uuid=self.unique_id[0:6]) + email = "{user}@example.com".format(user=username) + self.register_page.register( + email=email, + password="password", + username="", + full_name="Test User", + terms_of_service=False + ) + + # Verify that the expected errors are displayed. + errors = self.register_page.wait_for_errors() + self.assertIn(u'The Username field cannot be empty.', errors) + self.assertIn(u'You must agree to the edX Terms of Service and Honor Code.', errors) + self.assertIn(u'The Country field cannot be empty.', errors) + + def test_toggle_to_login_form(self): + self.register_page.visit().toggle_form() + self.assertEqual(self.register_page.current_form, "login") + + class LanguageTest(WebAppTest): """ Tests that the change language functionality on the dashboard works diff --git a/lms/envs/bok_choy.env.json b/lms/envs/bok_choy.env.json index fe5d509386..41455f9280 100644 --- a/lms/envs/bok_choy.env.json +++ b/lms/envs/bok_choy.env.json @@ -69,6 +69,7 @@ "ENABLE_INSTRUCTOR_ANALYTICS": true, "ENABLE_S3_GRADE_DOWNLOADS": true, "ENABLE_THIRD_PARTY_AUTH": true, + "ENABLE_COMBINED_LOGIN_REGISTRATION": true, "PREVIEW_LMS_BASE": "localhost:8003", "SUBDOMAIN_BRANDING": false, "SUBDOMAIN_COURSE_LISTINGS": false @@ -82,6 +83,17 @@ "MEDIA_URL": "", "MKTG_URL_LINK_MAP": {}, "PLATFORM_NAME": "edX", + "REGISTRATION_EXTRA_FIELDS": { + "level_of_education": "optional", + "gender": "optional", + "year_of_birth": "optional", + "mailing_address": "optional", + "goals": "optional", + "honor_code": "required", + "terms_of_service": "hidden", + "city": "hidden", + "country": "required" + }, "SEGMENT_IO_LMS": true, "SERVER_EMAIL": "devops@example.com", "SESSION_COOKIE_DOMAIN": null,