Files
edx-platform/common/djangoapps/student/tests/test_refunds.py
2023-02-01 13:52:26 -08:00

306 lines
13 KiB
Python

"""
Tests for enrollment refund capabilities.
"""
import json
import logging
from datetime import datetime, timedelta
from unittest.mock import patch
import ddt
import httpretty
import pytz
# Explicitly import the cache from ConfigurationModel so we can reset it after each test
from config_models.models import cache
from django.test.client import Client
from django.test.utils import override_settings
from django.urls import reverse
from edx_django_utils.cache import TieredCache, get_cache_key
# These imports refer to lms djangoapps.
# Their testcases are only run under lms.
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAttribute, EnrollmentRefundConfiguration
from common.djangoapps.student.tests.factories import UserFactory
from lms.djangoapps.certificates.data import CertificateStatuses
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
from openedx.core.djangoapps.commerce.utils import ECOMMERCE_DATE_FORMAT
from openedx.core.djangolib.testing.utils import skip_unless_lms
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
log = logging.getLogger(__name__)
TEST_API_URL = 'http://www-internal.example.com/api'
JSON = 'application/json'
@ddt.ddt
@skip_unless_lms
class RefundableTest(SharedModuleStoreTestCase):
"""
Tests for dashboard utility functions
"""
USER_PASSWORD = 'test'
ORDER_NUMBER = 'EDX-100000'
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create()
def setUp(self):
""" Setup components used by each refund test."""
super().setUp()
self.user = UserFactory.create(password=self.USER_PASSWORD)
self.verified_mode = CourseModeFactory.create(
course_id=self.course.id,
mode_slug='verified',
mode_display_name='Verified',
expiration_datetime=datetime.now(pytz.UTC) + timedelta(days=1)
)
self.enrollment = CourseEnrollment.enroll(self.user, self.course.id, mode='verified')
self.client = Client()
cache.clear()
@patch('common.djangoapps.student.models.course_enrollment.CourseEnrollment.refund_cutoff_date')
def test_refundable(self, cutoff_date):
""" Assert base case is refundable"""
cutoff_date.return_value = datetime.now(pytz.UTC) + timedelta(days=1)
assert self.enrollment.refundable()
@patch('common.djangoapps.student.models.course_enrollment.CourseEnrollment.refund_cutoff_date')
def test_refundable_expired_verification(self, cutoff_date):
""" Assert that enrollment is refundable if course mode has expired."""
cutoff_date.return_value = datetime.now(pytz.UTC) + timedelta(days=1)
self.verified_mode.expiration_datetime = datetime.now(pytz.UTC) - timedelta(days=1)
self.verified_mode.save()
assert self.enrollment.refundable()
@patch('common.djangoapps.student.models.course_enrollment.CourseEnrollment.refund_cutoff_date')
def test_refundable_when_certificate_exists(self, cutoff_date):
""" Assert that enrollment is not refundable once a certificat has been generated."""
cutoff_date.return_value = datetime.now(pytz.UTC) + timedelta(days=1)
assert self.enrollment.refundable()
certificate = GeneratedCertificateFactory.create(
user=self.user,
course_id=self.course.id,
status=CertificateStatuses.downloadable,
mode='verified'
)
assert not self.enrollment.refundable()
# Certificates that are not in the PASSED_STATUSES should allow a refund
certificate.status = CertificateStatuses.notpassing
certificate.save()
assert self.enrollment.refundable()
# Assert that can_refund overrides this and allows refund
certificate.status = CertificateStatuses.downloadable
certificate.save()
self.enrollment.can_refund = True
assert self.enrollment.refundable()
@patch('common.djangoapps.student.models.course_enrollment.CourseEnrollment.refund_cutoff_date')
def test_refundable_with_cutoff_date(self, cutoff_date):
""" Assert enrollment is refundable before cutoff and not refundable after."""
cutoff_date.return_value = datetime.now(pytz.UTC) + timedelta(days=1)
assert self.enrollment.refundable()
cutoff_date.return_value = datetime.now(pytz.UTC) - timedelta(minutes=5)
assert not self.enrollment.refundable()
cutoff_date.return_value = datetime.now(pytz.UTC) + timedelta(minutes=5)
assert self.enrollment.refundable()
@ddt.data(
(timedelta(days=1), timedelta(days=2), timedelta(days=2), 14),
(timedelta(days=2), timedelta(days=1), timedelta(days=2), 14),
(timedelta(days=1), timedelta(days=2), timedelta(days=2), 1),
(timedelta(days=2), timedelta(days=1), timedelta(days=2), 1),
)
@ddt.unpack
@httpretty.activate
@override_settings(ECOMMERCE_API_URL=TEST_API_URL)
def test_refund_cutoff_date(self, order_date_delta, course_start_delta, expected_date_delta, days):
"""
Assert that the later date is used with the configurable refund period in calculating the returned cutoff date.
"""
now = datetime.now(pytz.UTC).replace(microsecond=0)
order_date = now + order_date_delta
course_start = now + course_start_delta
expected_date = now + expected_date_delta
refund_period = timedelta(days=days)
date_placed = order_date.strftime(ECOMMERCE_DATE_FORMAT)
expected_content = f'{{"date_placed": "{date_placed}"}}'
httpretty.register_uri(
httpretty.GET,
f'{TEST_API_URL}/orders/{self.ORDER_NUMBER}/',
status=200, body=expected_content,
adding_headers={'Content-Type': JSON}
)
self.enrollment.course_overview.start = course_start
self.enrollment.attributes.create(
enrollment=self.enrollment,
namespace='order',
name='order_number',
value=self.ORDER_NUMBER
)
CONFIG_METHOD_NAME = 'common.djangoapps.student.models.course_enrollment.EnrollmentRefundConfiguration.current'
with patch(CONFIG_METHOD_NAME) as config:
instance = config.return_value
instance.refund_window = refund_period
assert self.enrollment.refund_cutoff_date() == (expected_date + refund_period)
expected_date_placed_attr = {
"namespace": "order",
"name": "date_placed",
"value": date_placed,
}
assert expected_date_placed_attr in CourseEnrollmentAttribute.get_enrollment_attributes(self.enrollment)
@ddt.data(
(datetime.now(pytz.UTC) + timedelta(days=1), True),
(datetime.now(pytz.UTC) - timedelta(days=1), False),
(datetime.now(pytz.UTC) - timedelta(minutes=5), False),
)
@ddt.unpack
@httpretty.activate
@override_settings(ECOMMERCE_API_URL=TEST_API_URL)
def test_is_order_voucher_refundable(self, voucher_expiration_date, expected):
"""
Assert that the correct value is returned based on voucher expiration date.
"""
voucher_expiration_date_str = voucher_expiration_date.strftime(ECOMMERCE_DATE_FORMAT)
response = json.dumps({"vouchers": [{"end_datetime": voucher_expiration_date_str}]})
httpretty.register_uri(
httpretty.GET,
f'{TEST_API_URL}/orders/{self.ORDER_NUMBER}/',
status=200, body=response,
adding_headers={'Content-Type': JSON}
)
self.enrollment.attributes.create(
enrollment=self.enrollment,
namespace='order',
name='order_number',
value=self.ORDER_NUMBER
)
assert self.enrollment.is_order_voucher_refundable() == expected
def test_refund_cutoff_date_no_attributes(self):
""" Assert that the None is returned when no order number attribute is found."""
assert self.enrollment.refund_cutoff_date() is None
@httpretty.activate
@override_settings(ECOMMERCE_API_URL=TEST_API_URL)
def test_is_order_voucher_refundable_no_attributes(self, ):
""" Assert that False is returned when no order number or vouchers attribute is found in response."""
# no order number attribute
assert self.enrollment.is_order_voucher_refundable() is False
# no voucher information in orders api response
response = json.dumps({"vouchers": []})
httpretty.register_uri(
httpretty.GET,
f'{TEST_API_URL}/orders/{self.ORDER_NUMBER}/',
status=200, body=response,
adding_headers={'Content-Type': JSON}
)
self.enrollment.attributes.create(
enrollment=self.enrollment,
namespace='order',
name='order_number',
value=self.ORDER_NUMBER
)
assert self.enrollment.is_order_voucher_refundable() is False
response = json.dumps({"vouchers": None})
httpretty.register_uri(
httpretty.GET,
f'{TEST_API_URL}/orders/{self.ORDER_NUMBER}/',
status=200, body=response,
adding_headers={'Content-Type': JSON}
)
assert self.enrollment.is_order_voucher_refundable() is False
@patch('openedx.core.djangoapps.commerce.utils.get_ecommerce_api_client')
def test_get_order_attribute_from_ecommerce(self, mock_ecommerce_api_client):
"""
Assert that the get_order_attribute_from_ecommerce method returns order details if it's already cached,
without calling ecommerce.
"""
order_details = {"number": self.ORDER_NUMBER, "vouchers": [{"end_datetime": '2025-09-25T00:00:00Z'}]}
cache_key = get_cache_key(user_id=self.user.id, order_number=self.ORDER_NUMBER)
TieredCache.set_all_tiers(cache_key, order_details, 60)
self.enrollment.attributes.create(
enrollment=self.enrollment,
namespace='order',
name='order_number',
value=self.ORDER_NUMBER
)
assert self.enrollment.get_order_attribute_from_ecommerce("vouchers") == order_details["vouchers"]
mock_ecommerce_api_client.assert_not_called()
@patch('openedx.core.djangoapps.commerce.utils.get_ecommerce_api_client')
def test_refund_cutoff_date_with_date_placed_attr(self, mock_ecommerce_api_client):
"""
Assert that the refund_cutoff_date returns order placement date if order:date_placed
attribute exist without calling ecommerce.
"""
now = datetime.now(pytz.UTC).replace(microsecond=0)
order_date = now + timedelta(days=2)
course_start = now + timedelta(days=1)
self.enrollment.course_overview.start = course_start
self.enrollment.attributes.create(
enrollment=self.enrollment,
namespace='order',
name='date_placed',
value=order_date.strftime(ECOMMERCE_DATE_FORMAT)
)
refund_config = EnrollmentRefundConfiguration.current()
assert self.enrollment.refund_cutoff_date() == (order_date + refund_config.refund_window)
mock_ecommerce_api_client.assert_not_called()
@httpretty.activate
@override_settings(ECOMMERCE_API_URL=TEST_API_URL)
def test_multiple_refunds_dashbaord_page_error(self):
""" Order with mutiple refunds will not throw 500 error when dashboard page will access."""
now = datetime.now(pytz.UTC).replace(microsecond=0)
order_date = now + timedelta(days=1)
expected_content = f'{{"date_placed": "{order_date.strftime(ECOMMERCE_DATE_FORMAT)}"}}'
httpretty.register_uri(
httpretty.GET,
f'{TEST_API_URL}/orders/{self.ORDER_NUMBER}/',
status=200, body=expected_content,
adding_headers={'Content-Type': JSON}
)
# creating multiple attributes for same order.
for attribute_count in range(2): # pylint: disable=unused-variable
self.enrollment.attributes.create(
enrollment=self.enrollment,
namespace='order',
name='order_number',
value=self.ORDER_NUMBER
)
self.client.login(username=self.user.username, password=self.USER_PASSWORD)
resp = self.client.post(reverse('dashboard', args=[]))
assert resp.status_code == 200