class LMSPublisherTests(CourseCatalogTestMixin, TestCase): def setUp(self): super(LMSPublisherTests, self).setUp() httpretty.enable() self.mock_access_token_response() self.course = CourseFactory(verification_deadline=timezone.now() + datetime.timedelta(days=7), site=self.site) self.course.create_or_update_seat('honor', False, 0, self.partner) self.course.create_or_update_seat('verified', True, 50, self.partner) self.publisher = LMSPublisher() self.error_message = 'Failed to publish commerce data for {course_id} to LMS.'.format( course_id=self.course.id) def tearDown(self): super(LMSPublisherTests, self).tearDown() httpretty.disable() httpretty.reset() def _mock_commerce_api(self, status=200, body=None): self.assertTrue( httpretty.is_enabled(), 'httpretty must be enabled to mock Commerce API calls.') body = body or {} url = self.site_configuration.build_lms_url( '/api/commerce/v1/courses/{}/'.format(self.course.id)) httpretty.register_uri(httpretty.PUT, url, status=status, body=json.dumps(body), content_type=JSON) def mock_creditcourse_endpoint(self, course_id, status, body=None): self.assertTrue(httpretty.is_enabled(), 'httpretty must be enabled to mock Credit API calls.') url = get_lms_url('/api/credit/v1/courses/{}/'.format(course_id)) httpretty.register_uri(httpretty.PUT, url, status=status, body=json.dumps(body), content_type=JSON) def test_api_exception(self): """ If an exception is raised when communicating with the Commerce API, an ERROR message should be logged. """ error = 'time out error' with mock.patch('requests.put', side_effect=Timeout(error)): with LogCapture(LOGGER_NAME) as l: actual = self.publisher.publish(self.course) l.check(( LOGGER_NAME, 'ERROR', 'Failed to publish commerce data for [{course_id}] to LMS.' .format(course_id=self.course.id))) self.assertEqual(actual, self.error_message) def test_api_error(self): """ If the Commerce API returns a non-successful status, an ERROR message should be logged. """ status = 400 expected_msg = 'deadline issue' api_body = {'non_field_errors': [expected_msg]} self._mock_commerce_api(status, api_body) with LogCapture(LOGGER_NAME) as l: actual = self.publisher.publish(self.course) l.check(( LOGGER_NAME, 'ERROR', 'Failed to publish commerce data for [{}] to LMS. Status was [{}]. Body was [{}].' .format(self.course.id, status, json.dumps(api_body)))) self.assertEqual(actual, self.error_message + ' ' + expected_msg) def test_api_success(self): """ If the Commerce API returns a successful status, an INFO message should be logged. """ self._mock_commerce_api() with LogCapture(LOGGER_NAME) as l: response = self.publisher.publish(self.course) self.assertIsNone(response) l.check((LOGGER_NAME, 'INFO', 'Successfully published commerce data for [{}].'.format( self.course.id))) last_request = httpretty.last_request() # Verify the data passed to the API was correct. actual = json.loads(last_request.body) expected = { 'id': self.course.id, 'name': self.course.name, 'verification_deadline': self.course.verification_deadline.isoformat(), 'modes': [ self.publisher.serialize_seat_for_commerce_api(seat) for seat in self.course.seat_products ] } self.assertDictEqual(actual, expected) def test_serialize_seat_for_commerce_api(self): """ The method should convert a seat to a JSON-serializable dict consumable by the Commerce API. """ # Grab the verified seat seat = sorted(self.course.seat_products, key=lambda p: getattr(p.attr, 'certificate_type', ''))[1] stock_record = seat.stockrecords.first() actual = self.publisher.serialize_seat_for_commerce_api(seat) expected = { 'name': 'verified', 'currency': 'USD', 'price': int(stock_record.price_excl_tax), 'sku': stock_record.partner_sku, 'bulk_sku': None, 'expires': None, } self.assertDictEqual(actual, expected) # Try with an expiration datetime expires = datetime.datetime.utcnow() seat.expires = expires expected['expires'] = expires.isoformat() actual = self.publisher.serialize_seat_for_commerce_api(seat) self.assertDictEqual(actual, expected) @ddt.unpack @ddt.data( (True, 'professional'), (False, 'no-id-professional'), ) def test_serialize_seat_for_commerce_api_with_professional( self, is_verified, expected_mode): """ Verify that (a) professional seats NEVER have an expiration date and (b) the name/mode is properly set for no-id-professional seats. """ seat = self.course.create_or_update_seat( 'professional', is_verified, 500, self.partner, expires=datetime.datetime.utcnow()) stock_record = seat.stockrecords.first() actual = self.publisher.serialize_seat_for_commerce_api(seat) expected = { 'name': expected_mode, 'currency': 'USD', 'price': int(stock_record.price_excl_tax), 'sku': stock_record.partner_sku, 'bulk_sku': None, 'expires': None, } self.assertDictEqual(actual, expected) def test_serialize_seat_with_enrollment_code(self): toggle_switch(ENROLLMENT_CODE_SWITCH, True) seat = self.course.create_or_update_seat('verified', False, 10, self.partner, create_enrollment_code=True) stock_record = seat.stockrecords.first() ec_stock_record = StockRecord.objects.get( product__product_class__name=ENROLLMENT_CODE_PRODUCT_CLASS_NAME) actual = self.publisher.serialize_seat_for_commerce_api(seat) expected = { 'name': 'verified', 'currency': 'USD', 'price': int(stock_record.price_excl_tax), 'sku': stock_record.partner_sku, 'bulk_sku': ec_stock_record.partner_sku, 'expires': None, } self.assertDictEqual(actual, expected) def attempt_credit_publication(self, api_status): """ Sets up a credit seat and attempts to publish it to LMS. Returns String - Publish error message. """ # Setup the course and mock the API endpoints self.course.create_or_update_seat('credit', True, 100, self.partner, credit_provider='acme', credit_hours=1) self.mock_creditcourse_endpoint(self.course.id, api_status) self._mock_commerce_api(201) # Attempt to publish the course return self.publisher.publish(self.course) def assert_creditcourse_endpoint_called(self): """ Verify the Credit API's CreditCourse endpoint was called. """ paths = [ request.path for request in httpretty.httpretty.latest_requests ] self.assertIn('/api/credit/v1/courses/{}/'.format(self.course.id), paths) def test_credit_publication_success(self): """ Verify the endpoint returns successfully when credit publication succeeds. """ error_message = self.attempt_credit_publication(201) self.assertIsNone(error_message) self.assert_creditcourse_endpoint_called() def test_credit_publication_api_failure(self): """ Verify the endpoint fails appropriately when Credit API calls return an error. """ course_id = self.course.id with LogCapture(LOGGER_NAME) as l: status = 400 actual = self.attempt_credit_publication(status) # Ensure the HTTP status and response are logged expected_log = 'Failed to publish CreditCourse for [{course_id}] to LMS. ' \ 'Status was [{status}]. Body was [null].'.format(course_id=course_id, status=status) l.check((LOGGER_NAME, 'ERROR', expected_log)) expected = 'Failed to publish commerce data for {} to LMS.'.format( course_id) self.assertEqual(actual, expected) self.assert_creditcourse_endpoint_called() @mock.patch('requests.get', mock.Mock(side_effect=Exception)) def test_credit_publication_uncaught_exception(self): """ Verify the endpoint fails appropriately when the Credit API fails unexpectedly. """ actual = self.attempt_credit_publication(500) expected = 'Failed to publish commerce data for {} to LMS.'.format( self.course.id) self.assertEqual(actual, expected)
class LMSPublisherTests(CourseCatalogTestMixin, PartnerMixin, TestCase): def setUp(self): super(LMSPublisherTests, self).setUp() self.course = G(Course) self.partner = self.create_partner('edx') self.course.create_or_update_seat('honor', False, 0, self.partner) self.course.create_or_update_seat('verified', True, 50, self.partner) self.publisher = LMSPublisher() self.error_message = u'Failed to publish commerce data for {course_id} to LMS.'.format( course_id=self.course.id ) def _mock_commerce_api(self, status, body=None): self.assertTrue(httpretty.is_enabled, 'httpretty must be enabled to mock Commerce API calls.') body = body or {} url = '{}/courses/{}/'.format(settings.COMMERCE_API_URL.rstrip('/'), self.course.id) httpretty.register_uri(httpretty.PUT, url, status=status, body=json.dumps(body), content_type=JSON) def _mock_credit_api(self, creation_status, update_status, body=None): self.assertTrue(httpretty.is_enabled, 'httpretty must be enabled to mock Credit API calls.') url = get_lms_url('api/credit/v1/courses/') httpretty.register_uri( httpretty.POST, url, status=creation_status, body=json.dumps(body), content_type=JSON ) if update_status is not None: url += self.course.id.strip('/') + '/' httpretty.register_uri( httpretty.PUT, url, status=update_status, body=json.dumps(body), content_type=JSON ) @ddt.data('', None) def test_commerce_api_url_not_set(self, setting_value): """ If the Commerce API is not setup, the method should log an INFO message and return """ with override_settings(COMMERCE_API_URL=setting_value): with LogCapture(LOGGER_NAME) as l: response = self.publisher.publish(self.course) l.check((LOGGER_NAME, 'ERROR', 'COMMERCE_API_URL is not set. Commerce data will not be published!')) self.assertIsNotNone(response) self.assertEqual( response, self.error_message.format( course_id=self.course.id ) ) def test_api_exception(self): """ If an exception is raised when communicating with the Commerce API, an ERROR message should be logged. """ error = 'time out error' with mock.patch('requests.put', side_effect=Timeout(error)): with LogCapture(LOGGER_NAME) as l: response = self.publisher.publish(self.course) l.check( ( LOGGER_NAME, 'ERROR', u'Failed to publish commerce data for [{course_id}] to LMS.'.format( course_id=self.course.id ) ) ) self.assertIsNotNone(response) self.assertEqual(self.error_message, response) @httpretty.activate @ddt.unpack @ddt.data( (400, {'non_field_errors': ['deadline issue']}, 'deadline issue'), (404, 'page not found', 'page not found'), (401, {'detail': 'Authentication'}, 'Authentication'), (401, {}, ''), ) def test_api_bad_status(self, status, error_msg, expected_msg): """ If the Commerce API returns a non-successful status, an ERROR message should be logged. """ self._mock_commerce_api(status, error_msg) with LogCapture(LOGGER_NAME) as l: response = self.publisher.publish(self.course) l.check( ( LOGGER_NAME, 'ERROR', u'Failed to publish commerce data for [{}] to LMS. Status was [{}]. Body was [{}].'.format( self.course.id, status, json.dumps(error_msg)) ) ) self.assert_response_message(response, expected_msg) @httpretty.activate @ddt.data(200, 201) def test_api_success(self, status): """ If the Commerce API returns a successful status, an INFO message should be logged. """ self._mock_commerce_api(status) with LogCapture(LOGGER_NAME) as l: response = self.publisher.publish(self.course) self.assertIsNone(response) l.check((LOGGER_NAME, 'INFO', 'Successfully published commerce data for [{}].'.format(self.course.id))) last_request = httpretty.last_request() # Verify the headers passed to the API were correct. expected = { 'Content-Type': JSON, 'X-Edx-Api-Key': EDX_API_KEY } self.assertDictContainsSubset(expected, last_request.headers) # Verify the data passed to the API was correct. actual = json.loads(last_request.body) expected = { 'id': self.course.id, 'name': self.course.name, 'verification_deadline': self.course.verification_deadline.isoformat(), 'modes': [self.publisher.serialize_seat_for_commerce_api(seat) for seat in self.course.seat_products] } self.assertDictEqual(actual, expected) def test_serialize_seat_for_commerce_api(self): """ The method should convert a seat to a JSON-serializable dict consumable by the Commerce API. """ # Grab the verified seat seat = sorted(self.course.seat_products, key=lambda p: getattr(p.attr, 'certificate_type', ''))[1] stock_record = seat.stockrecords.first() actual = self.publisher.serialize_seat_for_commerce_api(seat) expected = { 'name': 'verified', 'currency': 'USD', 'price': int(stock_record.price_excl_tax), 'sku': stock_record.partner_sku, 'expires': None } self.assertDictEqual(actual, expected) # Try with an expiration datetime expires = datetime.datetime.utcnow() seat.expires = expires expected['expires'] = expires.isoformat() actual = self.publisher.serialize_seat_for_commerce_api(seat) self.assertDictEqual(actual, expected) @ddt.unpack @ddt.data( (True, 'professional'), (False, 'no-id-professional'), ) def test_serialize_seat_for_commerce_api_with_professional(self, is_verified, expected_mode): """ Verify that (a) professional seats NEVER have an expiration date and (b) the name/mode is properly set for no-id-professional seats. """ seat = self.course.create_or_update_seat( 'professional', is_verified, 500, self.partner, expires=datetime.datetime.utcnow() ) stock_record = seat.stockrecords.first() actual = self.publisher.serialize_seat_for_commerce_api(seat) expected = { 'name': expected_mode, 'currency': 'USD', 'price': int(stock_record.price_excl_tax), 'sku': stock_record.partner_sku, 'expires': None } self.assertDictEqual(actual, expected) @httpretty.activate @ddt.data( (201, None, 201), (400, 200, 200) ) @ddt.unpack def test_credit_publication_success(self, creation_status, update_status, commerce_status): """ Verify that course publication succeeds if the Credit API responds with 2xx status codes when publishing CreditCourse data to the LMS. """ self.course.create_or_update_seat('credit', True, 100, self.partner, credit_provider='Harvard', credit_hours=1) self._mock_credit_api(creation_status, update_status) self._mock_commerce_api(commerce_status) access_token = 'access_token' error_message = self.publisher.publish(self.course, access_token=access_token) self.assertIsNone(error_message) # Retrieve the latest request to the Credit API. if creation_status == 400: latest_request = httpretty.httpretty.latest_requests[1] else: latest_request = httpretty.httpretty.latest_requests[0] # Verify the headers passed to the Credit API were correct. expected = { 'Content-Type': JSON, 'Authorization': 'Bearer ' + access_token } self.assertDictContainsSubset(expected, latest_request.headers) # Verify the data passed to the Credit API was correct. expected = { 'course_key': self.course.id, 'enabled': True } actual = json.loads(latest_request.body) self.assertEqual(expected, actual) @httpretty.activate @ddt.unpack @ddt.data( ({'non_field_errors': ['deadline issue']}, 'deadline issue'), ('page not found', 'page not found'), ({'detail': 'Authentication'}, 'Authentication'), ({}, ''), ) def test_credit_publication_failure(self, error_message, expected_message): """ Verify that course publication fails if the Credit API does not respond with 2xx status codes when publishing CreditCourse data to the LMS. """ self.course.create_or_update_seat('credit', True, 100, self.partner, credit_provider='Harvard', credit_hours=1) self._mock_credit_api(400, 418, error_message) response = self.publisher.publish(self.course, access_token='access_token') self.assert_response_message(response, expected_message) def test_credit_publication_no_access_token(self): """ Verify that course publication fails if no access token is provided when publishing CreditCourse data to the LMS. """ self.course.create_or_update_seat('credit', True, 100, self.partner, credit_provider='Harvard', credit_hours=1) response = self.publisher.publish(self.course, access_token=None) self.assertIsNotNone(response) self.assertEqual(self.error_message, response) def test_credit_publication_exception(self): """ Verify that course publication fails if an exception is raised while publishing CreditCourse data to the LMS. """ self.course.create_or_update_seat('credit', True, 100, self.partner, credit_provider='Harvard', credit_hours=1) with mock.patch.object(LMSPublisher, '_publish_creditcourse') as mock_publish_creditcourse: mock_publish_creditcourse.side_effect = Exception(self.error_message) response = self.publisher.publish(self.course, access_token='access_token') self.assertIsNotNone(response) self.assertEqual(self.error_message, response) def assert_response_message(self, api_response, expected_error_msg): self.assertIsNotNone(api_response) if expected_error_msg: self.assertEqual(api_response, " ".join([self.error_message, expected_error_msg])) else: self.assertEqual(api_response, self.error_message, expected_error_msg)
class LMSPublisherTests(CourseCatalogTestMixin, TestCase): def setUp(self): super(LMSPublisherTests, self).setUp() self.course = G(Course) self.course.create_or_update_seat('honor', False, 0) self.course.create_or_update_seat('verified', True, 50) self.publisher = LMSPublisher() def _mock_commerce_api(self, status, body=None): self.assertTrue(httpretty.is_enabled, 'httpretty must be enabled to mock Commerce API calls.') body = body or {} url = '{}/courses/{}/'.format(settings.COMMERCE_API_URL.rstrip('/'), self.course.id) httpretty.register_uri(httpretty.PUT, url, status=status, body=json.dumps(body), content_type=JSON) @ddt.data('', None) def test_commerce_api_url_not_set(self, setting_value): """ If the Commerce API is not setup, the method should log an INFO message and return """ with override_settings(COMMERCE_API_URL=setting_value): with LogCapture(LOGGER_NAME) as l: self.publisher.publish(self.course) l.check((LOGGER_NAME, 'ERROR', 'COMMERCE_API_URL is not set. Commerce data will not be published!')) def test_api_exception(self): """ If an exception is raised when communicating with the Commerce API, an ERROR message should be logged. """ with mock.patch('requests.put', side_effect=Timeout): with LogCapture(LOGGER_NAME) as l: self.publisher.publish(self.course) l.check( (LOGGER_NAME, 'ERROR', 'Failed to publish commerce data for [{}] to LMS.'.format(self.course.id))) @httpretty.activate @ddt.data(401, 403, 404, 500) def test_api_bad_status(self, status): """ If the Commerce API returns a non-successful status, an ERROR message should be logged. """ body = {u'error': u'Testing!'} self._mock_commerce_api(status, body) with LogCapture(LOGGER_NAME) as l: self.publisher.publish(self.course) l.check((LOGGER_NAME, 'ERROR', u'Failed to publish commerce data for [{}] to LMS. Status was [{}]. Body was [{}].'.format( self.course.id, status, json.dumps(body)))) @httpretty.activate @ddt.data(200, 201) def test_api_success(self, status): """ If the Commerce API returns a successful status, an INFO message should be logged. """ self._mock_commerce_api(status) with LogCapture(LOGGER_NAME) as l: self.publisher.publish(self.course) l.check((LOGGER_NAME, 'INFO', 'Successfully published commerce data for [{}].'.format(self.course.id))) last_request = httpretty.last_request() # Verify the headers passed to the API were correct. expected = { 'Content-Type': JSON, 'X-Edx-Api-Key': EDX_API_KEY } self.assertDictContainsSubset(expected, last_request.headers) # Verify the data passed to the API was correct. actual = json.loads(last_request.body) expected = { 'id': self.course.id, 'name': self.course.name, 'verification_deadline': self.course.verification_deadline.isoformat(), 'modes': [self.publisher.serialize_seat_for_commerce_api(seat) for seat in self.course.seat_products] } self.assertDictEqual(actual, expected) def test_serialize_seat_for_commerce_api(self): """ The method should convert a seat to a JSON-serializable dict consumable by the Commerce API. """ # Grab the verified seat seat = sorted(self.course.seat_products, key=lambda p: getattr(p.attr, 'certificate_type', ''))[1] stock_record = seat.stockrecords.first() actual = self.publisher.serialize_seat_for_commerce_api(seat) expected = { 'name': 'verified', 'currency': 'USD', 'price': int(stock_record.price_excl_tax), 'sku': stock_record.partner_sku, 'expires': None } self.assertDictEqual(actual, expected) # Try with an expiration datetime expires = datetime.datetime.utcnow() seat.expires = expires expected['expires'] = expires.isoformat() actual = self.publisher.serialize_seat_for_commerce_api(seat) self.assertDictEqual(actual, expected) @ddt.unpack @ddt.data( (True, 'professional'), (False, 'no-id-professional'), ) def test_serialize_seat_for_commerce_api_with_professional(self, is_verified, expected_mode): """ Verify that (a) professional seats NEVER have an expiration date and (b) the name/mode is properly set for no-id-professional seats. """ seat = self.course.create_or_update_seat('professional', is_verified, 500, expires=datetime.datetime.utcnow()) stock_record = seat.stockrecords.first() actual = self.publisher.serialize_seat_for_commerce_api(seat) expected = { 'name': expected_mode, 'currency': 'USD', 'price': int(stock_record.price_excl_tax), 'sku': stock_record.partner_sku, 'expires': None } self.assertDictEqual(actual, expected)
class LMSPublisherTests(CourseCatalogTestMixin, TestCase): def setUp(self): super(LMSPublisherTests, self).setUp() self.course = CourseFactory(verification_deadline=datetime.datetime.now() + datetime.timedelta(days=7)) self.course.create_or_update_seat('honor', False, 0, self.partner) self.course.create_or_update_seat('verified', True, 50, self.partner) self.publisher = LMSPublisher() self.error_message = u'Failed to publish commerce data for {course_id} to LMS.'.format( course_id=self.course.id ) def _mock_commerce_api(self, status, body=None): self.assertTrue(httpretty.is_enabled(), 'httpretty must be enabled to mock Commerce API calls.') body = body or {} url = '{}/courses/{}/'.format(get_lms_commerce_api_url().rstrip('/'), self.course.id) httpretty.register_uri(httpretty.PUT, url, status=status, body=json.dumps(body), content_type=JSON) def mock_creditcourse_endpoint(self, course_id, status, body=None): self.assertTrue(httpretty.is_enabled(), 'httpretty must be enabled to mock Credit API calls.') url = get_lms_url('/api/credit/v1/courses/{}/'.format(course_id)) httpretty.register_uri( httpretty.PUT, url, status=status, body=json.dumps(body), content_type=JSON ) @mock.patch('ecommerce.courses.publishers.get_lms_commerce_api_url', mock.Mock(return_value=None)) def test_commerce_api_url_not_set(self): """ If the commerce API url cannot be retrieved, the method should log an ERROR message and return """ with LogCapture(LOGGER_NAME) as l: response = self.publisher.publish(self.course) l.check( (LOGGER_NAME, 'ERROR', 'Commerce API URL is not set. Commerce data will not be published!') ) self.assertIsNotNone(response) self.assertEqual(response, self.error_message) def test_api_exception(self): """ If an exception is raised when communicating with the Commerce API, an ERROR message should be logged. """ error = 'time out error' with mock.patch('requests.put', side_effect=Timeout(error)): with LogCapture(LOGGER_NAME) as l: response = self.publisher.publish(self.course) l.check( ( LOGGER_NAME, 'ERROR', u'Failed to publish commerce data for [{course_id}] to LMS.'.format( course_id=self.course.id ) ) ) self.assertIsNotNone(response) self.assertEqual(self.error_message, response) @httpretty.activate @ddt.unpack @ddt.data( (400, {'non_field_errors': ['deadline issue']}, 'deadline issue'), (404, 'page not found', 'page not found'), (401, {'detail': 'Authentication'}, 'Authentication'), (401, {}, ''), ) def test_api_bad_status(self, status, error_msg, expected_msg): """ If the Commerce API returns a non-successful status, an ERROR message should be logged. """ self._mock_commerce_api(status, error_msg) with LogCapture(LOGGER_NAME) as l: response = self.publisher.publish(self.course) l.check( ( LOGGER_NAME, 'ERROR', u'Failed to publish commerce data for [{}] to LMS. Status was [{}]. Body was [{}].'.format( self.course.id, status, json.dumps(error_msg)) ) ) self.assert_response_message(response, expected_msg) @httpretty.activate @ddt.data(200, 201) def test_api_success(self, status): """ If the Commerce API returns a successful status, an INFO message should be logged. """ self._mock_commerce_api(status) with LogCapture(LOGGER_NAME) as l: response = self.publisher.publish(self.course) self.assertIsNone(response) l.check((LOGGER_NAME, 'INFO', 'Successfully published commerce data for [{}].'.format(self.course.id))) last_request = httpretty.last_request() # Verify the headers passed to the API were correct. expected = { 'Content-Type': JSON, 'X-Edx-Api-Key': EDX_API_KEY } self.assertDictContainsSubset(expected, last_request.headers) # Verify the data passed to the API was correct. actual = json.loads(last_request.body) expected = { 'id': self.course.id, 'name': self.course.name, 'verification_deadline': self.course.verification_deadline.isoformat(), 'modes': [self.publisher.serialize_seat_for_commerce_api(seat) for seat in self.course.seat_products] } self.assertDictEqual(actual, expected) def test_serialize_seat_for_commerce_api(self): """ The method should convert a seat to a JSON-serializable dict consumable by the Commerce API. """ # Grab the verified seat seat = sorted(self.course.seat_products, key=lambda p: getattr(p.attr, 'certificate_type', ''))[1] stock_record = seat.stockrecords.first() actual = self.publisher.serialize_seat_for_commerce_api(seat) expected = { 'name': 'verified', 'currency': 'USD', 'price': int(stock_record.price_excl_tax), 'sku': stock_record.partner_sku, 'expires': None } self.assertDictEqual(actual, expected) # Try with an expiration datetime expires = datetime.datetime.utcnow() seat.expires = expires expected['expires'] = expires.isoformat() actual = self.publisher.serialize_seat_for_commerce_api(seat) self.assertDictEqual(actual, expected) @ddt.unpack @ddt.data( (True, 'professional'), (False, 'no-id-professional'), ) def test_serialize_seat_for_commerce_api_with_professional(self, is_verified, expected_mode): """ Verify that (a) professional seats NEVER have an expiration date and (b) the name/mode is properly set for no-id-professional seats. """ seat = self.course.create_or_update_seat( 'professional', is_verified, 500, self.partner, expires=datetime.datetime.utcnow() ) stock_record = seat.stockrecords.first() actual = self.publisher.serialize_seat_for_commerce_api(seat) expected = { 'name': expected_mode, 'currency': 'USD', 'price': int(stock_record.price_excl_tax), 'sku': stock_record.partner_sku, 'expires': None } self.assertDictEqual(actual, expected) def attempt_credit_publication(self, api_status): """ Sets up a credit seat and attempts to publish it to LMS. Returns String - Publish error message. """ # Setup the course and mock the API endpoints self.course.create_or_update_seat('credit', True, 100, self.partner, credit_provider='acme', credit_hours=1) self.mock_creditcourse_endpoint(self.course.id, api_status) self._mock_commerce_api(201) # Attempt to publish the course return self.publisher.publish(self.course, access_token='access_token') def assert_creditcourse_endpoint_called(self): """ Verify the Credit API's CreditCourse endpoint was called. """ last_request = httpretty.httpretty.latest_requests[0] self.assertEqual(last_request.path, '/api/credit/v1/courses/{}/'.format(self.course.id)) @httpretty.activate def test_credit_publication_success(self): """ Verify the endpoint returns successfully when credit publication succeeds. """ error_message = self.attempt_credit_publication(201) self.assertIsNone(error_message) self.assert_creditcourse_endpoint_called() @httpretty.activate def test_credit_publication_api_failure(self): """ Verify the endpoint fails appropriately when Credit API calls return an error. """ course_id = self.course.id with LogCapture(LOGGER_NAME) as l: status = 400 actual = self.attempt_credit_publication(status) # Ensure the HTTP status and response are logged expected_log = 'Failed to publish CreditCourse for [{course_id}] to LMS. ' \ 'Status was [{status}]. Body was \'null\'.'.format(course_id=course_id, status=status) l.check((LOGGER_NAME, 'ERROR', expected_log)) expected = 'Failed to publish commerce data for {} to LMS.'.format(course_id) self.assertEqual(actual, expected) self.assert_creditcourse_endpoint_called() @httpretty.activate @mock.patch('requests.get', mock.Mock(side_effect=Exception)) def test_credit_publication_uncaught_exception(self): """ Verify the endpoint fails appropriately when the Credit API fails unexpectedly. """ actual = self.attempt_credit_publication(500) expected = 'Failed to publish commerce data for {} to LMS.'.format(self.course.id) self.assertEqual(actual, expected) def assert_response_message(self, api_response, expected_error_msg): self.assertIsNotNone(api_response) if expected_error_msg: self.assertEqual(api_response, " ".join([self.error_message, expected_error_msg])) else: self.assertEqual(api_response, self.error_message, expected_error_msg)
class LMSPublisherTests(CourseCatalogTestMixin, TestCase): def setUp(self): super(LMSPublisherTests, self).setUp() self.course = CourseFactory(verification_deadline=datetime.datetime.now() + datetime.timedelta(days=7)) self.course.create_or_update_seat('honor', False, 0, self.partner) self.course.create_or_update_seat('verified', True, 50, self.partner) self.publisher = LMSPublisher() self.error_message = u'Failed to publish commerce data for {course_id} to LMS.'.format( course_id=self.course.id ) def _mock_commerce_api(self, status, body=None): self.assertTrue(httpretty.is_enabled(), 'httpretty must be enabled to mock Commerce API calls.') body = body or {} url = '{}/courses/{}/'.format(settings.COMMERCE_API_URL.rstrip('/'), self.course.id) httpretty.register_uri(httpretty.PUT, url, status=status, body=json.dumps(body), content_type=JSON) def mock_creditcourse_endpoint(self, course_id, status, body=None): self.assertTrue(httpretty.is_enabled(), 'httpretty must be enabled to mock Credit API calls.') url = get_lms_url('/api/credit/v1/courses/{}/'.format(course_id)) httpretty.register_uri( httpretty.PUT, url, status=status, body=json.dumps(body), content_type=JSON ) @ddt.data('', None) def test_commerce_api_url_not_set(self, setting_value): """ If the Commerce API is not setup, the method should log an INFO message and return """ with override_settings(COMMERCE_API_URL=setting_value): with LogCapture(LOGGER_NAME) as l: response = self.publisher.publish(self.course) l.check((LOGGER_NAME, 'ERROR', 'COMMERCE_API_URL is not set. Commerce data will not be published!')) self.assertIsNotNone(response) self.assertEqual(response, self.error_message) def test_api_exception(self): """ If an exception is raised when communicating with the Commerce API, an ERROR message should be logged. """ error = 'time out error' with mock.patch('requests.put', side_effect=Timeout(error)): with LogCapture(LOGGER_NAME) as l: response = self.publisher.publish(self.course) l.check( ( LOGGER_NAME, 'ERROR', u'Failed to publish commerce data for [{course_id}] to LMS.'.format( course_id=self.course.id ) ) ) self.assertIsNotNone(response) self.assertEqual(self.error_message, response) @httpretty.activate @ddt.unpack @ddt.data( (400, {'non_field_errors': ['deadline issue']}, 'deadline issue'), (404, 'page not found', 'page not found'), (401, {'detail': 'Authentication'}, 'Authentication'), (401, {}, ''), ) def test_api_bad_status(self, status, error_msg, expected_msg): """ If the Commerce API returns a non-successful status, an ERROR message should be logged. """ self._mock_commerce_api(status, error_msg) with LogCapture(LOGGER_NAME) as l: response = self.publisher.publish(self.course) l.check( ( LOGGER_NAME, 'ERROR', u'Failed to publish commerce data for [{}] to LMS. Status was [{}]. Body was [{}].'.format( self.course.id, status, json.dumps(error_msg)) ) ) self.assert_response_message(response, expected_msg) @httpretty.activate @ddt.data(200, 201) def test_api_success(self, status): """ If the Commerce API returns a successful status, an INFO message should be logged. """ self._mock_commerce_api(status) with LogCapture(LOGGER_NAME) as l: response = self.publisher.publish(self.course) self.assertIsNone(response) l.check((LOGGER_NAME, 'INFO', 'Successfully published commerce data for [{}].'.format(self.course.id))) last_request = httpretty.last_request() # Verify the headers passed to the API were correct. expected = { 'Content-Type': JSON, 'X-Edx-Api-Key': EDX_API_KEY } self.assertDictContainsSubset(expected, last_request.headers) # Verify the data passed to the API was correct. actual = json.loads(last_request.body) expected = { 'id': self.course.id, 'name': self.course.name, 'verification_deadline': self.course.verification_deadline.isoformat(), 'modes': [self.publisher.serialize_seat_for_commerce_api(seat) for seat in self.course.seat_products] } self.assertDictEqual(actual, expected) def test_serialize_seat_for_commerce_api(self): """ The method should convert a seat to a JSON-serializable dict consumable by the Commerce API. """ # Grab the verified seat seat = sorted(self.course.seat_products, key=lambda p: getattr(p.attr, 'certificate_type', ''))[1] stock_record = seat.stockrecords.first() actual = self.publisher.serialize_seat_for_commerce_api(seat) expected = { 'name': 'verified', 'currency': 'USD', 'price': int(stock_record.price_excl_tax), 'sku': stock_record.partner_sku, 'expires': None } self.assertDictEqual(actual, expected) # Try with an expiration datetime expires = datetime.datetime.utcnow() seat.expires = expires expected['expires'] = expires.isoformat() actual = self.publisher.serialize_seat_for_commerce_api(seat) self.assertDictEqual(actual, expected) @ddt.unpack @ddt.data( (True, 'professional'), (False, 'no-id-professional'), ) def test_serialize_seat_for_commerce_api_with_professional(self, is_verified, expected_mode): """ Verify that (a) professional seats NEVER have an expiration date and (b) the name/mode is properly set for no-id-professional seats. """ seat = self.course.create_or_update_seat( 'professional', is_verified, 500, self.partner, expires=datetime.datetime.utcnow() ) stock_record = seat.stockrecords.first() actual = self.publisher.serialize_seat_for_commerce_api(seat) expected = { 'name': expected_mode, 'currency': 'USD', 'price': int(stock_record.price_excl_tax), 'sku': stock_record.partner_sku, 'expires': None } self.assertDictEqual(actual, expected) def attempt_credit_publication(self, api_status): """ Sets up a credit seat and attempts to publish it to LMS. Returns String - Publish error message. """ # Setup the course and mock the API endpoints self.course.create_or_update_seat('credit', True, 100, self.partner, credit_provider='acme', credit_hours=1) self.mock_creditcourse_endpoint(self.course.id, api_status) self._mock_commerce_api(201) # Attempt to publish the course return self.publisher.publish(self.course, access_token='access_token') def assert_creditcourse_endpoint_called(self): """ Verify the Credit API's CreditCourse endpoint was called. """ last_request = httpretty.httpretty.latest_requests[0] self.assertEqual(last_request.path, '/api/credit/v1/courses/{}/'.format(self.course.id)) @httpretty.activate def test_credit_publication_success(self): """ Verify the endpoint returns successfully when credit publication succeeds. """ error_message = self.attempt_credit_publication(201) self.assertIsNone(error_message) self.assert_creditcourse_endpoint_called() @httpretty.activate def test_credit_publication_api_failure(self): """ Verify the endpoint fails appropriately when Credit API calls return an error. """ course_id = self.course.id with LogCapture(LOGGER_NAME) as l: status = 400 actual = self.attempt_credit_publication(status) # Ensure the HTTP status and response are logged expected_log = 'Failed to publish CreditCourse for [{course_id}] to LMS. ' \ 'Status was [{status}]. Body was \'null\'.'.format(course_id=course_id, status=status) l.check((LOGGER_NAME, 'ERROR', expected_log)) expected = 'Failed to publish commerce data for {} to LMS.'.format(course_id) self.assertEqual(actual, expected) self.assert_creditcourse_endpoint_called() @httpretty.activate @mock.patch('requests.get', mock.Mock(side_effect=Exception)) def test_credit_publication_uncaught_exception(self): """ Verify the endpoint fails appropriately when the Credit API fails unexpectedly. """ actual = self.attempt_credit_publication(500) expected = 'Failed to publish commerce data for {} to LMS.'.format(self.course.id) self.assertEqual(actual, expected) def assert_response_message(self, api_response, expected_error_msg): self.assertIsNotNone(api_response) if expected_error_msg: self.assertEqual(api_response, " ".join([self.error_message, expected_error_msg])) else: self.assertEqual(api_response, self.error_message, expected_error_msg)
class LMSPublisherTests(DiscoveryTestMixin, TestCase): def setUp(self): super(LMSPublisherTests, self).setUp() self.mock_access_token_response() self.course = CourseFactory(verification_deadline=timezone.now() + datetime.timedelta(days=7), partner=self.partner) self.course.create_or_update_seat('honor', False, 0) self.course.create_or_update_seat('verified', True, 50) self.publisher = LMSPublisher() self.error_message = 'Failed to publish commerce data for {course_id} to LMS.'.format( course_id=self.course.id) def tearDown(self): super().tearDown() responses.stop() responses.reset() def _mock_commerce_api(self, status=200, body=None): body = body or {} url = self.site_configuration.build_lms_url( '/api/commerce/v1/courses/{}/'.format(self.course.id)) responses.add(responses.PUT, url, status=status, json=body, content_type=JSON) def mock_creditcourse_endpoint(self, course_id, status, body=None): url = get_lms_url('/api/credit/v1/courses/{}/'.format(course_id)) responses.add(responses.PUT, url, status=status, json=body, content_type=JSON) def test_api_exception(self): """ If an exception is raised when communicating with the Commerce API, an ERROR message should be logged. """ error = 'time out error' with mock.patch('requests.put', side_effect=Timeout(error)): with LogCapture(LOGGER_NAME) as logger: actual = self.publisher.publish(self.course) logger.check(( LOGGER_NAME, 'ERROR', f"Failed to publish commerce data for [{self.course.id}] to LMS." )) self.assertEqual(actual, self.error_message) @responses.activate def test_api_error(self): """ If the Commerce API returns a non-successful status, an ERROR message should be logged. """ status = 400 self._mock_commerce_api(status) with LogCapture(LOGGER_NAME) as logger: actual = self.publisher.publish(self.course) url = urljoin(f"{self.site.siteconfiguration.commerce_api_url}/", f"courses/{self.course.id}/") logger.check((LOGGER_NAME, 'ERROR', ( f"Failed to publish commerce data for [{self.course.id}] to LMS. Error was {status} " f"Client Error: Bad Request for url: {url}."))) self.assertEqual(actual, self.error_message) @responses.activate def test_api_success(self): """ If the Commerce API returns a successful status, an INFO message should be logged. """ self._mock_commerce_api() with LogCapture(LOGGER_NAME) as logger: response = self.publisher.publish(self.course) self.assertIsNone(response) logger.check( (LOGGER_NAME, 'INFO', 'Successfully published commerce data for [{}].'.format( self.course.id))) last_request = responses.calls[-1].request # Verify the data passed to the API was correct. actual = json.loads(last_request.body.decode('utf-8')) expected = { 'id': self.course.id, 'name': self.course.name, 'verification_deadline': self.course.verification_deadline.isoformat(), 'modes': [ self.publisher.serialize_seat_for_commerce_api(seat) for seat in self.course.seat_products ] } self.assertDictEqual(actual, expected) def test_serialize_seat_for_commerce_api(self): """ The method should convert a seat to a JSON-serializable dict consumable by the Commerce API. """ # Grab the verified seat seat = sorted(self.course.seat_products, key=lambda p: getattr(p.attr, 'certificate_type', ''))[1] stock_record = seat.stockrecords.first() actual = self.publisher.serialize_seat_for_commerce_api(seat) expected = { 'name': 'verified', 'currency': 'USD', 'price': int(stock_record.price_excl_tax), 'sku': stock_record.partner_sku, 'bulk_sku': None, 'expires': None, } self.assertDictEqual(actual, expected) # Try with an expiration datetime expires = datetime.datetime.utcnow() seat.expires = expires expected['expires'] = expires.isoformat() actual = self.publisher.serialize_seat_for_commerce_api(seat) self.assertDictEqual(actual, expected) @ddt.unpack @ddt.data( (True, 'professional'), (False, 'no-id-professional'), ) def test_serialize_seat_for_commerce_api_with_professional( self, is_verified, expected_mode): """ Verify that (a) professional seats NEVER have an expiration date and (b) the name/mode is properly set for no-id-professional seats. """ seat = self.course.create_or_update_seat( 'professional', is_verified, 500, expires=datetime.datetime.utcnow()) stock_record = seat.stockrecords.first() actual = self.publisher.serialize_seat_for_commerce_api(seat) expected = { 'name': expected_mode, 'currency': 'USD', 'price': int(stock_record.price_excl_tax), 'sku': stock_record.partner_sku, 'bulk_sku': None, 'expires': None, } self.assertDictEqual(actual, expected) def test_serialize_seat_with_enrollment_code(self): seat = self.course.create_or_update_seat('verified', False, 10, create_enrollment_code=True) stock_record = seat.stockrecords.first() ec_stock_record = StockRecord.objects.get( product__product_class__name=ENROLLMENT_CODE_PRODUCT_CLASS_NAME) actual = self.publisher.serialize_seat_for_commerce_api(seat) expected = { 'name': 'verified', 'currency': 'USD', 'price': int(stock_record.price_excl_tax), 'sku': stock_record.partner_sku, 'bulk_sku': ec_stock_record.partner_sku, 'expires': None, } self.assertDictEqual(actual, expected) def attempt_credit_publication(self, api_status): """ Sets up a credit seat and attempts to publish it to LMS. Returns String - Publish error message. """ # Setup the course and mock the API endpoints self.course.create_or_update_seat('credit', True, 100, credit_provider='acme', credit_hours=1) self.mock_creditcourse_endpoint(self.course.id, api_status) self._mock_commerce_api(201) # Attempt to publish the course return self.publisher.publish(self.course) def assert_creditcourse_endpoint_called(self): """ Verify the Credit API's CreditCourse endpoint was called. """ paths = [call.request.path_url for call in responses.calls] self.assertIn('/api/credit/v1/courses/{}/'.format(self.course.id), paths) @responses.activate def test_credit_publication_success(self): """ Verify the endpoint returns successfully when credit publication succeeds. """ error_message = self.attempt_credit_publication(201) self.assertIsNone(error_message) self.assert_creditcourse_endpoint_called() @responses.activate def test_credit_publication_api_failure(self): """ Verify the endpoint fails appropriately when Credit API calls return an error. """ course_id = self.course.id url = urljoin(f"{self.site.siteconfiguration.credit_api_url}/", f"courses/{self.course.id}/") with LogCapture(LOGGER_NAME) as logger: status = 400 actual = self.attempt_credit_publication(status) expected_log = ( f"Failed to publish CreditCourse for [{self.course.id}] to LMS. Error was {status} Client Error: " f"Bad Request for url: {url}.") logger.check((LOGGER_NAME, 'ERROR', expected_log)) expected = 'Failed to publish commerce data for {} to LMS.'.format( course_id) self.assertEqual(actual, expected) self.assert_creditcourse_endpoint_called() @mock.patch('requests.get', mock.Mock(side_effect=Exception)) def test_credit_publication_uncaught_exception(self): """ Verify the endpoint fails appropriately when the Credit API fails unexpectedly. """ actual = self.attempt_credit_publication(500) expected = 'Failed to publish commerce data for {} to LMS.'.format( self.course.id) self.assertEqual(actual, expected)