示例#1
0
class EdxOrderPlacementMixinTests(BusinessIntelligenceMixin,
                                  PaymentEventsMixin, RefundTestMixin,
                                  TransactionTestCase):
    """
    Tests validating generic behaviors of the EdxOrderPlacementMixin.
    """
    def setUp(self):
        super(EdxOrderPlacementMixinTests, self).setUp()
        self.user = UserFactory(lms_user_id=61710)
        self.order = self.create_order(status=ORDER.OPEN)

        # Ensure that the basket attribute type exists for these tests
        self.basket_attribute_type, _ = BasketAttributeType.objects.get_or_create(
            name=EMAIL_OPT_IN_ATTRIBUTE)

    def test_handle_payment_logging(self, __):
        """
        Ensure that we emit a log entry upon receipt of a payment notification, and create Source and PaymentEvent
        objects.
        """
        basket = create_basket(owner=self.user, site=self.site)

        mixin = EdxOrderPlacementMixin()
        mixin.payment_processor = DummyProcessor(self.site)
        processor_name = DummyProcessor.NAME
        total = basket.total_incl_tax
        reference = basket.id

        with LogCapture(LOGGER_NAME) as logger:
            mixin.handle_payment({}, basket)
            logger.check_present((
                LOGGER_NAME, 'INFO',
                'payment_received: amount="{}", basket_id="{}", currency="{}", '
                'processor_name="{}", reference="{}", user_id="{}"'.format(
                    total, basket.id, basket.currency, processor_name,
                    reference, self.user.id)))

        # pylint: disable=protected-access

        # Validate a payment Source was created
        source_type = SourceType.objects.get(code=processor_name)
        label = self.user.username
        self.assert_basket_matches_source(basket, mixin._payment_sources[-1],
                                          source_type, reference, label)

        # Validate the PaymentEvent was created
        paid_type = PaymentEventType.objects.get(code='paid')
        self.assert_valid_payment_event_fields(mixin._payment_events[-1],
                                               total, paid_type,
                                               processor_name, reference)

    def test_order_number_collision(self, _mock_track):
        """
        Verify that an attempt to create an order with the same number as an existing
        order causes an exception to be raised.
        """
        order_placement_mixin = EdxOrderPlacementMixin()

        basket = self.order.basket

        shipping_method = NoShippingRequired()
        shipping_charge = shipping_method.calculate(basket)

        order_total = OrderTotalCalculator().calculate(basket, shipping_charge)

        with self.assertRaises(ValueError):
            order_placement_mixin.handle_order_placement(
                self.order.number,
                self.user,
                basket,
                None,
                shipping_method,
                shipping_charge,
                None,
                order_total,
            )

    def test_handle_successful_order(self, mock_track):
        """
        Ensure that tracking events are fired with correct content when order
        placement event handling is invoked.
        """
        tracking_context = {
            'ga_client_id': 'test-client-id',
            'lms_user_id': 'test-user-id',
            'lms_ip': '127.0.0.1'
        }
        self.user.tracking_context = tracking_context
        self.user.save()

        with LogCapture(LOGGER_NAME) as logger:
            EdxOrderPlacementMixin().handle_successful_order(self.order)
            # ensure event is being tracked
            self.assertTrue(mock_track.called)
            # ensure event data is correct
            self.assert_correct_event(
                mock_track,
                self.order,
                self.user.lms_user_id,
                tracking_context['ga_client_id'],
                tracking_context['lms_ip'],
                self.order.number,
                self.order.currency,
                self.order.user.email,
                self.order.total_excl_tax,
                self.order.
                total_excl_tax,  # value for revenue field is same as total.
                check_traits=True,
            )
            logger.check_present((
                LOGGER_NAME, 'INFO',
                'order_placed: amount="{}", basket_id="{}", contains_coupon="{}", currency="{}",'
                ' order_number="{}", user_id="{}"'.format(
                    self.order.total_excl_tax, self.order.basket.id,
                    self.order.contains_coupon, self.order.currency,
                    self.order.number, self.order.user.id)))

    def test_handle_post_order_for_bulk_purchase(self, __):
        """
        Ensure that the bulk purchase order is linked to the provided business
        client when the method `handle_post_order` is invoked.
        """
        toggle_switch(ENROLLMENT_CODE_SWITCH, True)

        course = CourseFactory(partner=self.partner)
        course.create_or_update_seat('verified',
                                     True,
                                     50,
                                     create_enrollment_code=True)
        enrollment_code = Product.objects.get(
            product_class__name=ENROLLMENT_CODE_PRODUCT_CLASS_NAME)
        user = UserFactory()
        basket = BasketFactory(owner=user, site=self.site)
        basket.add_product(enrollment_code, quantity=1)
        order = create_order(number=1, basket=basket, user=user)
        request_data = {
            'organization': 'Dummy Business Client',
            PURCHASER_BEHALF_ATTRIBUTE: 'False',
        }
        # Manually add organization and purchaser attributes on the basket for testing
        basket_add_organization_attribute(basket, request_data)

        EdxOrderPlacementMixin().handle_post_order(order)

        # Now verify that a new business client has been created in current
        # order is now linked with that client through Invoice model.
        business_client = BusinessClient.objects.get(
            name=request_data['organization'])
        assert Invoice.objects.get(
            order=order).business_client == business_client

    def test_handle_post_order_for_seat_purchase(self, __):
        """
        Ensure that the single seat purchase order is not linked any business
        client when the method `handle_post_order` is invoked.
        """
        toggle_switch(ENROLLMENT_CODE_SWITCH, False)

        course = CourseFactory(partner=self.partner)
        verified_product = course.create_or_update_seat('verified', True, 50)
        user = UserFactory()
        basket = BasketFactory(owner=user, site=self.site)
        basket.add_product(verified_product, quantity=1)
        order = create_order(number=1, basket=basket, user=user)
        request_data = {
            'organization': 'Dummy Business Client',
            PURCHASER_BEHALF_ATTRIBUTE: 'False',
        }
        # Manually add organization and purchaser attributes on the basket for testing
        basket_add_organization_attribute(basket, request_data)

        EdxOrderPlacementMixin().handle_post_order(order)

        # Now verify that the single seat order is not linked to business
        # client by checking that there is no record for BusinessClient.
        assert not BusinessClient.objects.all()

    def test_handle_successful_order_no_context(self, mock_track):
        """
        Ensure that expected values are substituted when no tracking_context
        was available.
        """
        EdxOrderPlacementMixin().handle_successful_order(self.order)
        # ensure event is being tracked
        self.assertTrue(mock_track.called)
        # ensure event data is correct
        self.assert_correct_event(
            mock_track,
            self.order,
            self.user.lms_user_id,
            None,
            None,
            self.order.number,
            self.order.currency,
            self.order.user.email,
            self.order.total_excl_tax,
            self.order.
            total_excl_tax,  # value for revenue field is same as total.
            check_traits=True,
        )

    def test_order_no_lms_user_id(self, mock_track):
        """
        Ensure that expected values are substituted when no LMS user id
        was available.
        """
        tracking_context = {
            'ga_client_id': 'test-client-id',
            'lms_user_id': 'test-user-id',
            'lms_ip': '127.0.0.1'
        }
        self.user.tracking_context = tracking_context
        self.user.lms_user_id = None
        self.user.save()

        EdxOrderPlacementMixin().handle_successful_order(self.order)
        # ensure event is being tracked
        self.assertTrue(mock_track.called)
        # ensure event data is correct
        self.assert_correct_event(
            mock_track,
            self.order,
            ECOM_TRACKING_ID_FMT.format(self.user.id),
            tracking_context['ga_client_id'],
            tracking_context['lms_ip'],
            self.order.number,
            self.order.currency,
            self.order.user.email,
            self.order.total_excl_tax,
            self.order.
            total_excl_tax,  # value for revenue field is same as total.
            check_traits=True,
        )

    def test_handle_successful_order_no_segment_key(self, mock_track):
        """
        Ensure that tracking events do not fire when there is no Segment key
        configured.
        """
        self.site.siteconfiguration.segment_key = None
        EdxOrderPlacementMixin().handle_successful_order(self.order)
        # ensure no event was fired
        self.assertFalse(mock_track.called)

    def test_handle_successful_order_segment_error(self, mock_track):
        """
        Ensure that exceptions raised while emitting tracking events are
        logged, but do not otherwise interrupt program flow.
        """
        with mock.patch('ecommerce.extensions.analytics.utils.logger.exception'
                        ) as mock_log_exc:
            mock_track.side_effect = Exception("clunk")
            EdxOrderPlacementMixin().handle_successful_order(self.order)
        # ensure that analytics.track was called, but the exception was caught
        self.assertTrue(mock_track.called)
        # ensure we logged a warning.
        self.assertTrue(
            mock_log_exc.called_with(
                "Failed to emit tracking event upon order placement."))

    def test_handle_successful_async_order(self, __):
        """
        Verify that a Waffle Sample can be used to control async order fulfillment.
        """
        sample, created = Sample.objects.get_or_create(
            name='async_order_fulfillment',
            defaults={
                'percent':
                100.0,
                'note':
                'Determines what percentage of orders are fulfilled asynchronously.',
            })

        if not created:
            sample.percent = 100.0
            sample.save()

        with mock.patch(
                'ecommerce.extensions.checkout.mixins.fulfill_order.delay'
        ) as mock_delay:
            EdxOrderPlacementMixin().handle_successful_order(self.order)
            mock_delay.assert_called_once_with(
                self.order.number,
                site_code=self.partner.short_code,
                email_opt_in=False)

    def test_handle_successful_order_no_email_opt_in(self, _):
        """
        Verify that the post checkout defaults email_opt_in to false.
        """
        with mock.patch(
                'ecommerce.extensions.checkout.mixins.post_checkout.send'
        ) as mock_send:
            mixin = EdxOrderPlacementMixin()
            mixin.handle_successful_order(self.order)
            send_arguments = {
                'sender': mixin,
                'order': self.order,
                'request': None,
                'email_opt_in': False
            }
            mock_send.assert_called_once_with(**send_arguments)

    @ddt.data(True, False)
    def test_handle_successful_order_with_email_opt_in(self, expected_opt_in,
                                                       _):
        """
        Verify that the post checkout sets email_opt_in if it is given.
        """
        BasketAttribute.objects.get_or_create(
            basket=self.order.basket,
            attribute_type=self.basket_attribute_type,
            value_text=expected_opt_in,
        )

        with mock.patch(
                'ecommerce.extensions.checkout.mixins.post_checkout.send'
        ) as mock_send:
            mixin = EdxOrderPlacementMixin()
            mixin.handle_successful_order(self.order)
            send_arguments = {
                'sender': mixin,
                'order': self.order,
                'request': None,
                'email_opt_in': expected_opt_in,
            }
            mock_send.assert_called_once_with(**send_arguments)

    def test_place_free_order(self, __):
        """ Verify an order is placed and the basket is submitted. """
        basket = create_basket(empty=True)
        basket.add_product(ProductFactory(stockrecords__price_excl_tax=0))
        order = EdxOrderPlacementMixin().place_free_order(basket)

        self.assertIsNotNone(order)
        self.assertEqual(basket.status, Basket.SUBMITTED)

    def test_non_free_basket_order(self, __):
        """ Verify an error is raised for non-free basket. """
        basket = create_basket(empty=True)
        basket.add_product(ProductFactory(stockrecords__price_excl_tax=10))

        with self.assertRaises(BasketNotFreeError):
            EdxOrderPlacementMixin().place_free_order(basket)

    def test_valid_payment_segment_logging(self, mock_track):
        """
        Verify the "Payment Info Entered" Segment event is fired after payment info is validated
        """
        tracking_context = {
            'ga_client_id': 'test-client-id',
            'lms_user_id': 'test-user-id',
            'lms_ip': '127.0.0.1'
        }
        self.user.tracking_context = tracking_context
        self.user.save()

        basket = create_basket(owner=self.user, site=self.site)

        mixin = EdxOrderPlacementMixin()
        mixin.payment_processor = DummyProcessor(self.site)

        user_tracking_id, ga_client_id, lms_ip = parse_tracking_context(
            self.user)
        context = {
            'ip': lms_ip,
            'Google Analytics': {
                'clientId': ga_client_id
            },
            'page': {
                'url': 'https://testserver.fake/'
            },
        }

        mixin.handle_payment({}, basket)

        # Verify the correct events are fired to Segment
        calls = []

        properties = translate_basket_line_for_segment(basket.lines.first())
        properties['cart_id'] = basket.id
        calls.append(
            mock.call(user_tracking_id,
                      'Product Added',
                      properties,
                      context=context))

        properties = {
            'checkout_id': basket.order_number,
            'step': 1,
            'payment_method': 'Visa | ' + DummyProcessor.NAME,
        }
        calls.append(
            mock.call(user_tracking_id,
                      'Checkout Step Completed',
                      properties,
                      context=context))
        properties['step'] = 2
        calls.append(
            mock.call(user_tracking_id,
                      'Checkout Step Viewed',
                      properties,
                      context=context))
        calls.append(
            mock.call(user_tracking_id,
                      'Checkout Step Completed',
                      properties,
                      context=context))

        properties = {'checkout_id': basket.order_number}
        calls.append(
            mock.call(user_tracking_id,
                      'Payment Info Entered',
                      properties,
                      context=context))

        properties = {
            'basket_id': basket.id,
            'total': basket.total_incl_tax,
            'success': True,
            'processor_name': DummyProcessor.NAME,
        }
        calls.append(
            mock.call(user_tracking_id,
                      'Payment Processor Response',
                      properties,
                      context=context))

        mock_track.assert_has_calls(calls)

    @mock.patch.object(DummyProcessor, 'handle_processor_response',
                       mock.Mock(side_effect=Exception))
    def test_payment_not_accepted_segment_logging(self, mock_track):
        """
        Verify if the payment is not accepted, we still log the processor response
        """
        tracking_context = {
            'ga_client_id': 'test-client-id',
            'lms_user_id': 'test-user-id',
            'lms_ip': '127.0.0.1'
        }
        self.user.tracking_context = tracking_context
        self.user.save()

        basket = create_basket(owner=self.user, site=self.site)

        mixin = EdxOrderPlacementMixin()
        mixin.payment_processor = DummyProcessor(self.site)

        user_tracking_id, ga_client_id, lms_ip = parse_tracking_context(
            self.user)
        context = {
            'ip': lms_ip,
            'Google Analytics': {
                'clientId': ga_client_id
            },
            'page': {
                'url': 'https://testserver.fake/'
            },
        }
        with self.assertRaises(Exception):
            mixin.handle_payment({}, basket)

        # Verify the correct events are fired to Segment
        calls = []

        properties = translate_basket_line_for_segment(basket.lines.first())
        properties['cart_id'] = basket.id
        calls.append(
            mock.call(user_tracking_id,
                      'Product Added',
                      properties,
                      context=context))

        properties = {
            'basket_id': basket.id,
            'payment_error': 'Exception',
            'success': False,
            'processor_name': DummyProcessor.NAME,
        }
        calls.append(
            mock.call(user_tracking_id,
                      'Payment Processor Response',
                      properties,
                      context=context))

        mock_track.assert_has_calls(calls)

    def test_update_assigned_voucher_offer_assignment(self, __):
        """
        Verify the "update_assigned_voucher_offer_assignment" works as expected.
        """
        enterprise_offer = EnterpriseOfferFactory()
        voucher = VoucherFactory()
        voucher.offers.add(enterprise_offer)
        basket = create_basket(owner=self.user, site=self.site)
        basket.vouchers.add(voucher)
        order = create_order(user=self.user, basket=basket)
        voucher_application = VoucherApplication.objects.create(
            voucher=voucher, user=self.user, order=order)
        offer_assignment = OfferAssignmentFactory(offer=enterprise_offer,
                                                  code=voucher.code,
                                                  user_email=self.user.email)

        # create nudge email templates and subscription records
        for email_type in (DAY3, DAY10, DAY19):
            nudge_email_template = CodeAssignmentNudgeEmailTemplatesFactory(
                email_type=email_type)
            nudge_email = CodeAssignmentNudgeEmailsFactory(
                email_template=nudge_email_template,
                user_email=self.user.email,
                code=voucher.code)

            # verify subscription is active
            assert nudge_email.is_subscribed

        EdxOrderPlacementMixin().update_assigned_voucher_offer_assignment(
            order)

        offer_assignment = OfferAssignment.objects.get(id=offer_assignment.id)
        assert offer_assignment.status == OFFER_REDEEMED
        assert offer_assignment.voucher_application == voucher_application

        # verify that nudge emails subscriptions are inactive
        assert CodeAssignmentNudgeEmails.objects.filter(
            is_subscribed=True).count() == 0
        assert CodeAssignmentNudgeEmails.objects.filter(
            code__in=[voucher.code],
            user_email__in=[self.user.email],
            is_subscribed=False).count() == 3

    def test_create_assignments_for_multi_use_per_customer(self, __):
        """
        Verify the `create_assignments_for_multi_use_per_customer` works as expected for `MULTI_USE_PER_CUSTOMER`.
        """
        coupon_max_global_applications = 10
        enterprise_offer = EnterpriseOfferFactory(
            max_global_applications=coupon_max_global_applications)
        voucher = VoucherFactory(usage=Voucher.MULTI_USE_PER_CUSTOMER)
        voucher.offers.add(enterprise_offer)
        basket = create_basket(owner=self.user, site=self.site)
        basket.vouchers.add(voucher)
        order = create_order(user=self.user, basket=basket)

        assert OfferAssignment.objects.all().count() == 0

        EdxOrderPlacementMixin().create_assignments_for_multi_use_per_customer(
            order)
        EdxOrderPlacementMixin().update_assigned_voucher_offer_assignment(
            order)

        assert OfferAssignment.objects.all().count(
        ) == coupon_max_global_applications
        assert OfferAssignment.objects.filter(
            offer=enterprise_offer,
            code=voucher.code,
            user_email=basket.owner.email,
            status=OFFER_ASSIGNED).count() == 9
        assert OfferAssignment.objects.filter(
            offer=enterprise_offer,
            code=voucher.code,
            user_email=basket.owner.email,
            status=OFFER_REDEEMED).count() == 1

    def test_create_offer_assignments_for_updated_max_uses(self, __):
        """
        Verify the `create_assignments_for_multi_use_per_customer` works as expected for
        `MULTI_USE_PER_CUSTOMER` when `max_global_applications` is updated for existing voucher.
        """
        coupon_max_global_applications = 1
        enterprise_offer = EnterpriseOfferFactory(
            max_global_applications=coupon_max_global_applications)
        voucher = VoucherFactory(usage=Voucher.MULTI_USE_PER_CUSTOMER)
        voucher.offers.add(enterprise_offer)
        basket = create_basket(owner=self.user, site=self.site)
        basket.vouchers.add(voucher)
        order = create_order(user=self.user, basket=basket)

        assert OfferAssignment.objects.all().count() == 0

        EdxOrderPlacementMixin().create_assignments_for_multi_use_per_customer(
            order)
        EdxOrderPlacementMixin().update_assigned_voucher_offer_assignment(
            order)

        assert OfferAssignment.objects.all().count(
        ) == coupon_max_global_applications
        assert OfferAssignment.objects.filter(
            offer=enterprise_offer,
            code=voucher.code,
            user_email=basket.owner.email,
            status=OFFER_REDEEMED).count() == 1

        # update max_global_applications
        coupon_new_max_global_applications = 5
        enterprise_offer.max_global_applications = coupon_new_max_global_applications
        enterprise_offer.save()

        assert voucher.enterprise_offer.max_global_applications == coupon_new_max_global_applications

        EdxOrderPlacementMixin().create_assignments_for_multi_use_per_customer(
            order)

        assert OfferAssignment.objects.all().count(
        ) == coupon_new_max_global_applications
        assert OfferAssignment.objects.filter(
            offer=enterprise_offer,
            code=voucher.code,
            user_email=basket.owner.email,
            status=OFFER_ASSIGNED).count() == 4
        assert OfferAssignment.objects.filter(
            offer=enterprise_offer,
            code=voucher.code,
            user_email=basket.owner.email,
            status=OFFER_REDEEMED).count() == 1

        # call once again to verify nothing is created because all available slots are assigned
        EdxOrderPlacementMixin().create_assignments_for_multi_use_per_customer(
            order)
        assert OfferAssignment.objects.all().count(
        ) == coupon_new_max_global_applications
示例#2
0
class EnrollmentFulfillmentModuleTests(ProgramTestMixin, DiscoveryTestMixin,
                                       FulfillmentTestMixin, TestCase):
    """Test course seat fulfillment."""

    course_id = 'edX/DemoX/Demo_Course'
    certificate_type = 'test-certificate-type'
    provider = None

    def setUp(self):
        super(EnrollmentFulfillmentModuleTests, self).setUp()

        self.user = UserFactory()
        self.user.tracking_context = {
            'ga_client_id': 'test-client-id',
            'lms_user_id': 'test-user-id',
            'lms_ip': '127.0.0.1'
        }
        self.user.save()
        self.course = CourseFactory(id=self.course_id,
                                    name='Demo Course',
                                    partner=self.partner)

        self.seat = self.course.create_or_update_seat(self.certificate_type,
                                                      False, 100,
                                                      self.provider)

        basket = factories.BasketFactory(owner=self.user, site=self.site)
        basket.add_product(self.seat, 1)
        self.order = create_order(number=1, basket=basket, user=self.user)

    # pylint: disable=attribute-defined-outside-init
    def create_seat_and_order(self,
                              certificate_type='test-certificate-type',
                              provider=None):
        """ Create the certificate of given type and seat of given provider.

        Arguments:
            certificate_type(str): The type of certificate
            provider(str): The provider ID.
        Returns:
            None
        """
        self.certificate_type = certificate_type
        self.provider = provider
        self.seat = self.course.create_or_update_seat(self.certificate_type,
                                                      False, 100,
                                                      self.provider)

        basket = factories.BasketFactory(owner=self.user, site=self.site)
        basket.add_product(self.seat, 1)
        self.order = create_order(number=2, basket=basket, user=self.user)

    def prepare_basket_with_voucher(self, program_uuid=None):
        catalog = Catalog.objects.create(partner=self.partner)

        coupon_product_class, _ = ProductClass.objects.get_or_create(
            name=COUPON_PRODUCT_CLASS_NAME)
        coupon = factories.create_product(product_class=coupon_product_class,
                                          title='Test product')

        stock_record = StockRecord.objects.filter(product=self.seat).first()
        catalog.stock_records.add(stock_record)

        vouchers = create_vouchers(
            benefit_type=Benefit.PERCENTAGE,
            benefit_value=100.00,
            catalog=catalog,
            coupon=coupon,
            end_datetime=datetime.datetime.now() + datetime.timedelta(days=30),
            enterprise_customer=None,
            enterprise_customer_catalog=None,
            name="Test Voucher",
            quantity=10,
            start_datetime=datetime.datetime.now(),
            voucher_type=Voucher.SINGLE_USE,
            program_uuid=program_uuid,
        )
        Applicator().apply_offers(self.order.basket, vouchers[0].offers.all())

    def test_enrollment_module_support(self):
        """Test that we get the correct values back for supported product lines."""
        supported_lines = EnrollmentFulfillmentModule().get_supported_lines(
            list(self.order.lines.all()))
        self.assertEqual(1, len(supported_lines))

    @httpretty.activate
    def test_enrollment_module_fulfill(self):
        """Happy path test to ensure we can properly fulfill enrollments."""
        httpretty.register_uri(httpretty.POST,
                               get_lms_enrollment_api_url(),
                               status=200,
                               body='{}',
                               content_type=JSON)
        # Attempt to enroll.
        with LogCapture(LOGGER_NAME) as logger:
            EnrollmentFulfillmentModule().fulfill_product(
                self.order, list(self.order.lines.all()))

            line = self.order.lines.get()
            logger.check_present((
                LOGGER_NAME, 'INFO',
                'line_fulfilled: course_id="{}", credit_provider="{}", mode="{}", order_line_id="{}", '
                'order_number="{}", product_class="{}", user_id="{}"'.format(
                    line.product.attr.course_key,
                    None,
                    mode_for_product(line.product),
                    line.id,
                    line.order.number,
                    line.product.get_product_class().name,
                    line.order.user.id,
                )))

        self.assertEqual(LINE.COMPLETE, line.status)

        last_request = httpretty.last_request()
        actual_body = json.loads(last_request.body)
        actual_headers = last_request.headers

        expected_body = {
            'user':
            self.order.user.username,
            'is_active':
            True,
            'mode':
            self.certificate_type,
            'course_details': {
                'course_id': self.course_id,
            },
            'enrollment_attributes': [{
                'namespace': 'order',
                'name': 'order_number',
                'value': self.order.number
            }]
        }

        expected_headers = {
            'X-Edx-Ga-Client-Id': self.user.tracking_context['ga_client_id'],
            'X-Forwarded-For': self.user.tracking_context['lms_ip'],
        }

        self.assertDictContainsSubset(expected_headers, actual_headers)
        self.assertEqual(expected_body, actual_body)

    @httpretty.activate
    def test_enrollment_module_fulfill_order_with_discount_no_voucher(self):
        """
        Test that components of the Fulfillment Module which trigger on the presence of a voucher do
        not cause failures in cases where a discount does not have a voucher included
        (such as with a Conditional Offer)
        """
        httpretty.register_uri(httpretty.POST,
                               get_lms_enrollment_api_url(),
                               status=200,
                               body='{}',
                               content_type=JSON)
        self.create_seat_and_order(certificate_type='credit', provider='MIT')
        self.order.discounts.create()
        __, lines = EnrollmentFulfillmentModule().fulfill_product(
            self.order, list(self.order.lines.all()))
        # No exceptions should be raised and the order should be fulfilled
        self.assertEqual(lines[0].status, 'Complete')

    @override_settings(EDX_API_KEY=None)
    def test_enrollment_module_not_configured(self):
        """Test that lines receive a configuration error status if fulfillment configuration is invalid."""
        EnrollmentFulfillmentModule().fulfill_product(
            self.order, list(self.order.lines.all()))
        self.assertEqual(LINE.FULFILLMENT_CONFIGURATION_ERROR,
                         self.order.lines.all()[0].status)

    def test_enrollment_module_fulfill_bad_attributes(self):
        """Test that use of the Fulfillment Module fails when the product does not have attributes."""
        ProductAttribute.objects.get(
            product_class__name=SEAT_PRODUCT_CLASS_NAME,
            code='course_key').delete()
        EnrollmentFulfillmentModule().fulfill_product(
            self.order, list(self.order.lines.all()))
        self.assertEqual(LINE.FULFILLMENT_CONFIGURATION_ERROR,
                         self.order.lines.all()[0].status)

    @mock.patch('requests.post', mock.Mock(side_effect=ConnectionError))
    def test_enrollment_module_network_error(self):
        """Test that lines receive a network error status if a fulfillment request experiences a network error."""
        EnrollmentFulfillmentModule().fulfill_product(
            self.order, list(self.order.lines.all()))
        self.assertEqual(LINE.FULFILLMENT_NETWORK_ERROR,
                         self.order.lines.all()[0].status)

    @mock.patch('requests.post', mock.Mock(side_effect=Timeout))
    def test_enrollment_module_request_timeout(self):
        """Test that lines receive a timeout error status if a fulfillment request times out."""
        EnrollmentFulfillmentModule().fulfill_product(
            self.order, list(self.order.lines.all()))
        self.assertEqual(LINE.FULFILLMENT_TIMEOUT_ERROR,
                         self.order.lines.all()[0].status)

    @httpretty.activate
    @ddt.data(None, '{"message": "Oops!"}')
    def test_enrollment_module_server_error(self, body):
        """Test that lines receive a server-side error status if a server-side error occurs during fulfillment."""
        # NOTE: We are testing for cases where the response does and does NOT have data. The module should be able
        # to handle both cases.
        httpretty.register_uri(httpretty.POST,
                               get_lms_enrollment_api_url(),
                               status=500,
                               body=body,
                               content_type=JSON)
        EnrollmentFulfillmentModule().fulfill_product(
            self.order, list(self.order.lines.all()))
        self.assertEqual(LINE.FULFILLMENT_SERVER_ERROR,
                         self.order.lines.all()[0].status)

    @httpretty.activate
    def test_revoke_product(self):
        """ The method should call the Enrollment API to un-enroll the student, and return True. """
        httpretty.register_uri(httpretty.POST,
                               get_lms_enrollment_api_url(),
                               status=200,
                               body='{}',
                               content_type=JSON)
        line = self.order.lines.first()

        with LogCapture(LOGGER_NAME) as logger:
            self.assertTrue(EnrollmentFulfillmentModule().revoke_line(line))

            logger.check_present((
                LOGGER_NAME, 'INFO',
                'line_revoked: certificate_type="{}", course_id="{}", order_line_id="{}", order_number="{}", '
                'product_class="{}", user_id="{}"'.format(
                    getattr(line.product.attr, 'certificate_type', ''),
                    line.product.attr.course_key, line.id, line.order.number,
                    line.product.get_product_class().name,
                    line.order.user.id)))

        last_request = httpretty.last_request()
        actual_body = json.loads(last_request.body)
        actual_headers = last_request.headers

        expected_body = {
            'user': self.order.user.username,
            'is_active': False,
            'mode': self.certificate_type,
            'course_details': {
                'course_id': self.course_id,
            },
        }

        expected_headers = {
            'X-Edx-Ga-Client-Id': self.user.tracking_context['ga_client_id'],
            'X-Forwarded-For': self.user.tracking_context['lms_ip'],
        }

        self.assertDictContainsSubset(expected_headers, actual_headers)
        self.assertEqual(expected_body, actual_body)

    @httpretty.activate
    def test_revoke_product_expected_error(self):
        """
        If the Enrollment API responds with an expected error, the method should log that revocation was
        bypassed, and return True.
        """
        message = 'Enrollment mode mismatch: active mode=x, requested mode=y. Won\'t deactivate.'
        body = '{{"message": "{}"}}'.format(message)
        httpretty.register_uri(httpretty.POST,
                               get_lms_enrollment_api_url(),
                               status=400,
                               body=body,
                               content_type=JSON)

        line = self.order.lines.first()
        logger_name = 'ecommerce.extensions.fulfillment.modules'
        with LogCapture(logger_name) as logger:
            self.assertTrue(EnrollmentFulfillmentModule().revoke_line(line))
            logger.check_present(
                (logger_name, 'INFO',
                 'Attempting to revoke fulfillment of Line [{}]...'.format(
                     line.id)),
                (logger_name, 'INFO', 'Skipping revocation for line [%d]: %s' %
                 (line.id, message)))

    @httpretty.activate
    def test_revoke_product_unexpected_error(self):
        """ If the Enrollment API responds with a non-200 status, the method should log an error and return False. """
        message = 'Meh.'
        body = '{{"message": "{}"}}'.format(message)
        httpretty.register_uri(httpretty.POST,
                               get_lms_enrollment_api_url(),
                               status=500,
                               body=body,
                               content_type=JSON)

        line = self.order.lines.first()
        logger_name = 'ecommerce.extensions.fulfillment.modules'
        with LogCapture(logger_name) as logger:
            self.assertFalse(EnrollmentFulfillmentModule().revoke_line(line))
            logger.check_present(
                (logger_name, 'INFO',
                 'Attempting to revoke fulfillment of Line [{}]...'.format(
                     line.id)),
                (logger_name, 'ERROR',
                 'Failed to revoke fulfillment of Line [%d]: %s' %
                 (line.id, message)))

    @httpretty.activate
    def test_revoke_product_unknown_exception(self):
        """
        If an exception is raised while contacting the Enrollment API, the method should log an error and return False.
        """
        def request_callback(_method, _uri, _headers):
            raise Timeout

        httpretty.register_uri(httpretty.POST,
                               get_lms_enrollment_api_url(),
                               body=request_callback)
        line = self.order.lines.first()
        logger_name = 'ecommerce.extensions.fulfillment.modules'

        with LogCapture(logger_name) as logger:
            self.assertFalse(EnrollmentFulfillmentModule().revoke_line(line))
            logger.check_present(
                (logger_name, 'INFO',
                 'Attempting to revoke fulfillment of Line [{}]...'.format(
                     line.id)),
                (logger_name, 'ERROR',
                 'Failed to revoke fulfillment of Line [{}].'.format(line.id)))

    @httpretty.activate
    def test_credit_enrollment_module_fulfill(self):
        """Happy path test to ensure we can properly fulfill enrollments."""
        # Create the credit certificate type and order for the credit certificate type.
        self.create_seat_and_order(certificate_type='credit', provider='MIT')
        httpretty.register_uri(httpretty.POST,
                               get_lms_enrollment_api_url(),
                               status=200,
                               body='{}',
                               content_type=JSON)

        # Attempt to enroll.
        with LogCapture(LOGGER_NAME) as logger:
            EnrollmentFulfillmentModule().fulfill_product(
                self.order, list(self.order.lines.all()))

            line = self.order.lines.get()
            logger.check_present((
                LOGGER_NAME, 'INFO',
                'line_fulfilled: course_id="{}", credit_provider="{}", mode="{}", order_line_id="{}", '
                'order_number="{}", product_class="{}", user_id="{}"'.format(
                    line.product.attr.course_key,
                    line.product.attr.credit_provider,
                    mode_for_product(line.product),
                    line.id,
                    line.order.number,
                    line.product.get_product_class().name,
                    line.order.user.id,
                )))

        self.assertEqual(LINE.COMPLETE, line.status)

        actual = json.loads(httpretty.last_request().body)
        expected = {
            'user':
            self.order.user.username,
            'is_active':
            True,
            'mode':
            self.certificate_type,
            'course_details': {
                'course_id': self.course_id,
            },
            'enrollment_attributes': [{
                'namespace': 'order',
                'name': 'order_number',
                'value': self.order.number
            }, {
                'namespace': 'credit',
                'name': 'provider_id',
                'value': self.provider
            }]
        }
        self.assertEqual(actual, expected)

    def test_enrollment_headers(self):
        """ Test that the enrollment module 'EnrollmentFulfillmentModule' is
        sending enrollment request over to the LMS with proper headers.
        """
        # Create a dummy data for the enrollment request.
        data = {
            'user': '******',
            'is_active': True,
            'mode': 'honor',
            'course_details': {
                'course_id': self.course_id
            },
            'enrollment_attributes': []
        }

        # Now call the enrollment api to send POST request to LMS and verify
        # that the header of the request being sent contains the analytics
        # header 'x-edx-ga-client-id'.
        # This will raise the exception 'ConnectionError' because the LMS is
        # not available for ecommerce tests.
        try:
            # pylint: disable=protected-access
            EnrollmentFulfillmentModule()._post_to_enrollment_api(
                data=data, user=self.user, usage='test enrollment')
        except ConnectionError as exp:
            # Check that the enrollment request object has the analytics header
            # 'x-edx-ga-client-id' and 'x-forwarded-for'.
            self.assertEqual(exp.request.headers.get('x-edx-ga-client-id'),
                             self.user.tracking_context['ga_client_id'])
            self.assertEqual(exp.request.headers.get('x-forwarded-for'),
                             self.user.tracking_context['lms_ip'])

    def test_voucher_usage(self):
        """
        Test that using a voucher applies offer discount to reduce order price
        """
        self.prepare_basket_with_voucher()
        self.assertEqual(self.order.basket.total_excl_tax, 0.00)

    @httpretty.activate
    def test_voucher_usage_with_program(self):
        """
        Test that using a voucher with a program basket results in a fulfilled order.
        """
        httpretty.register_uri(httpretty.POST,
                               get_lms_enrollment_api_url(),
                               status=200,
                               body='{}',
                               content_type=JSON)
        self.create_seat_and_order(certificate_type='credit', provider='MIT')
        program_uuid = uuid.uuid4()
        self.mock_program_detail_endpoint(
            program_uuid, self.site_configuration.discovery_api_url)
        self.mock_user_data(self.user.username)
        self.prepare_basket_with_voucher(program_uuid=program_uuid)
        __, lines = EnrollmentFulfillmentModule().fulfill_product(
            self.order, list(self.order.lines.all()))
        # No exceptions should be raised and the order should be fulfilled
        self.assertEqual(lines[0].status, 'Complete')