327 lines
13 KiB
Python
327 lines
13 KiB
Python
"""
|
|
Tests for signal handling in commerce djangoapp.
|
|
"""
|
|
|
|
|
|
import base64
|
|
import json
|
|
from unittest import mock
|
|
from urllib.parse import urljoin
|
|
|
|
import pytest
|
|
import ddt
|
|
import httpretty
|
|
from django.conf import settings
|
|
from django.contrib.auth.models import AnonymousUser
|
|
from django.test import TestCase
|
|
from django.test.utils import override_settings
|
|
from opaque_keys.edx.keys import CourseKey
|
|
from requests import Timeout
|
|
|
|
from common.djangoapps.course_modes.models import CourseMode
|
|
from common.djangoapps.student.signals import REFUND_ORDER
|
|
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
|
|
|
|
from ..models import CommerceConfiguration
|
|
from ..utils import _generate_refund_notification_body, _send_refund_notification, create_zendesk_ticket
|
|
from . import JSON
|
|
from .mocks import mock_create_refund, mock_process_refund
|
|
|
|
ZENDESK_URL = 'http://zendesk.example.com/'
|
|
ZENDESK_USER = 'test@example.com'
|
|
ZENDESK_API_KEY = 'abc123'
|
|
|
|
|
|
@ddt.ddt
|
|
@override_settings(ZENDESK_URL=ZENDESK_URL, ZENDESK_USER=ZENDESK_USER, ZENDESK_API_KEY=ZENDESK_API_KEY)
|
|
class TestRefundSignal(TestCase):
|
|
"""
|
|
Exercises logic triggered by the REFUND_ORDER signal.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
|
|
# Ensure the E-Commerce service user exists
|
|
UserFactory(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME, is_staff=True)
|
|
|
|
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)
|
|
|
|
self.config = CommerceConfiguration.current()
|
|
self.config.enable_automatic_refund_approval = True
|
|
self.config.save()
|
|
|
|
def send_signal(self):
|
|
"""
|
|
DRY helper: emit the REFUND_ORDER signal, as is done in
|
|
common.djangoapps.student.models after a successful unenrollment.
|
|
"""
|
|
REFUND_ORDER.send(sender=None, course_enrollment=self.course_enrollment)
|
|
|
|
@override_settings(
|
|
ECOMMERCE_PUBLIC_URL_ROOT=None,
|
|
ECOMMERCE_API_URL=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('lms.djangoapps.commerce.signals.refund_seat') as mock_refund_seat:
|
|
self.send_signal()
|
|
assert not mock_refund_seat.called
|
|
|
|
@mock.patch('lms.djangoapps.commerce.signals.refund_seat')
|
|
def test_receiver(self, mock_refund_seat):
|
|
"""
|
|
Ensure that the REFUND_ORDER 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()
|
|
assert mock_refund_seat.called
|
|
assert mock_refund_seat.call_args[0] == (self.course_enrollment,)
|
|
|
|
# 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()
|
|
assert not mock_refund_seat.called
|
|
|
|
@mock.patch('lms.djangoapps.commerce.signals.refund_seat')
|
|
@mock.patch('lms.djangoapps.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()
|
|
assert mock_refund_seat.called
|
|
assert mock_refund_seat.call_args[0] == (self.course_enrollment,)
|
|
|
|
# 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()
|
|
assert mock_refund_seat.called
|
|
assert mock_refund_seat.call_args[0] == (self.course_enrollment,)
|
|
|
|
# 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()
|
|
assert mock_refund_seat.called
|
|
assert mock_refund_seat.call_args[0] == (self.course_enrollment,)
|
|
|
|
# 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()
|
|
assert not mock_refund_seat.called
|
|
|
|
@mock.patch('lms.djangoapps.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()
|
|
assert mock_log_exception.called
|
|
|
|
@mock.patch('lms.djangoapps.commerce.utils._send_refund_notification')
|
|
def test_notification_when_approval_fails(self, mock_send_notification):
|
|
"""
|
|
Ensure the notification function is triggered when refunds are initiated, and cannot be automatically approved.
|
|
"""
|
|
refund_id = 1
|
|
failed_refund_id = 2
|
|
|
|
with mock_create_refund(status=201, response=[refund_id, failed_refund_id]):
|
|
with mock_process_refund(refund_id, reset_on_exit=False):
|
|
with mock_process_refund(failed_refund_id, status=500, reset_on_exit=False):
|
|
self.send_signal()
|
|
assert mock_send_notification.called
|
|
mock_send_notification.assert_called_with(self.course_enrollment.user, [failed_refund_id])
|
|
|
|
@mock.patch('lms.djangoapps.commerce.utils._send_refund_notification')
|
|
def test_notification_if_automatic_approval_disabled(self, mock_send_notification):
|
|
"""
|
|
Ensure the notification is always sent if the automatic approval functionality is disabled.
|
|
"""
|
|
refund_id = 1
|
|
self.config.enable_automatic_refund_approval = False
|
|
self.config.save()
|
|
|
|
with mock_create_refund(status=201, response=[refund_id]):
|
|
self.send_signal()
|
|
assert mock_send_notification.called
|
|
mock_send_notification.assert_called_with(self.course_enrollment.user, [refund_id])
|
|
|
|
@mock.patch('lms.djangoapps.commerce.utils._send_refund_notification')
|
|
def test_no_notification_after_approval(self, mock_send_notification):
|
|
"""
|
|
Ensure the notification function is triggered when refunds are initiated, and cannot be automatically approved.
|
|
"""
|
|
refund_id = 1
|
|
|
|
with mock_create_refund(status=201, response=[refund_id]):
|
|
with mock_process_refund(refund_id, reset_on_exit=False):
|
|
self.send_signal()
|
|
assert not mock_send_notification.called
|
|
|
|
last_request = httpretty.last_request()
|
|
self.assertDictEqual(json.loads(last_request.body.decode('utf8')), {'action': 'approve_payment_only'})
|
|
|
|
@mock.patch('lms.djangoapps.commerce.utils._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()
|
|
assert not mock_send_notification.called
|
|
|
|
@mock.patch('lms.djangoapps.commerce.utils._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()
|
|
assert not mock_send_notification.called
|
|
|
|
@mock.patch('lms.djangoapps.commerce.utils._send_refund_notification', side_effect=Exception("Splat!"))
|
|
@mock.patch('lms.djangoapps.commerce.utils.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()
|
|
assert mock_send_notification.called
|
|
assert mock_log_warning.called
|
|
|
|
@mock.patch('openedx.core.djangoapps.theming.helpers.is_request_in_themed_site', return_value=True)
|
|
def test_notification_themed_site(self, mock_is_request_in_themed_site): # pylint: disable=unused-argument
|
|
"""
|
|
Ensure the notification function raises an Exception if used in the
|
|
context of themed site.
|
|
"""
|
|
with pytest.raises(NotImplementedError):
|
|
_send_refund_notification(self.course_enrollment.user, [1, 2, 3])
|
|
|
|
@ddt.data('email@example.com', 'üñîcode.email@example.com')
|
|
@mock.patch('lms.djangoapps.commerce.utils.create_zendesk_ticket')
|
|
def test_send_refund_notification(self, student_email, mock_zendesk):
|
|
""" Verify the support team is notified of the refund request. """
|
|
refund_ids = [1, 2, 3]
|
|
|
|
# pass a student with unicode and ascii email to ensure that
|
|
# generate_refund_notification_body can handle formatting a unicode
|
|
# message
|
|
self.student.email = student_email
|
|
_send_refund_notification(self.course_enrollment.user, refund_ids)
|
|
body = _generate_refund_notification_body(self.student, refund_ids)
|
|
mock_zendesk.assert_called_with(
|
|
self.student.profile.name,
|
|
self.student.email,
|
|
"[Refund] User-Requested Refund",
|
|
body,
|
|
['auto_refund']
|
|
)
|
|
|
|
def _mock_zendesk_api(self, status=201):
|
|
""" Mock Zendesk's ticket creation API. """
|
|
httpretty.register_uri(httpretty.POST, urljoin(ZENDESK_URL, '/api/v2/tickets.json'), status=status,
|
|
body='{}', content_type=JSON)
|
|
|
|
def call_create_zendesk_ticket(self, name='Test user', email='user@example.com', subject='Test Ticket',
|
|
body='I want a refund!', tags=None):
|
|
""" Call the create_zendesk_ticket function. """
|
|
tags = tags or ['auto_refund']
|
|
return create_zendesk_ticket(name, email, subject, body, tags)
|
|
|
|
@override_settings(ZENDESK_URL=ZENDESK_URL, ZENDESK_USER=None, ZENDESK_API_KEY=None)
|
|
def test_create_zendesk_ticket_no_settings(self):
|
|
""" Verify the Zendesk API is not called if the settings are not all set. """
|
|
with mock.patch('requests.post') as mock_post:
|
|
success = self.call_create_zendesk_ticket()
|
|
assert not success
|
|
assert not mock_post.called
|
|
|
|
def test_create_zendesk_ticket_request_error(self):
|
|
"""
|
|
Verify exceptions are handled appropriately if the request to the Zendesk API fails.
|
|
|
|
We simply need to ensure the exception is not raised beyond the function.
|
|
"""
|
|
with mock.patch('requests.post', side_effect=Timeout) as mock_post:
|
|
success = self.call_create_zendesk_ticket()
|
|
assert not success
|
|
assert mock_post.called
|
|
|
|
@httpretty.activate
|
|
def test_create_zendesk_ticket(self):
|
|
""" Verify the Zendesk API is called. """
|
|
self._mock_zendesk_api()
|
|
|
|
name = 'Test user'
|
|
email = 'user@example.com'
|
|
subject = 'Test Ticket'
|
|
body = 'I want a refund!'
|
|
tags = ['auto_refund']
|
|
ticket_created = self.call_create_zendesk_ticket(name, email, subject, body, tags)
|
|
assert ticket_created
|
|
last_request = httpretty.last_request()
|
|
|
|
# Verify the headers
|
|
expected = {
|
|
'content-type': JSON,
|
|
'Authorization': 'Basic {}'.format(base64.b64encode(
|
|
f'{ZENDESK_USER}/token:{ZENDESK_API_KEY}'.encode('utf8')).decode('utf8')
|
|
)
|
|
}
|
|
self.assertDictContainsSubset(expected, last_request.headers)
|
|
|
|
# Verify the content
|
|
expected = {
|
|
'ticket': {
|
|
'requester': {
|
|
'name': name,
|
|
'email': email
|
|
},
|
|
'subject': subject,
|
|
'comment': {'body': body},
|
|
'tags': ['LMS'] + tags
|
|
}
|
|
}
|
|
self.assertDictEqual(json.loads(last_request.body.decode('utf8')), expected)
|