Commit 6df64f82 by Clinton Blackburn

Merge pull request #73 from edx/payment-acceptance-tests

Added Acceptance Tests for Payment Flow
parents 22b83fb0 c2191b87
...@@ -51,6 +51,8 @@ diff_*.html ...@@ -51,6 +51,8 @@ diff_*.html
report report
edx_ecommerce.log edx_ecommerce.log
venv venv
acceptance_tests.*.log
acceptance_tests.*.png
# Override config files # Override config files
override.cfg override.cfg
......
import datetime
import requests import requests
from requests.auth import AuthBase from requests.auth import AuthBase
from acceptance_tests.config import (ENROLLMENT_API_URL, ENROLLMENT_API_TOKEN, ECOMMERCE_API_SERVER_URL, from acceptance_tests.config import ENROLLMENT_API_URL, ENROLLMENT_API_TOKEN
ECOMMERCE_API_TOKEN)
class BearerAuth(AuthBase): class BearerAuth(AuthBase):
...@@ -31,26 +28,3 @@ class EnrollmentApiClient(object): ...@@ -31,26 +28,3 @@ class EnrollmentApiClient(object):
""" """
url = '{host}/enrollment/{username},{course_id}'.format(host=self.host, username=username, course_id=course_id) url = '{host}/enrollment/{username},{course_id}'.format(host=self.host, username=username, course_id=course_id)
return requests.get(url, auth=BearerAuth(self.key)).json() return requests.get(url, auth=BearerAuth(self.key)).json()
class EcommerceApiClient(object):
def __init__(self, host=None, key=None):
self.host = host or '{}/api/v1'.format(ECOMMERCE_API_SERVER_URL)
self.key = key or ECOMMERCE_API_TOKEN
def orders(self):
""" Retrieve the orders for the user linked to the authenticated user. """
url = '{}/orders/'.format(self.host)
response = requests.get(url, auth=BearerAuth(self.key))
data = response.json()
status_code = response.status_code
if status_code != 200:
raise Exception('Invalid E-Commerce API response: [{}] - [{}]'.format(status_code, data))
orders = data['results']
for order in orders:
order['date_placed'] = datetime.datetime.strptime(order['date_placed'], "%Y-%m-%dT%H:%M:%S.%fZ")
return orders
...@@ -10,7 +10,8 @@ ACCESS_TOKEN = os.environ.get('ACCESS_TOKEN', 'edx') ...@@ -10,7 +10,8 @@ ACCESS_TOKEN = os.environ.get('ACCESS_TOKEN', 'edx')
# Application configuration # Application configuration
APP_SERVER_URL = os.environ.get('APP_SERVER_URL', 'http://localhost:8002').strip('/') APP_SERVER_URL = os.environ.get('APP_SERVER_URL', 'http://localhost:8002').strip('/')
ECOMMERCE_API_SERVER_URL = os.environ.get('ECOMMERCE_API_SERVER_URL', APP_SERVER_URL).strip('/') ECOMMERCE_API_SERVER_URL = os.environ.get('ECOMMERCE_API_SERVER_URL', APP_SERVER_URL + '/api/v2').strip('/')
ECOMMERCE_API_SIGNING_KEY = os.environ.get('ECOMMERCE_API_SIGNING_KEY', 'edx')
ECOMMERCE_API_TOKEN = os.environ.get('ECOMMERCE_API_AUTH_TOKEN', ACCESS_TOKEN) ECOMMERCE_API_TOKEN = os.environ.get('ECOMMERCE_API_AUTH_TOKEN', ACCESS_TOKEN)
# Amount of time allotted for processing an order. This value is used to match newly-placed orders in testing, and # Amount of time allotted for processing an order. This value is used to match newly-placed orders in testing, and
...@@ -21,6 +22,7 @@ ORDER_PROCESSING_TIME = int(os.environ.get('ORDER_PROCESSING_TIME', 15)) ...@@ -21,6 +22,7 @@ ORDER_PROCESSING_TIME = int(os.environ.get('ORDER_PROCESSING_TIME', 15))
ENABLE_AUTO_AUTH = str2bool(os.environ.get('ENABLE_AUTO_AUTH', False)) ENABLE_AUTO_AUTH = str2bool(os.environ.get('ENABLE_AUTO_AUTH', False))
ENABLE_OAUTH_TESTS = str2bool(os.environ.get('ENABLE_OAUTH_TESTS', True)) ENABLE_OAUTH_TESTS = str2bool(os.environ.get('ENABLE_OAUTH_TESTS', True))
COURSE_ID = os.environ.get('COURSE_ID', 'edX/DemoX/Demo_Course') COURSE_ID = os.environ.get('COURSE_ID', 'edX/DemoX/Demo_Course')
VERIFIED_COURSE_ID = os.environ.get('VERIFIED_COURSE_ID', 'edX/victor101/Victor_s_test_course')
# LMS configuration # LMS configuration
BASIC_AUTH_USERNAME = os.environ.get('BASIC_AUTH_USERNAME') BASIC_AUTH_USERNAME = os.environ.get('BASIC_AUTH_USERNAME')
...@@ -29,6 +31,7 @@ LMS_URL = os.environ.get('LMS_URL').strip('/') ...@@ -29,6 +31,7 @@ LMS_URL = os.environ.get('LMS_URL').strip('/')
LMS_USERNAME = os.environ.get('LMS_USERNAME') LMS_USERNAME = os.environ.get('LMS_USERNAME')
LMS_EMAIL = os.environ.get('LMS_EMAIL') LMS_EMAIL = os.environ.get('LMS_EMAIL')
LMS_PASSWORD = os.environ.get('LMS_PASSWORD') LMS_PASSWORD = os.environ.get('LMS_PASSWORD')
HTTPS_RECEIPT_PAGE = str2bool(os.environ.get('HTTPS_RECEIPT_PAGE', True))
if ENABLE_OAUTH_TESTS and not (LMS_URL and LMS_USERNAME and LMS_PASSWORD): if ENABLE_OAUTH_TESTS and not (LMS_URL and LMS_USERNAME and LMS_PASSWORD):
raise Exception('LMS settings must be set in order to test OAuth.') raise Exception('LMS settings must be set in order to test OAuth.')
......
from acceptance_tests.config import ENABLE_AUTO_AUTH, APP_SERVER_URL, LMS_PASSWORD, LMS_EMAIL import logging
import uuid
import requests
from acceptance_tests.api import EnrollmentApiClient
from acceptance_tests.config import (ENABLE_AUTO_AUTH, APP_SERVER_URL, LMS_PASSWORD, LMS_EMAIL, LMS_URL,
BASIC_AUTH_USERNAME, BASIC_AUTH_PASSWORD, ECOMMERCE_API_SERVER_URL,
ECOMMERCE_API_SIGNING_KEY)
from acceptance_tests.pages import LMSLoginPage from acceptance_tests.pages import LMSLoginPage
from ecommerce_api_client.client import EcommerceApiClient
log = logging.getLogger(__name__)
class LoginMixin(object): class LoginMixin(object):
...@@ -17,15 +29,62 @@ class LoginMixin(object): ...@@ -17,15 +29,62 @@ class LoginMixin(object):
url = '{}/test/auto_auth/'.format(APP_SERVER_URL) url = '{}/test/auto_auth/'.format(APP_SERVER_URL)
self.browser.get(url) self.browser.get(url)
def login_with_lms(self, course_id=None): def login_with_lms(self, email=None, password=None, course_id=None):
""" Visit LMS and login. """ """ Visit LMS and login. """
email = email or LMS_EMAIL
password = password or LMS_PASSWORD
# Note: We use Selenium directly here (as opposed to Bok Choy) to avoid issues with promises being broken. # Note: We use Selenium directly here (as opposed to Bok Choy) to avoid issues with promises being broken.
self.lms_login_page.browser.get(self.lms_login_page.url(course_id)) # pylint: disable=not-callable self.lms_login_page.browser.get(self.lms_login_page.url(course_id)) # pylint: disable=not-callable
self.lms_login_page.login(LMS_EMAIL, LMS_PASSWORD) self.lms_login_page.login(email, password)
class LogoutMixin(object): class LogoutMixin(object):
def logout(self): def logout(self):
url = '{}/accounts/logout/'.format(APP_SERVER_URL) url = '{}/accounts/logout/'.format(APP_SERVER_URL)
self.browser.get(url) self.browser.get(url)
class LmsUserMixin(object):
password = 'edx'
def create_lms_user(self, username=None, password=None, email=None):
username = username or ('auto_auth_' + uuid.uuid4().hex[0:20])
password = password or 'edx'
email = email or '{}@example.com'.format(username)
url = '{host}/auto_auth?no_login=true&username={username}&password={password}&email={email}'.format(
host=LMS_URL, username=username, password=password, email=email)
auth = None
if BASIC_AUTH_USERNAME and BASIC_AUTH_PASSWORD:
auth = (BASIC_AUTH_USERNAME, BASIC_AUTH_PASSWORD)
requests.get(url, auth=auth)
return username, password, email
class EnrollmentApiMixin(object):
def setUp(self):
super(EnrollmentApiMixin, self).setUp()
self.enrollment_api_client = EnrollmentApiClient()
def assert_user_enrolled(self, username, course_id, mode='honor'):
status = self.enrollment_api_client.get_enrollment_status(username, course_id)
self.assertDictContainsSubset({'is_active': True, 'mode': mode}, status)
class EcommerceApiMixin(object):
@property
def ecommerce_api_client(self):
return EcommerceApiClient(ECOMMERCE_API_SERVER_URL, ECOMMERCE_API_SIGNING_KEY, self.username, self.email)
def assert_order_created_and_completed(self):
orders = self.ecommerce_api_client.get_orders()
self.assertGreater(len(orders), 0, 'No orders found for the user!')
# TODO Validate this is the correct order.
order = orders[0]
self.assertEqual(order['status'], 'Complete')
import abc
import urllib import urllib
from bok_choy.page_object import PageObject from bok_choy.page_object import PageObject
from bok_choy.promise import EmptyPromise from bok_choy.promise import EmptyPromise
from selenium.webdriver.support.select import Select
from acceptance_tests.config import BASIC_AUTH_USERNAME, BASIC_AUTH_PASSWORD, APP_SERVER_URL, LMS_URL from acceptance_tests.config import BASIC_AUTH_USERNAME, BASIC_AUTH_PASSWORD, APP_SERVER_URL, LMS_URL
...@@ -27,14 +29,22 @@ class DashboardHomePage(EcommerceAppPage): ...@@ -27,14 +29,22 @@ class DashboardHomePage(EcommerceAppPage):
return self.browser.title.startswith('Dashboard | Oscar') return self.browser.title.startswith('Dashboard | Oscar')
class LMSLoginPage(PageObject): class LMSPage(PageObject): # pylint: disable=abstract-method
def url(self, course_id=None): # pylint: disable=arguments-differ __metaclass__ = abc.ABCMeta
url = '{0}/login'.format(LMS_URL) def _build_url(self, path):
url = '{0}/{1}'.format(LMS_URL, path)
if BASIC_AUTH_USERNAME and BASIC_AUTH_PASSWORD: if BASIC_AUTH_USERNAME and BASIC_AUTH_PASSWORD:
url = url.replace('://', '://{0}:{1}@'.format(BASIC_AUTH_USERNAME, BASIC_AUTH_PASSWORD)) url = url.replace('://', '://{0}:{1}@'.format(BASIC_AUTH_USERNAME, BASIC_AUTH_PASSWORD))
return url
class LMSLoginPage(LMSPage):
def url(self, course_id=None): # pylint: disable=arguments-differ
url = self._build_url('login')
if course_id: if course_id:
params = {'enrollment_action': 'enroll', 'course_id': course_id} params = {'enrollment_action': 'enroll', 'course_id': course_id}
url = '{0}?{1}'.format(url, urllib.urlencode(params)) url = '{0}?{1}'.format(url, urllib.urlencode(params))
...@@ -54,3 +64,59 @@ class LMSLoginPage(PageObject): ...@@ -54,3 +64,59 @@ class LMSLoginPage(PageObject):
# Wait for LMS to redirect to the dashboard # Wait for LMS to redirect to the dashboard
EmptyPromise(self._is_browser_on_lms_dashboard(), "LMS login redirected to dashboard").fulfill() EmptyPromise(self._is_browser_on_lms_dashboard(), "LMS login redirected to dashboard").fulfill()
class LMSCourseModePage(LMSPage):
def is_browser_on_page(self):
return self.browser.title.lower().startswith('enroll in')
@property
def url(self):
path = 'course_modes/choose/{}/'.format(urllib.quote_plus(self.course_id))
return self._build_url(path)
def __init__(self, browser, course_id):
super(LMSCourseModePage, self).__init__(browser)
self.course_id = course_id
def purchase_verified(self):
# Click the purchase button on the track selection page
self.q(css='input[name=verified_mode]').click()
# Click the payment button
self.q(css='a#cybersource').click()
# Wait for form to load
self.wait_for_element_presence('#billing_details', 'Waiting for billing form to load.')
# Select the credit card type (Visa) first since it triggers the display of additional fields
self.q(css='#card_type_001').click() # Visa
# Select the appropriate <option> elements
select_fields = (
('#bill_to_address_country', 'US'),
('#bill_to_address_state_us_ca', 'MA'),
('#card_expiry_year', '2020')
)
for selector, value in select_fields:
select = Select(self.browser.find_element_by_css_selector(selector))
select.select_by_value(value)
# Fill in the text fields
billing_information = {
'bill_to_forename': 'Ed',
'bill_to_surname': 'Xavier',
'bill_to_address_line1': '141 Portland Ave.',
'bill_to_address_line2': '9th Floor',
'bill_to_address_city': 'Cambridge',
'bill_to_address_postal_code': '02141',
'bill_to_email': 'edx@example.com',
'card_number': '4111111111111111',
'card_cvn': '1234'
}
for field, value in billing_information.items():
self.q(css='#' + field).fill(value)
# Click the payment button
self.q(css='input[type=submit]').click()
import datetime
import logging
from bok_choy.web_app_test import WebAppTest from bok_choy.web_app_test import WebAppTest
from acceptance_tests.api import EnrollmentApiClient, EcommerceApiClient from acceptance_tests.config import COURSE_ID
from acceptance_tests.config import COURSE_ID, LMS_USERNAME, ORDER_PROCESSING_TIME from acceptance_tests.mixins import LoginMixin, EcommerceApiMixin, EnrollmentApiMixin, LmsUserMixin
from acceptance_tests.mixins import LoginMixin
log = logging.getLogger(__name__)
class LoginEnrollmentTests(LoginMixin, WebAppTest): class LoginEnrollmentTests(EcommerceApiMixin, EnrollmentApiMixin, LmsUserMixin, LoginMixin, WebAppTest):
def setUp(self): def setUp(self):
super(LoginEnrollmentTests, self).setUp() super(LoginEnrollmentTests, self).setUp()
self.course_id = COURSE_ID self.course_id = COURSE_ID
self.username = LMS_USERNAME self.username, self.password, self.email = self.create_lms_user()
self.enrollment_api_client = EnrollmentApiClient()
self.ecommerce_api_client = EcommerceApiClient()
# TODO Delete existing enrollments
def _test_honor_enrollment_common(self):
"""
Validates an order is created for the logged in user and that a corresponding order has been created.
"""
# Get the latest order
orders = self.ecommerce_api_client.orders()
self.assertGreater(len(orders), 0, 'No orders found for the user!')
order = orders[0]
# TODO Find a better way to verify this is the correct enrollment.
# Verify the date and status
self.assertEqual(order['status'], 'Complete')
order_date = order['date_placed']
now = datetime.datetime.utcnow()
self.assertLess(order_date, now)
self.assertGreater(order_date, now - datetime.timedelta(seconds=ORDER_PROCESSING_TIME))
# Verify user enrolled in course
status = self.enrollment_api_client.get_enrollment_status(self.username, self.course_id)
log.debug(status)
self.assertDictContainsSubset({'is_active': True, 'mode': 'honor'}, status)
def test_honor_enrollment_and_login(self): def test_honor_enrollment_and_login(self):
""" Verifies that a user can login and enroll in a course via the login page. """ """ Verifies that a user can login and enroll in a course via the login page. """
# Login and enroll via LMS # Login and enroll via LMS
self.login_with_lms(self.course_id) self.login_with_lms(self.email, self.password, self.course_id)
self.assert_order_created_and_completed()
self._test_honor_enrollment_common() self.assert_user_enrolled(self.username, self.course_id)
from bok_choy.web_app_test import WebAppTest
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from acceptance_tests.config import VERIFIED_COURSE_ID, HTTPS_RECEIPT_PAGE
from acceptance_tests.mixins import LoginMixin, EnrollmentApiMixin, EcommerceApiMixin, LmsUserMixin
from acceptance_tests.pages import LMSCourseModePage
class VerifiedCertificatePaymentTests(EcommerceApiMixin, EnrollmentApiMixin, LmsUserMixin, LoginMixin, WebAppTest):
def setUp(self):
super(VerifiedCertificatePaymentTests, self).setUp()
self.course_id = VERIFIED_COURSE_ID
self.username, self.password, self.email = self.create_lms_user()
def test_payment(self):
self.login_with_lms(self.email, self.password)
course_modes_page = LMSCourseModePage(self.browser, self.course_id)
course_modes_page.visit()
course_modes_page.purchase_verified()
if not HTTPS_RECEIPT_PAGE:
self.browser.switch_to_alert().accept()
# Wait for the payment processor response to be processed, and the receipt page updated.
WebDriverWait(self.browser, 30).until(EC.presence_of_element_located((By.CLASS_NAME, 'content-main')))
self.assert_order_created_and_completed()
self.assert_user_enrolled(self.username, self.course_id, 'verified')
# Verify we reach the receipt page.
self.assertIn('receipt', self.browser.title.lower())
cells = self.browser.find_elements_by_css_selector('table.report-receipt tbody td')
self.assertGreater(len(cells), 0)
order = self.ecommerce_api_client.get_orders()[0]
line = order['lines'][0]
expected = [
order['number'],
line['description'],
order['date_placed'].strftime('%Y-%m-%dT%H:%M:%S'),
'{amount} ({currency})'.format(amount=line['line_price_excl_tax'], currency=order['currency'])
]
actual = [cell.text for cell in cells]
self.assertListEqual(actual, expected)
...@@ -90,8 +90,6 @@ class EnrollmentFulfillmentModule(BaseFulfillmentModule): ...@@ -90,8 +90,6 @@ class EnrollmentFulfillmentModule(BaseFulfillmentModule):
""" """
REQUEST_TIMEOUT = 5
def get_supported_lines(self, order, lines): def get_supported_lines(self, order, lines):
""" Return a list of lines that can be fulfilled through enrollment. """ Return a list of lines that can be fulfilled through enrollment.
...@@ -168,7 +166,7 @@ class EnrollmentFulfillmentModule(BaseFulfillmentModule): ...@@ -168,7 +166,7 @@ class EnrollmentFulfillmentModule(BaseFulfillmentModule):
enrollment_api_url, enrollment_api_url,
data=json.dumps(data), data=json.dumps(data),
headers=headers, headers=headers,
timeout=self.REQUEST_TIMEOUT timeout=getattr(settings, 'ENROLLMENT_FULFILLMENT_TIMEOUT', 5)
) )
if response.status_code == status.HTTP_200_OK: if response.status_code == status.HTTP_200_OK:
......
...@@ -71,13 +71,17 @@ INTERNAL_IPS = ('127.0.0.1',) ...@@ -71,13 +71,17 @@ INTERNAL_IPS = ('127.0.0.1',)
# Do not include a trailing slash. # Do not include a trailing slash.
LMS_URL_ROOT = 'http://127.0.0.1:8000' LMS_URL_ROOT = 'http://127.0.0.1:8000'
def get_lms_url(path):
return LMS_URL_ROOT + path
# The location of the LMS heartbeat page # The location of the LMS heartbeat page
LMS_HEARTBEAT_URL = LMS_URL_ROOT + '/heartbeat' LMS_HEARTBEAT_URL = get_lms_url('/heartbeat')
# The location of the LMS student dashboard # The location of the LMS student dashboard
LMS_DASHBOARD_URL = LMS_URL_ROOT + '/dashboard' LMS_DASHBOARD_URL = get_lms_url('/dashboard')
OAUTH2_PROVIDER_URL = LMS_URL_ROOT + '/oauth2' OAUTH2_PROVIDER_URL = get_lms_url('/oauth2')
# END URL CONFIGURATION # END URL CONFIGURATION
...@@ -93,7 +97,8 @@ JWT_AUTH['JWT_SECRET_KEY'] = 'insecure-secret-key' ...@@ -93,7 +97,8 @@ JWT_AUTH['JWT_SECRET_KEY'] = 'insecure-secret-key'
# ORDER PROCESSING # ORDER PROCESSING
ENROLLMENT_API_URL = LMS_URL_ROOT + '/api/enrollment/v1/enrollment' ENROLLMENT_API_URL = get_lms_url('/api/enrollment/v1/enrollment')
ENROLLMENT_FULFILLMENT_TIMEOUT = 15 # devstack is slow!
EDX_API_KEY = 'replace-me' EDX_API_KEY = 'replace-me'
# END ORDER PROCESSING # END ORDER PROCESSING
...@@ -106,10 +111,8 @@ PAYMENT_PROCESSOR_CONFIG = { ...@@ -106,10 +111,8 @@ PAYMENT_PROCESSOR_CONFIG = {
'access_key': 'fake-access-key', 'access_key': 'fake-access-key',
'secret_key': 'fake-secret-key', 'secret_key': 'fake-secret-key',
'payment_page_url': 'https://replace-me/', 'payment_page_url': 'https://replace-me/',
# TODO: XCOM-202 must be completed before any other receipt page is used. 'receipt_page_url': get_lms_url('/commerce/checkout/receipt/'),
# By design this specific receipt page is expected. 'cancel_page_url': get_lms_url('/commerce/checkout/cancel/')
'receipt_page_url': 'https://replace-me/verify_student/payment-confirmation/',
'cancel_page_url': 'https://replace-me/',
} }
} }
# END PAYMENT PROCESSING # END PAYMENT PROCESSING
......
...@@ -14,3 +14,5 @@ jsonfield==1.0.3 ...@@ -14,3 +14,5 @@ jsonfield==1.0.3
logutils==0.3.3 logutils==0.3.3
pycountry==1.10 pycountry==1.10
requests==2.6.0 requests==2.6.0
git+https://github.com/edx/ecommerce-api-client.git@0.1.0#egg=ecommerce-api-client
...@@ -12,3 +12,4 @@ nose-ignore-docstring==0.2 ...@@ -12,3 +12,4 @@ nose-ignore-docstring==0.2
pep8==1.6.2 pep8==1.6.2
pylint==1.4.1 pylint==1.4.1
selenium==2.45.0 selenium==2.45.0
slumber==0.7.0
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment