237 lines
9.8 KiB
Python
237 lines
9.8 KiB
Python
"""
|
|
Tests for signal handling in commerce djangoapp.
|
|
"""
|
|
from django.contrib.auth.models import AnonymousUser
|
|
from django.test import TestCase
|
|
from django.test.utils import override_settings
|
|
|
|
from course_modes.models import CourseMode
|
|
import ddt
|
|
import mock
|
|
from opaque_keys.edx.keys import CourseKey
|
|
from student.models import UNENROLL_DONE
|
|
from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
|
|
|
from commerce.signals import refund_seat, send_refund_notification
|
|
from commerce.tests import TEST_PUBLIC_URL_ROOT, TEST_API_URL, TEST_API_SIGNING_KEY
|
|
from commerce.tests.mocks import mock_create_refund
|
|
|
|
|
|
@ddt.ddt
|
|
@override_settings(
|
|
ECOMMERCE_PUBLIC_URL_ROOT=TEST_PUBLIC_URL_ROOT,
|
|
ECOMMERCE_API_URL=TEST_API_URL,
|
|
ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY,
|
|
)
|
|
class TestRefundSignal(TestCase):
|
|
"""
|
|
Exercises logic triggered by the UNENROLL_DONE signal.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super(TestRefundSignal, self).setUp()
|
|
self.requester = UserFactory(username="test-requester")
|
|
self.student = UserFactory(username="test-student", email="test-student@example.com")
|
|
self.course_enrollment = CourseEnrollmentFactory(
|
|
user=self.student,
|
|
course_id=CourseKey.from_string('course-v1:org+course+run'),
|
|
mode=CourseMode.VERIFIED,
|
|
)
|
|
self.course_enrollment.refundable = mock.Mock(return_value=True)
|
|
|
|
def send_signal(self, skip_refund=False):
|
|
"""
|
|
DRY helper: emit the UNENROLL_DONE signal, as is done in
|
|
common.djangoapps.student.models after a successful unenrollment.
|
|
"""
|
|
UNENROLL_DONE.send(sender=None, course_enrollment=self.course_enrollment, skip_refund=skip_refund)
|
|
|
|
@override_settings(
|
|
ECOMMERCE_PUBLIC_URL_ROOT=None,
|
|
ECOMMERCE_API_URL=None,
|
|
ECOMMERCE_API_SIGNING_KEY=None,
|
|
)
|
|
def test_no_service(self):
|
|
"""
|
|
Ensure that the receiver quietly bypasses attempts to initiate
|
|
refunds when there is no external service configured.
|
|
"""
|
|
with mock.patch('commerce.signals.refund_seat') as mock_refund_seat:
|
|
self.send_signal()
|
|
self.assertFalse(mock_refund_seat.called)
|
|
|
|
@mock.patch('commerce.signals.refund_seat')
|
|
def test_receiver(self, mock_refund_seat):
|
|
"""
|
|
Ensure that the UNENROLL_DONE signal triggers correct calls to
|
|
refund_seat(), when it is appropriate to do so.
|
|
|
|
TODO (jsa): ideally we would assert that the signal receiver got wired
|
|
up independently of the import statement in this module. I'm not aware
|
|
of any reliable / sane way to do this.
|
|
"""
|
|
self.send_signal()
|
|
self.assertTrue(mock_refund_seat.called)
|
|
self.assertEqual(mock_refund_seat.call_args[0], (self.course_enrollment, self.student))
|
|
|
|
# if skip_refund is set to True in the signal, we should not try to initiate a refund.
|
|
mock_refund_seat.reset_mock()
|
|
self.send_signal(skip_refund=True)
|
|
self.assertFalse(mock_refund_seat.called)
|
|
|
|
# if the course_enrollment is not refundable, we should not try to initiate a refund.
|
|
mock_refund_seat.reset_mock()
|
|
self.course_enrollment.refundable = mock.Mock(return_value=False)
|
|
self.send_signal()
|
|
self.assertFalse(mock_refund_seat.called)
|
|
|
|
@mock.patch('commerce.signals.refund_seat')
|
|
@mock.patch('commerce.signals.get_request_user', return_value=None)
|
|
def test_requester(self, mock_get_request_user, mock_refund_seat):
|
|
"""
|
|
Ensure the right requester is specified when initiating refunds.
|
|
"""
|
|
# no HTTP request/user: auth to commerce service as the unenrolled student.
|
|
self.send_signal()
|
|
self.assertTrue(mock_refund_seat.called)
|
|
self.assertEqual(mock_refund_seat.call_args[0], (self.course_enrollment, self.student))
|
|
|
|
# HTTP user is the student: auth to commerce service as the unenrolled student.
|
|
mock_get_request_user.return_value = self.student
|
|
mock_refund_seat.reset_mock()
|
|
self.send_signal()
|
|
self.assertTrue(mock_refund_seat.called)
|
|
self.assertEqual(mock_refund_seat.call_args[0], (self.course_enrollment, self.student))
|
|
|
|
# HTTP user is another user: auth to commerce service as the requester.
|
|
mock_get_request_user.return_value = self.requester
|
|
mock_refund_seat.reset_mock()
|
|
self.send_signal()
|
|
self.assertTrue(mock_refund_seat.called)
|
|
self.assertEqual(mock_refund_seat.call_args[0], (self.course_enrollment, self.requester))
|
|
|
|
# HTTP user is another server (AnonymousUser): do not try to initiate a refund at all.
|
|
mock_get_request_user.return_value = AnonymousUser()
|
|
mock_refund_seat.reset_mock()
|
|
self.send_signal()
|
|
self.assertFalse(mock_refund_seat.called)
|
|
|
|
@mock.patch('commerce.signals.log.warning')
|
|
def test_not_authorized_warning(self, mock_log_warning):
|
|
"""
|
|
Ensure that expected authorization issues are logged as warnings.
|
|
"""
|
|
with mock_create_refund(status=403):
|
|
refund_seat(self.course_enrollment, UserFactory())
|
|
self.assertTrue(mock_log_warning.called)
|
|
|
|
@mock.patch('commerce.signals.log.exception')
|
|
def test_error_logging(self, mock_log_exception):
|
|
"""
|
|
Ensure that unexpected Exceptions are logged as errors (but do not
|
|
break program flow).
|
|
"""
|
|
with mock_create_refund(status=500):
|
|
self.send_signal()
|
|
self.assertTrue(mock_log_exception.called)
|
|
|
|
@mock.patch('commerce.signals.send_refund_notification')
|
|
def test_notification(self, mock_send_notification):
|
|
"""
|
|
Ensure the notification function is triggered when refunds are
|
|
initiated
|
|
"""
|
|
with mock_create_refund(status=200, response=[1, 2, 3]):
|
|
self.send_signal()
|
|
self.assertTrue(mock_send_notification.called)
|
|
|
|
@mock.patch('commerce.signals.send_refund_notification')
|
|
def test_notification_no_refund(self, mock_send_notification):
|
|
"""
|
|
Ensure the notification function is NOT triggered when no refunds are
|
|
initiated
|
|
"""
|
|
with mock_create_refund(status=200, response=[]):
|
|
self.send_signal()
|
|
self.assertFalse(mock_send_notification.called)
|
|
|
|
@mock.patch('commerce.signals.send_refund_notification')
|
|
@ddt.data(
|
|
CourseMode.HONOR,
|
|
CourseMode.PROFESSIONAL,
|
|
CourseMode.AUDIT,
|
|
CourseMode.NO_ID_PROFESSIONAL_MODE,
|
|
CourseMode.CREDIT_MODE,
|
|
)
|
|
def test_notification_not_verified(self, mode, mock_send_notification):
|
|
"""
|
|
Ensure the notification function is NOT triggered when the
|
|
unenrollment is for any mode other than verified (i.e. any mode other
|
|
than one for which refunds are presently supported). See the
|
|
TODO associated with XCOM-371 in the signals module in the commerce
|
|
package for more information.
|
|
"""
|
|
self.course_enrollment.mode = mode
|
|
with mock_create_refund(status=200, response=[1, 2, 3]):
|
|
self.send_signal()
|
|
self.assertFalse(mock_send_notification.called)
|
|
|
|
@mock.patch('commerce.signals.send_refund_notification', side_effect=Exception("Splat!"))
|
|
@mock.patch('commerce.signals.log.warning')
|
|
def test_notification_error(self, mock_log_warning, mock_send_notification):
|
|
"""
|
|
Ensure an error occuring during notification does not break program
|
|
flow, but a warning is logged.
|
|
"""
|
|
with mock_create_refund(status=200, response=[1, 2, 3]):
|
|
self.send_signal()
|
|
self.assertTrue(mock_send_notification.called)
|
|
self.assertTrue(mock_log_warning.called)
|
|
|
|
@mock.patch('commerce.signals.microsite.is_request_in_microsite', return_value=True)
|
|
def test_notification_microsite(self, mock_is_request_in_microsite): # pylint: disable=unused-argument
|
|
"""
|
|
Ensure the notification function raises an Exception if used in the
|
|
context of microsites.
|
|
"""
|
|
with self.assertRaises(NotImplementedError):
|
|
send_refund_notification(self.course_enrollment, [1, 2, 3])
|
|
|
|
@override_settings(PAYMENT_SUPPORT_EMAIL='payment@example.com')
|
|
@mock.patch('commerce.signals.EmailMultiAlternatives')
|
|
def test_notification_content(self, mock_email_class):
|
|
"""
|
|
Ensure the email sender, recipient, subject, content type, and content
|
|
are all correct.
|
|
"""
|
|
# mock_email_class is the email message class/constructor.
|
|
# mock_message is the instance returned by the constructor.
|
|
# we need to make assertions regarding both.
|
|
mock_message = mock.MagicMock()
|
|
mock_email_class.return_value = mock_message
|
|
|
|
refund_ids = [1, 2, 3]
|
|
send_refund_notification(self.course_enrollment, refund_ids)
|
|
|
|
# check headers and text content
|
|
self.assertEqual(
|
|
mock_email_class.call_args[0],
|
|
("[Refund] User-Requested Refund", mock.ANY, self.student.email, ['payment@example.com']),
|
|
)
|
|
text_body = mock_email_class.call_args[0][1]
|
|
# check for a URL for each refund
|
|
for exp in [r'{0}/dashboard/refunds/{1}/'.format(TEST_PUBLIC_URL_ROOT, refund_id)
|
|
for refund_id in refund_ids]:
|
|
self.assertRegexpMatches(text_body, exp)
|
|
|
|
# check HTML content
|
|
self.assertEqual(mock_message.attach_alternative.call_args[0], (mock.ANY, "text/html"))
|
|
html_body = mock_message.attach_alternative.call_args[0][0]
|
|
# check for a link to each refund
|
|
for exp in [r'a href="{0}/dashboard/refunds/{1}/"'.format(TEST_PUBLIC_URL_ROOT, refund_id)
|
|
for refund_id in refund_ids]:
|
|
self.assertRegexpMatches(html_body, exp)
|
|
|
|
# make sure we actually SEND the message too.
|
|
self.assertTrue(mock_message.send.called)
|