diff --git a/AUTHORS b/AUTHORS index 8d9ab513c6..65e311622f 100644 --- a/AUTHORS +++ b/AUTHORS @@ -76,7 +76,7 @@ Jonah Stanley Slater Victoroff Peter Fogg Bethany LaPenta -Renzo Lucioni +Renzo Lucioni Felix Sun Adam Palay Ian Hoover diff --git a/common/djangoapps/course_modes/tests/test_views.py b/common/djangoapps/course_modes/tests/test_views.py index 11a1cd3540..32fbe8c939 100644 --- a/common/djangoapps/course_modes/tests/test_views.py +++ b/common/djangoapps/course_modes/tests/test_views.py @@ -1,6 +1,7 @@ import unittest import decimal import ddt +from mock import patch from django.conf import settings from django.test.utils import override_settings from django.core.urlresolvers import reverse @@ -9,10 +10,12 @@ from xmodule.modulestore.tests.django_utils import ( ModuleStoreTestCase, mixed_store_config ) +from util.testing import UrlResetMixin from xmodule.modulestore.tests.factories import CourseFactory from course_modes.tests.factories import CourseModeFactory from student.tests.factories import CourseEnrollmentFactory, UserFactory from student.models import CourseEnrollment +from course_modes.models import CourseMode, Mode # Since we don't need any XML course fixtures, use a modulestore configuration @@ -23,10 +26,10 @@ MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, incl @ddt.ddt @override_settings(MODULESTORE=MODULESTORE_CONFIG) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class CourseModeViewTest(ModuleStoreTestCase): - +class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase): + @patch.dict(settings.FEATURES, {'MODE_CREATION_FOR_TESTING': True}) def setUp(self): - super(CourseModeViewTest, self).setUp() + super(CourseModeViewTest, self).setUp('course_modes.urls') self.course = CourseFactory.create() self.user = UserFactory.create(username="Bob", email="bob@example.com", password="edx") self.client.login(username=self.user.username, password="edx") @@ -235,3 +238,65 @@ class CourseModeViewTest(ModuleStoreTestCase): response = self.client.post(choose_track_url, self.POST_PARAMS_FOR_COURSE_MODE['unsupported']) self.assertEqual(400, response.status_code) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_default_mode_creation(self): + # Hit the mode creation endpoint with no querystring params, to create an honor mode + url = reverse('create_mode', args=[unicode(self.course.id)]) + response = self.client.get(url) + + self.assertEquals(response.status_code, 200) + + expected_mode = [Mode(u'honor', u'Honor Code Certificate', 0, '', 'usd', None, None)] + course_mode = CourseMode.modes_for_course(self.course.id) + + self.assertEquals(course_mode, expected_mode) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + @ddt.data( + (u'verified', u'Verified Certificate', 10, '10,20,30', 'usd'), + (u'professional', u'Professional Education', 100, '100,200', 'usd'), + ) + @ddt.unpack + def test_verified_mode_creation(self, mode_slug, mode_display_name, min_price, suggested_prices, currency): + parameters = {} + parameters['mode_slug'] = mode_slug + parameters['mode_display_name'] = mode_display_name + parameters['min_price'] = min_price + parameters['suggested_prices'] = suggested_prices + parameters['currency'] = currency + + url = reverse('create_mode', args=[unicode(self.course.id)]) + response = self.client.get(url, parameters) + + self.assertEquals(response.status_code, 200) + + expected_mode = [Mode(mode_slug, mode_display_name, min_price, suggested_prices, currency, None, None)] + course_mode = CourseMode.modes_for_course(self.course.id) + + self.assertEquals(course_mode, expected_mode) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_multiple_mode_creation(self): + # Create an honor mode + base_url = reverse('create_mode', args=[unicode(self.course.id)]) + self.client.get(base_url) + + # Excluding the currency parameter implicitly tests the mode creation endpoint's ability to + # use default values when parameters are partially missing. + parameters = {} + parameters['mode_slug'] = u'verified' + parameters['mode_display_name'] = u'Verified Certificate' + parameters['min_price'] = 10 + parameters['suggested_prices'] = '10,20' + + # Create a verified mode + url = reverse('create_mode', args=[unicode(self.course.id)]) + response = self.client.get(url, parameters) + + honor_mode = Mode(u'honor', u'Honor Code Certificate', 0, '', 'usd', None, None) + verified_mode = Mode(u'verified', u'Verified Certificate', 10, '10,20', 'usd', None, None) + expected_modes = [honor_mode, verified_mode] + course_modes = CourseMode.modes_for_course(self.course.id) + + self.assertEquals(course_modes, expected_modes) diff --git a/common/djangoapps/course_modes/urls.py b/common/djangoapps/course_modes/urls.py index 6e4b24ac17..bb7d9317e4 100644 --- a/common/djangoapps/course_modes/urls.py +++ b/common/djangoapps/course_modes/urls.py @@ -8,5 +8,11 @@ from course_modes import views urlpatterns = patterns( '', # pylint seems to dislike as_view() calls because it's a `classonlymethod` instead of `classmethod`, so we disable the warning - url(r'^choose/{}/$'.format(settings.COURSE_ID_PATTERN), views.ChooseModeView.as_view(), name="course_modes_choose"), # pylint: disable=no-value-for-parameter + url(r'^choose/{}/$'.format(settings.COURSE_ID_PATTERN), views.ChooseModeView.as_view(), name='course_modes_choose'), # pylint: disable=no-value-for-parameter ) + +# Enable verified mode creation +if settings.FEATURES.get('MODE_CREATION_FOR_TESTING'): + urlpatterns += ( + url(r'^create_mode/{}/$'.format(settings.COURSE_ID_PATTERN), 'course_modes.views.create_mode', name='create_mode'), + ) diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index 15f3027a27..36a84cf661 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -5,7 +5,7 @@ Views for the course_mode module import decimal from django.core.urlresolvers import reverse from django.conf import settings -from django.http import HttpResponseBadRequest +from django.http import HttpResponse, HttpResponseBadRequest from django.shortcuts import redirect from django.views.generic.base import View from django.utils.translation import ugettext as _ @@ -226,3 +226,48 @@ class ChooseModeView(View): return 'honor' else: return None + + +def create_mode(request, course_id): + """Add a mode to the course corresponding to the given course ID. + + Only available when settings.FEATURES['MODE_CREATION_FOR_TESTING'] is True. + + Attempts to use the following querystring parameters from the request: + `mode_slug` (str): The mode to add, either 'honor', 'verified', or 'professional' + `mode_display_name` (str): Describes the new course mode + `min_price` (int): The minimum price a user must pay to enroll in the new course mode + `suggested_prices` (str): Comma-separated prices to suggest to the user. + `currency` (str): The currency in which to list prices. + + By default, this endpoint will create an 'honor' mode for the given course with display name + 'Honor Code', a minimum price of 0, no suggested prices, and using USD as the currency. + + Args: + request (`Request`): The Django Request object. + course_id (unicode): The slash-separated course key. + + Returns: + Response + """ + PARAMETERS = { + 'mode_slug': u'honor', + 'mode_display_name': u'Honor Code Certificate', + 'min_price': 0, + 'suggested_prices': u'', + 'currency': u'usd', + } + + # Try pulling querystring parameters out of the request + for parameter, default in PARAMETERS.iteritems(): + PARAMETERS[parameter] = request.GET.get(parameter, default) + + # Attempt to create the new mode for the given course + course_key = CourseKey.from_string(course_id) + CourseMode.objects.get_or_create(course_id=course_key, **PARAMETERS) + + # Return a success message and a 200 response + return HttpResponse("Mode '{mode_slug}' created for course with ID '{course_id}'.".format( + mode_slug=PARAMETERS['mode_slug'], + course_id=course_id + )) diff --git a/common/test/acceptance/pages/lms/create_mode.py b/common/test/acceptance/pages/lms/create_mode.py new file mode 100644 index 0000000000..2dbcb77367 --- /dev/null +++ b/common/test/acceptance/pages/lms/create_mode.py @@ -0,0 +1,71 @@ +"""Mode creation page (used to add modes to courses during testing).""" + +import re +import urllib + +from bok_choy.page_object import PageObject +from . import BASE_URL + + +class ModeCreationPage(PageObject): + """The mode creation page. + + When allowed by the Django settings file, visiting this page allows modes to be + created for an existing course. + """ + + def __init__(self, browser, course_id, mode_slug=None, mode_display_name=None, min_price=None, suggested_prices=None, currency=None): + """The mode creation page is an endpoint for HTTP GET requests. + + By default, it will create an 'honor' mode for the given course with display name + 'Honor Code', a minimum price of 0, no suggested prices, and using USD as the currency. + + Arguments: + browser (Browser): The browser instance. + course_id (unicode): The ID of the course for which modes are to be created. + + Keyword Arguments: + mode_slug (str): The mode to add, either 'honor', 'verified', or 'professional' + mode_display_name (str): Describes the new course mode + min_price (int): The minimum price a user must pay to enroll in the new course mode + suggested_prices (str): Comma-separated prices to suggest to the user. + currency (str): The currency in which to list prices. + """ + super(ModeCreationPage, self).__init__(browser) + + self.course_id = course_id + self._parameters = {} + + if mode_slug is not None: + self._parameters['mode_slug'] = mode_slug + + if mode_display_name is not None: + self._parameters['mode_display_name'] = mode_display_name + + if min_price is not None: + self._parameters['min_price'] = min_price + + if suggested_prices is not None: + self._parameters['suggested_prices'] = suggested_prices + + if currency is not None: + self._parameters['currency'] = currency + + @property + def url(self): + """Construct the mode creation URL.""" + url = '{base}/course_modes/create_mode/{course_id}'.format( + base=BASE_URL, + course_id=self.course_id + ) + + query_string = urllib.urlencode(self._parameters) + if query_string: + url += '?' + query_string + + return url + + def is_browser_on_page(self): + message = self.q(css='BODY').text[0] + match = re.search(r'Mode ([^$]+) created for course with ID ([^$]+).$', message) + return True if match else False diff --git a/common/test/acceptance/pages/lms/dashboard.py b/common/test/acceptance/pages/lms/dashboard.py index dd7706adae..bb01c1e97a 100644 --- a/common/test/acceptance/pages/lms/dashboard.py +++ b/common/test/acceptance/pages/lms/dashboard.py @@ -13,8 +13,32 @@ class DashboardPage(PageObject): Student dashboard, where the student can view courses she/he has registered for. """ + def __init__(self, browser, separate_verified=False): + """Initialize the page. - url = BASE_URL + "/dashboard" + Arguments: + browser (Browser): The browser instance. + + Keyword Arguments: + separate_verified (Boolean): Whether to use the split payment and + verification flow. + """ + super(DashboardPage, self).__init__(browser) + + if separate_verified: + self._querystring = "?separate-verified=1" + else: + self._querystring = "?disable-separate-verified=1" + + @property + def url(self): + """Return the URL corresponding to the dashboard.""" + url = "{base}/dashboard{querystring}".format( + base=BASE_URL, + querystring=self._querystring + ) + + return url def is_browser_on_page(self): return self.q(css='section.my-courses').present @@ -44,6 +68,60 @@ class DashboardPage(PageObject): return self.q(css='section.info > hgroup > h3 > a').map(_get_course_name).results + def get_enrollment_mode(self, course_name): + """Get the enrollment mode for a given course on the dashboard. + + Arguments: + course_name (str): The name of the course whose mode should be retrieved. + + Returns: + String, indicating the enrollment mode for the course corresponding to + the provided course name. + + Raises: + Exception, if no course with the provided name is found on the dashboard. + """ + # Filter elements by course name, only returning the relevant course item + course_listing = self.q(css=".course").filter(lambda el: course_name in el.text).results + + if course_listing: + # There should only be one course listing for the provided course name. + # Since 'ENABLE_VERIFIED_CERTIFICATES' is true in the Bok Choy settings, we + # can expect two classes to be present on
elements, one being 'course' + # and the other being the enrollment mode. + enrollment_mode = course_listing[0].get_attribute('class').split('course ')[1] + else: + raise Exception("No course named {} was found on the dashboard".format(course_name)) + + return enrollment_mode + + def upgrade_enrollment(self, course_name, upgrade_page): + """Interact with the upgrade button for the course with the provided name. + + Arguments: + course_name (str): The name of the course whose mode should be checked. + upgrade_page (PageObject): The page to wait on after clicking the upgrade button. Importing + the definition of PaymentAndVerificationFlow results in a circular dependency. + + Raises: + Exception, if no enrollment corresponding to the provided course name appears + on the dashboard. + """ + # Filter elements by course name, only returning the relevant course item + course_listing = self.q(css=".course").filter(lambda el: course_name in el.text).results + + if course_listing: + # There should only be one course listing corresponding to the provided course name. + el = course_listing[0] + + # Expand the upsell copy and click the upgrade button + el.find_element_by_css_selector('.message-upsell').click() + el.find_element_by_css_selector('#upgrade-to-verified').click() + + upgrade_page.wait_for_page() + else: + raise Exception("No enrollment for {} is visible on the dashboard.".format(course_name)) + def view_course(self, course_id): """ Go to the course with `course_id` (e.g. edx/Open_DemoX/edx_demo_course) diff --git a/common/test/acceptance/pages/lms/pay_and_verify.py b/common/test/acceptance/pages/lms/pay_and_verify.py new file mode 100644 index 0000000000..c29d24a7ba --- /dev/null +++ b/common/test/acceptance/pages/lms/pay_and_verify.py @@ -0,0 +1,168 @@ +"""Payment and verification pages""" + +import re +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 PaymentAndVerificationFlow(PageObject): + """Interact with the split payment and verification flow. + + These pages are currently hidden behind the feature flag + `SEPARATE_VERIFICATION_FROM_PAYMENT`, which is enabled in + the Bok Choy settings. + + When enabled, the flow can be accessed at the following URLs: + `/verify_student/start-flow/{course}/` + `/verify_student/upgrade/{course}/` + `/verify_student/verify-now/{course}/` + `/verify_student/verify-later/{course}/` + `/verify_student/payment-confirmation/{course}/` + + Users can reach the flow when attempting to enroll in a course's verified + mode, either directly from the track selection page, or by upgrading from + the honor mode. Users can also reach the flow when attempting to complete + a deferred verification, or when attempting to view a receipt corresponding + to an earlier payment. + """ + def __init__(self, browser, course_id, entry_point='start-flow'): + """Initialize the page. + + Arguments: + browser (Browser): The browser instance. + course_id (unicode): The course in which the user is enrolling. + + Keyword Arguments: + entry_point (str): Where to begin the flow; must be one of 'start-flow', + 'upgrade', 'verify-now', verify-later', or 'payment-confirmation'. + + Raises: + ValueError + """ + super(PaymentAndVerificationFlow, self).__init__(browser) + self._course_id = course_id + + if entry_point not in ['start-flow', 'upgrade', 'verify-now', 'verify-later', 'payment-confirmation']: + raise ValueError( + "Entry point must be either 'start-flow', 'upgrade', 'verify-now', 'verify-later', or 'payment-confirmation'." + ) + self._entry_point = entry_point + + @property + def url(self): + """Return the URL corresponding to the initial position in the flow.""" + url = "{base}/verify_student/{entry_point}/{course}".format( + base=BASE_URL, + entry_point=self._entry_point, + course=self._course_id + ) + + return url + + def is_browser_on_page(self): + """Check if a step in the payment and verification flow has loaded.""" + return ( + self.q(css="div .make-payment-step").is_present() or + self.q(css="div .payment-confirmation-step").is_present() or + self.q(css="div .face-photo-step").is_present() or + self.q(css="div .id-photo-step").is_present() or + self.q(css="div .review-photos-step").is_present() or + self.q(css="div .enrollment-confirmation-step").is_present() + ) + + def indicate_contribution(self): + """Interact with the radio buttons appearing on the first page of the upgrade flow.""" + self.q(css=".contribution-option > input").first.click() + + def proceed_to_payment(self): + """Interact with the payment button.""" + self.q(css="#pay_button").click() + + FakePaymentPage(self.browser, self._course_id).wait_for_page() + + def immediate_verification(self): + """Interact with the immediate verification button.""" + self.q(css="#verify_now_button").click() + + PaymentAndVerificationFlow(self.browser, self._course_id, entry_point='verify-now').wait_for_page() + + def defer_verification(self): + """Interact with the link allowing the user to defer their verification.""" + self.q(css="#verify_later_button").click() + + DashboardPage(self.browser).wait_for_page() + + def webcam_capture(self): + """Interact with a webcam capture button.""" + self.q(css="#webcam_capture_button").click() + + def _check_func(): + next_step_button_classes = self.q(css="#next_step_button").attrs('class') + next_step_button_enabled = 'is-disabled' not in next_step_button_classes + return (next_step_button_enabled, next_step_button_classes) + + # Check that the #next_step_button is enabled before returning control to the caller + Promise(_check_func, "The 'Next Step' button is enabled.").fulfill() + + def next_verification_step(self, next_page_object): + """Interact with the 'Next' step button found in the verification flow.""" + self.q(css="#next_step_button").click() + + next_page_object.wait_for_page() + + def go_to_dashboard(self): + """Interact with the link to the dashboard appearing on the enrollment confirmation page.""" + if self.q(css="div .enrollment-confirmation-step").is_present(): + self.q(css=".action-primary").click() + else: + raise Exception("The dashboard can only be accessed from the enrollment confirmation.") + + DashboardPage(self.browser, separate_verified=True).wait_for_page() + + +class FakePaymentPage(PageObject): + """Interact with the fake payment endpoint. + + This page is hidden behind the feature flag `ENABLE_PAYMENT_FAKE`, + which is enabled in the Bok Choy env settings. + + Configuring this payment endpoint also requires configuring the Bok Choy + auth settings with the following: + + "CC_PROCESSOR_NAME": "CyberSource2", + "CC_PROCESSOR": { + "CyberSource2": { + "SECRET_KEY": , + "ACCESS_KEY": , + "PROFILE_ID": "edx", + "PURCHASE_ENDPOINT": "/shoppingcart/payment_fake" + } + } + """ + def __init__(self, browser, course_id): + """Initialize the page. + + Arguments: + browser (Browser): The browser instance. + course_id (unicode): The course in which the user is enrolling. + """ + super(FakePaymentPage, self).__init__(browser) + self._course_id = course_id + + url = BASE_URL + "/shoppingcart/payment_fake/" + + def is_browser_on_page(self): + """Check if a step in the payment and verification flow has loaded.""" + message = self.q(css='BODY').text[0] + match = re.search('Payment page', message) + return True if match else False + + def submit_payment(self): + """Interact with the payment submission button.""" + self.q(css="input[value='Submit']").click() + + return PaymentAndVerificationFlow(self.browser, self._course_id, entry_point='payment-confirmation').wait_for_page() diff --git a/common/test/acceptance/pages/lms/track_selection.py b/common/test/acceptance/pages/lms/track_selection.py new file mode 100644 index 0000000000..8bd77a9e2b --- /dev/null +++ b/common/test/acceptance/pages/lms/track_selection.py @@ -0,0 +1,72 @@ +"""Track selection page""" + +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 +from .pay_and_verify import PaymentAndVerificationFlow + + +class TrackSelectionPage(PageObject): + """Interact with the track selection page. + + This page can be accessed at `/course_modes/choose/{course_id}/`. + """ + def __init__(self, browser, course_id, separate_verified=False): + """Initialize the page. + + Arguments: + browser (Browser): The browser instance. + course_id (unicode): The course in which the user is enrolling. + + Keyword Arguments: + separate_verified (Boolean): Whether to use the split payment and + verification flow when enrolling as verified. + """ + super(TrackSelectionPage, self).__init__(browser) + self._course_id = course_id + self._separate_verified = separate_verified + + if self._separate_verified: + self._querystring = "?separate-verified=1" + else: + self._querystring = "?disable-separate-verified=1" + + @property + def url(self): + """Return the URL corresponding to the track selection page.""" + url = "{base}/course_modes/choose/{course_id}{querystring}".format( + base=BASE_URL, + course_id=self._course_id, + querystring=self._querystring + ) + + return url + + def is_browser_on_page(self): + """Check if the track selection page has loaded.""" + return self.q(css=".wrapper-register-choose").is_present() + + def enroll(self, mode="honor"): + """Interact with one of the enrollment buttons on the page. + + Keyword Arguments: + mode (str): Can be "honor" or "verified" + + Raises: + ValueError + """ + if mode == "honor": + self.q(css="input[name='honor_mode']").click() + + return DashboardPage(self.browser, separate_verified=self._separate_verified).wait_for_page() + elif mode == "verified": + # Check the first contribution option, then click the enroll button + self.q(css=".contribution-option > input").first.click() + self.q(css="input[name='verified_mode']").click() + + return PaymentAndVerificationFlow(self.browser, self._course_id).wait_for_page() + else: + raise ValueError("Mode must be either 'honor' or 'verified'.") diff --git a/common/test/acceptance/tests/lms/test_lms.py b/common/test/acceptance/tests/lms/test_lms.py index 307d973d64..7b2d806fd3 100644 --- a/common/test/acceptance/tests/lms/test_lms.py +++ b/common/test/acceptance/tests/lms/test_lms.py @@ -16,6 +16,7 @@ from ..helpers import ( select_option_by_value, ) from ...pages.lms.auto_auth import AutoAuthPage +from ...pages.lms.create_mode import ModeCreationPage from ...pages.common.logout import LogoutPage from ...pages.lms.find_courses import FindCoursesPage from ...pages.lms.course_about import CourseAboutPage @@ -28,6 +29,8 @@ 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 ...pages.lms.track_selection import TrackSelectionPage +from ...pages.lms.pay_and_verify import PaymentAndVerificationFlow, FakePaymentPage from ...pages.studio.settings import SettingsPage from ...fixtures.course import CourseFixture, XBlockFixtureDesc, CourseUpdateDesc @@ -237,6 +240,128 @@ class RegisterFromCombinedPageTest(UniqueCourseTest): self.assertEqual(self.register_page.current_form, "login") +@attr('shard_1') +class PayAndVerifyTest(UniqueCourseTest): + """Test that we can proceed through the payment and verification flow.""" + def setUp(self): + """Initialize the test. + + Create the necessary page objects, create a test course and configure its modes, + create a user and log them in. + """ + super(PayAndVerifyTest, self).setUp() + self.track_selection_page = TrackSelectionPage(self.browser, self.course_id, separate_verified=True) + self.payment_and_verification_flow = PaymentAndVerificationFlow(self.browser, self.course_id) + self.immediate_verification_page = PaymentAndVerificationFlow(self.browser, self.course_id, entry_point='verify-now') + self.upgrade_page = PaymentAndVerificationFlow(self.browser, self.course_id, entry_point='upgrade') + self.fake_payment_page = FakePaymentPage(self.browser, self.course_id) + self.dashboard_page = DashboardPage(self.browser, separate_verified=True) + + # Create a course + CourseFixture( + self.course_info['org'], + self.course_info['number'], + self.course_info['run'], + self.course_info['display_name'] + ).install() + + # Add an honor mode to the course + ModeCreationPage(self.browser, self.course_id).visit() + + # Add a verified mode to the course + ModeCreationPage(self.browser, self.course_id, mode_slug=u'verified', mode_display_name=u'Verified Certificate', min_price=10, suggested_prices='10,20').visit() + + def test_immediate_verification_enrollment(self): + # Create a user and log them in + AutoAuthPage(self.browser).visit() + + # Navigate to the track selection page with the appropriate GET parameter in the URL + self.track_selection_page.visit() + + # Enter the payment and verification flow by choosing to enroll as verified + self.track_selection_page.enroll('verified') + + # Proceed to the fake payment page + self.payment_and_verification_flow.proceed_to_payment() + + # Submit payment + self.fake_payment_page.submit_payment() + + # Proceed to verification + self.payment_and_verification_flow.immediate_verification() + + # Take face photo and proceed to the ID photo step + self.payment_and_verification_flow.webcam_capture() + self.payment_and_verification_flow.next_verification_step(self.immediate_verification_page) + + # Take ID photo and proceed to the review photos step + self.payment_and_verification_flow.webcam_capture() + self.payment_and_verification_flow.next_verification_step(self.immediate_verification_page) + + # Submit photos and proceed to the enrollment confirmation step + self.payment_and_verification_flow.next_verification_step(self.immediate_verification_page) + + # Navigate to the dashboard with the appropriate GET parameter in the URL + self.dashboard_page.visit() + + # Expect that we're enrolled as verified in the course + enrollment_mode = self.dashboard_page.get_enrollment_mode(self.course_info["display_name"]) + self.assertEqual(enrollment_mode, 'verified') + + def test_deferred_verification_enrollment(self): + # Create a user and log them in + AutoAuthPage(self.browser).visit() + + # Navigate to the track selection page with the appropriate GET parameter in the URL + self.track_selection_page.visit() + + # Enter the payment and verification flow by choosing to enroll as verified + self.track_selection_page.enroll('verified') + + # Proceed to the fake payment page + self.payment_and_verification_flow.proceed_to_payment() + + # Submit payment + self.fake_payment_page.submit_payment() + + # Navigate to the dashboard with the appropriate GET parameter in the URL + self.dashboard_page.visit() + + # Expect that we're enrolled as verified in the course + enrollment_mode = self.dashboard_page.get_enrollment_mode(self.course_info["display_name"]) + self.assertEqual(enrollment_mode, 'verified') + + def test_enrollment_upgrade(self): + # Create a user, log them in, and enroll them in the honor mode + AutoAuthPage(self.browser, course_id=self.course_id).visit() + + # Navigate to the dashboard with the appropriate GET parameter in the URL + self.dashboard_page.visit() + + # Expect that we're enrolled as honor in the course + enrollment_mode = self.dashboard_page.get_enrollment_mode(self.course_info["display_name"]) + self.assertEqual(enrollment_mode, 'honor') + + # Click the upsell button on the dashboard + self.dashboard_page.upgrade_enrollment(self.course_info["display_name"], self.upgrade_page) + + # Select the first contribution option appearing on the page + self.upgrade_page.indicate_contribution() + + # Proceed to the fake payment page + self.upgrade_page.proceed_to_payment() + + # Submit payment + self.fake_payment_page.submit_payment() + + # Navigate to the dashboard with the appropriate GET parameter in the URL + self.dashboard_page.visit() + + # Expect that we're enrolled as verified in the course + enrollment_mode = self.dashboard_page.get_enrollment_mode(self.course_info["display_name"]) + self.assertEqual(enrollment_mode, 'verified') + + class LanguageTest(WebAppTest): """ Tests that the change language functionality on the dashboard works diff --git a/lms/envs/bok_choy.auth.json b/lms/envs/bok_choy.auth.json index 8504e8c276..4820812192 100644 --- a/lms/envs/bok_choy.auth.json +++ b/lms/envs/bok_choy.auth.json @@ -2,6 +2,15 @@ "ANALYTICS_API_KEY": "", "AWS_ACCESS_KEY_ID": "", "AWS_SECRET_ACCESS_KEY": "", + "CC_PROCESSOR_NAME": "CyberSource2", + "CC_PROCESSOR": { + "CyberSource2": { + "SECRET_KEY": "abcd123", + "ACCESS_KEY": "abcd123", + "PROFILE_ID": "edx", + "PURCHASE_ENDPOINT": "/shoppingcart/payment_fake" + } + }, "CELERY_BROKER_PASSWORD": "celery", "CELERY_BROKER_USER": "celery", "CONTENTSTORE": { diff --git a/lms/envs/bok_choy.env.json b/lms/envs/bok_choy.env.json index 9337bf278d..d6e5c693e0 100644 --- a/lms/envs/bok_choy.env.json +++ b/lms/envs/bok_choy.env.json @@ -65,15 +65,21 @@ "FEATURES": { "AUTH_USE_OPENID_PROVIDER": true, "CERTIFICATES_ENABLED": true, + "MULTIPLE_ENROLLMENT_ROLES": true, + "ENABLE_PAYMENT_FAKE": true, + "ENABLE_VERIFIED_CERTIFICATES": true, "ENABLE_DISCUSSION_SERVICE": true, "ENABLE_INSTRUCTOR_ANALYTICS": true, "ENABLE_S3_GRADE_DOWNLOADS": true, "ENABLE_THIRD_PARTY_AUTH": true, "ENABLE_COMBINED_LOGIN_REGISTRATION": true, + "SEPARATE_VERIFICATION_FROM_PAYMENT": true, "PREVIEW_LMS_BASE": "localhost:8003", "SUBDOMAIN_BRANDING": false, "SUBDOMAIN_COURSE_LISTINGS": false, - "ALLOW_AUTOMATED_SIGNUPS": true + "ALLOW_AUTOMATED_SIGNUPS": true, + "MODE_CREATION_FOR_TESTING": true, + "AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING": true }, "FEEDBACK_SUBMISSION_EMAIL": "", "GITHUB_REPO_ROOT": "** OVERRIDDEN **", diff --git a/lms/envs/common.py b/lms/envs/common.py index 9b884f1bdf..21de7853c0 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -312,7 +312,7 @@ FEATURES = { # Show the mobile app links in the footer 'ENABLE_FOOTER_MOBILE_APP_LINKS': False, - # let students save and manage their annotations + # Let students save and manage their annotations 'ENABLE_EDXNOTES': False, # Milestones application flag @@ -320,6 +320,9 @@ FEATURES = { # Prerequisite courses feature flag 'ENABLE_PREREQUISITE_COURSES': False, + + # For easily adding modes to courses during acceptance testing + 'MODE_CREATION_FOR_TESTING': False, } # Ignore static asset files on import which match this pattern diff --git a/lms/static/js/verify_student/pay_and_verify.js b/lms/static/js/verify_student/pay_and_verify.js index 803596fa11..d7c2eb943c 100644 --- a/lms/static/js/verify_student/pay_and_verify.js +++ b/lms/static/js/verify_student/pay_and_verify.js @@ -70,6 +70,12 @@ var edx = edx || {}; platformName: el.data('platform-name'), requirements: el.data('requirements') }, + 'face-photo-step': { + platformName: el.data('platform-name') + }, + 'id-photo-step': { + platformName: el.data('platform-name') + }, 'review-photos-step': { fullName: el.data('full-name'), platformName: el.data('platform-name') @@ -79,12 +85,6 @@ var edx = edx || {}; courseStartDate: el.data('course-start-date'), coursewareUrl: el.data('courseware-url'), platformName: el.data('platform-name') - }, - 'face-photo-step': { - platformName: el.data('platform-name') - }, - 'id-photo-step': { - platformName: el.data('platform-name') } } }).render(); diff --git a/lms/static/js/verify_student/views/webcam_photo_view.js b/lms/static/js/verify_student/views/webcam_photo_view.js index 34cd46958d..1a760c9015 100644 --- a/lms/static/js/verify_student/views/webcam_photo_view.js +++ b/lms/static/js/verify_student/views/webcam_photo_view.js @@ -25,7 +25,14 @@ // Start the capture this.getUserMediaFunc()( - { video: true }, + { + video: true, + + // Specify the `fake` constraint if we detect we are running in a test + // environment. In Chrome, this will do nothing, but in Firefox, it will + // instruct the browser to use a fake video device. + fake: window.location.hostname === 'localhost' + }, _.bind( this.getUserMediaCallback, this ), _.bind( this.handleVideoFailure, this ) ); diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 7212facd43..89700110ef 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -25,7 +25,7 @@ git+https://github.com/mitocw/django-cas.git@60a5b8e5a62e63e0d5d224a87f0b489201a -e git+https://github.com/edx/codejail.git@2b095e820ff752a108653bb39d518b122f7154db#egg=codejail -e git+https://github.com/edx/js-test-tool.git@v0.1.6#egg=js_test_tool -e git+https://github.com/edx/event-tracking.git@0.1.0#egg=event-tracking --e git+https://github.com/edx/bok-choy.git@4a259e3548a19e41cc39433caf68ea58d10a27ba#egg=bok_choy +-e git+https://github.com/edx/bok-choy.git@9fd05383dc434e0275c9874fd50ad14e519448bc#egg=bok_choy -e git+https://github.com/edx-solutions/django-splash.git@7579d052afcf474ece1239153cffe1c89935bc4f#egg=django-splash -e git+https://github.com/edx/acid-block.git@e46f9cda8a03e121a00c7e347084d142d22ebfb7#egg=acid-xblock -e git+https://github.com/edx/edx-ora2.git@release-2015-01-05T12.24#egg=edx-ora2