From 122e50501325382d030b46c16c72ae7ef7a0f1f2 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 8 Apr 2026 08:04:00 -0400 Subject: [PATCH 1/4] Fix Cart -> Learn dashboard for UAI industry verticals (#3466) --- ecommerce/views/legacy/__init__.py | 23 +++++++++++++++++------ ecommerce/views/legacy/views_test.py | 2 +- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/ecommerce/views/legacy/__init__.py b/ecommerce/views/legacy/__init__.py index ddc8093b51..5a1e62e834 100644 --- a/ecommerce/views/legacy/__init__.py +++ b/ecommerce/views/legacy/__init__.py @@ -86,12 +86,23 @@ log = logging.getLogger(__name__) -def _has_uai_b2c_program_purchase(order): - """Return True if the order includes a program whose readable_id contains UAI+B2C.""" +UAI_READABLE_ID_PREFIXES = [ + "program-v1:UAI+B2C", + "course-v1:UAI_SOURCE+UAI.", +] + + +def _has_uai_purchase(order): + """Return True if the order includes a program or course run with a UAI prefix.""" + + def _has_uai_prefix(readable_id): + return any( + readable_id.startswith(prefix) for prefix in UAI_READABLE_ID_PREFIXES + ) + return any( - isinstance(line.purchased_object, Program) - and line.purchased_object.readable_id - and "UAI+B2C" in line.purchased_object.readable_id + line.purchased_object.readable_id + and _has_uai_prefix(line.purchased_object.readable_id) for line in order.lines.all() ) @@ -766,7 +777,7 @@ def post_checkout_redirect(self, order_state, order, request): reverse("cart"), {"type": USER_MSG_TYPE_PAYMENT_DECLINED} ) elif order_state == OrderStatus.FULFILLED: - if _has_uai_b2c_program_purchase(order): + if _has_uai_purchase(order): return HttpResponseRedirect(settings.MIT_LEARN_DASHBOARD_URL) return redirect_with_user_message( diff --git a/ecommerce/views/legacy/views_test.py b/ecommerce/views/legacy/views_test.py index 1fd97593ae..8b2f93e9b4 100644 --- a/ecommerce/views/legacy/views_test.py +++ b/ecommerce/views/legacy/views_test.py @@ -670,7 +670,7 @@ def test_checkout_result_redirects_uai_b2c_program_to_learn_dashboard( ) mocker.patch("courses.api.create_program_enrollments") - program = ProgramFactory.create(readable_id="program-v1:MITx+UAI+B2C+2026") + program = ProgramFactory.create(readable_id="program-v1:UAI+B2C.3+2026") with reversion.create_revision(): product = ProductFactory.create(purchasable_object=program) From 4bc9279c21913135012acaee1bef308ed81b1fd7 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 8 Apr 2026 12:42:24 -0400 Subject: [PATCH 2/4] Fix flakey discount-related tests (#3472) --- ecommerce/api_test.py | 7 ++++--- ecommerce/views/v0/views_test.py | 15 ++++++++++----- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/ecommerce/api_test.py b/ecommerce/api_test.py index 2cfae1b1d4..c538ae60a7 100644 --- a/ecommerce/api_test.py +++ b/ecommerce/api_test.py @@ -868,7 +868,8 @@ def mock_cre_side_effect( False, ], ) -def test_apply_discount_to_basket(user, better_discount, is_valid): +@pytest.mark.parametrize("_count", range(100)) +def test_apply_discount_to_basket(user, better_discount, is_valid, _count): """ Test that applying a discount to a basket works as expected. @@ -884,7 +885,7 @@ def test_apply_discount_to_basket(user, better_discount, is_valid): BasketItem.objects.create(basket=basket, product=product, quantity=1) existing_discount = UnlimitedUseDiscountFactory.create( - amount=100, discount_type="fixed-price" + amount=50, discount_type="percent-off" ) BasketDiscount.objects.create( redeemed_by=user, @@ -894,7 +895,7 @@ def test_apply_discount_to_basket(user, better_discount, is_valid): ) new_discount = UnlimitedUseDiscountFactory.create( - amount=(50 if better_discount else 150), discount_type="fixed-price" + amount=(60 if better_discount else 10), discount_type="percent-off" ) if not is_valid: diff --git a/ecommerce/views/v0/views_test.py b/ecommerce/views/v0/views_test.py index 5cff8d0dbd..fc35c7e85a 100644 --- a/ecommerce/views/v0/views_test.py +++ b/ecommerce/views/v0/views_test.py @@ -400,7 +400,12 @@ def test_create_basket_with_products( ], ) def test_create_basket_with_product( # noqa: PLR0913 - user, user_client, existing_basket, add_discount, bad_discount, existing_discount + user, + user_client, + existing_basket, + add_discount, + bad_discount, + existing_discount, ): """Test creating a basket with a single product, and/or a discount.""" @@ -419,8 +424,8 @@ def test_create_basket_with_product( # noqa: PLR0913 # the one that'll be "supplied" below. ex_discount = DiscountFactory( - discount_type="fixed-price", - amount=150 if existing_discount == "worse" else 50, + discount_type="percent-off", + amount=10 if existing_discount == "worse" else 60, ) BasketDiscount.objects.create( redeemed_basket=basket, @@ -431,7 +436,7 @@ def test_create_basket_with_product( # noqa: PLR0913 if bad_discount: discount = DiscountFactory( - discount_type="fixed-price", amount=100, max_redemptions=1 + discount_type="percent-off", amount=50, max_redemptions=1 ) order = OrderFactory.create() DiscountRedemption.objects.create( @@ -441,7 +446,7 @@ def test_create_basket_with_product( # noqa: PLR0913 redeemed_order=order, ) else: - discount = DiscountFactory(discount_type="fixed-price", amount=100) + discount = DiscountFactory(discount_type="percent-off", amount=50) url = reverse( "v0:baskets_api-create_from_product_with_discount", From 7dc8c2c6a55f3c01a0c9ca12bd42adb531b29f2f Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 8 Apr 2026 13:48:43 -0400 Subject: [PATCH 3/4] Indicate order status on redirect via query params for MIT Learn (#3464) Co-authored-by: Claude Opus 4.6 (1M context) --- ecommerce/api.py | 40 +--- ecommerce/api_test.py | 29 ++- ecommerce/serializers/serializers_test.py | 15 +- ecommerce/views/legacy/__init__.py | 103 +++++++--- ecommerce/views/legacy/views_test.py | 230 ++++++++++++++-------- 5 files changed, 260 insertions(+), 157 deletions(-) diff --git a/ecommerce/api.py b/ecommerce/api.py index e028b10e1d..4f723e8d25 100644 --- a/ecommerce/api.py +++ b/ecommerce/api.py @@ -66,11 +66,9 @@ USER_MSG_TYPE_DISCOUNT_INVALID, USER_MSG_TYPE_ENROLL_BLOCKED, USER_MSG_TYPE_ENROLL_DUPLICATED, - USER_MSG_TYPE_PAYMENT_ACCEPTED_NOVALUE, - USER_MSG_TYPE_REQUIRED_ENROLLMENT_CODE_EMPTY, ) from main.settings import ECOMMERCE_DEFAULT_PAYMENT_GATEWAY -from main.utils import parse_supplied_date, redirect_with_user_message +from main.utils import parse_supplied_date from openedx.api import create_user from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE from openedx.tasks import create_user_from_id @@ -103,30 +101,18 @@ def generate_checkout_payload( # noqa: PLR0911 return { "country_blocked": True, "error": USER_MSG_TYPE_ENROLL_BLOCKED, - "response": redirect_with_user_message( - reverse("user-dashboard"), - {"type": USER_MSG_TYPE_ENROLL_BLOCKED}, - ), } if basket.has_user_purchased_same_courserun(request.user): return { "purchased_same_courserun": True, "error": USER_MSG_TYPE_ENROLL_DUPLICATED, - "response": redirect_with_user_message( - reverse("cart"), - {"type": USER_MSG_TYPE_ENROLL_DUPLICATED}, - ), } if basket.has_user_purchased_non_upgradable_courserun(): return { "purchased_non_upgradeable_courserun": True, "error": USER_MSG_TYPE_COURSE_NON_UPGRADABLE, - "response": redirect_with_user_message( - reverse("cart"), - {"type": USER_MSG_TYPE_COURSE_NON_UPGRADABLE}, - ), } if not skip_discount_check and not check_basket_discounts_for_validity(request): @@ -136,10 +122,6 @@ def generate_checkout_payload( # noqa: PLR0911 return { "invalid_discounts": True, "error": USER_MSG_TYPE_DISCOUNT_INVALID, - "response": redirect_with_user_message( - reverse("cart"), - {"type": USER_MSG_TYPE_DISCOUNT_INVALID}, - ), } active_contracts = get_active_contracts_from_basket_items(basket) @@ -148,30 +130,18 @@ def generate_checkout_payload( # noqa: PLR0911 return { "invalid_discounts": True, "error": USER_MSG_TYPE_B2B_ERROR_MISSING_ENROLLMENT_CODE, - "response": redirect_with_user_message( - reverse("cart"), - {"type": USER_MSG_TYPE_REQUIRED_ENROLLMENT_CODE_EMPTY}, - ), } if not validate_basket_for_b2b_purchase(request, active_contracts): return { "invalid_discounts": True, "error": USER_MSG_TYPE_B2B_INVALID_BASKET, - "response": redirect_with_user_message( - reverse("cart"), - {"type": USER_MSG_TYPE_DISCOUNT_INVALID}, - ), } if not basket.basket_items.count(): return { "basket_empty": True, "error": USER_MSG_TYPE_BASKET_EMPTY, - "response": redirect_with_user_message( - reverse("cart"), - {"type": USER_MSG_TYPE_BASKET_EMPTY}, - ), } order = PendingOrder.create_from_basket(basket) @@ -213,13 +183,7 @@ def generate_checkout_payload( # noqa: PLR0911 return { "no_checkout": True, - "response": redirect_with_user_message( - reverse("user-dashboard"), - { - "type": USER_MSG_TYPE_PAYMENT_ACCEPTED_NOVALUE, - "run": order.lines.first().courseware, - }, - ), + "order": order, "order_id": order.id, } diff --git a/ecommerce/api_test.py b/ecommerce/api_test.py index c538ae60a7..93f2a602ea 100644 --- a/ecommerce/api_test.py +++ b/ecommerce/api_test.py @@ -30,6 +30,7 @@ create_verified_program_course_run_enrollment, create_verified_program_discount, establish_basket, + generate_checkout_payload, get_auto_apply_discounts_for_basket, process_cybersource_payment_response, refund_order, @@ -217,6 +218,18 @@ def create_basket(user, products): return basket +def create_pending_order(user): + """ + Call generate_checkout_payload to create a PendingOrder with a realistic + CyberSource payload. + """ + rf = RequestFactory() + request = rf.get("/") + request.user = user + request.session = {} + return generate_checkout_payload(request) + + @pytest.mark.parametrize( "order_state", [ @@ -489,9 +502,7 @@ def test_unenrollment_unenrolls_learner(mocker, user): @pytest.mark.skip_nplusone_check -def test_process_cybersource_payment_response( # noqa: PLR0913 - settings, rf, mocker, user_client, user, products -): +def test_process_cybersource_payment_response(settings, rf, mocker, user, products): """Test that ensures the response from Cybersource for an ACCEPTed payment updates the orders state""" settings.OPENEDX_SERVICE_WORKER_API_TOKEN = "mock_api_token" # noqa: S105 @@ -501,9 +512,9 @@ def test_process_cybersource_payment_response( # noqa: PLR0913 ) create_basket(user, products) - resp = user_client.post(reverse("checkout_api-start_checkout")) + checkout_payload = create_pending_order(user) - payload = resp.json()["payload"] + payload = checkout_payload["payload"] payload = { **{f"req_{key}": value for key, value in payload.items()}, "decision": "ACCEPT", @@ -527,8 +538,8 @@ def test_process_cybersource_payment_response( # noqa: PLR0913 @pytest.mark.skip_nplusone_check @pytest.mark.parametrize("include_discount", [True, False]) -def test_process_cybersource_payment_decline_response( # noqa: PLR0913 - rf, mocker, user_client, user, products, include_discount +def test_process_cybersource_payment_decline_response( + rf, mocker, user, products, include_discount ): """Test that ensures the response from Cybersource for an DECLINEd payment updates the orders state""" @@ -538,9 +549,9 @@ def test_process_cybersource_payment_decline_response( # noqa: PLR0913 ) create_basket(user, products) - resp = user_client.post(reverse("checkout_api-start_checkout")) + checkout_payload = create_pending_order(user) - payload = resp.json()["payload"] + payload = checkout_payload["payload"] payload = { **{f"req_{key}": value for key, value in payload.items()}, "decision": "DECLINE", diff --git a/ecommerce/serializers/serializers_test.py b/ecommerce/serializers/serializers_test.py index 8eed8010f7..414f767ecc 100644 --- a/ecommerce/serializers/serializers_test.py +++ b/ecommerce/serializers/serializers_test.py @@ -3,6 +3,7 @@ import pytest import reversion from dateutil.parser import parse +from django.test import RequestFactory from django.urls import reverse from mitol.common.utils import now_in_utc @@ -310,15 +311,21 @@ def create_order_receipt(mocker, user, products, user_client): """ Sets up an order for use with the receipt serializer tests. """ + from ecommerce.api import generate_checkout_payload # noqa: PLC0415 + mocker.patch( "mitol.payment_gateway.api.PaymentGateway.validate_processor_response", return_value=True, ) - basket = create_basket(user, products) # noqa: F841 + create_basket(user, products) - resp = user_client.post(reverse("checkout_api-start_checkout")) + rf = RequestFactory() + request = rf.get("/") + request.user = user + request.session = {} + checkout_payload = generate_checkout_payload(request) - payload = resp.json()["payload"] + payload = checkout_payload["payload"] payload = { **{f"req_{key}": value for key, value in payload.items()}, "decision": "ACCEPT", @@ -328,7 +335,7 @@ def create_order_receipt(mocker, user, products, user_client): order = Order.objects.get(state=OrderStatus.PENDING, purchaser=user) - resp = user_client.post(reverse("checkout-result-callback"), payload) + user_client.post(reverse("checkout-result-callback"), payload) order.refresh_from_db() return order diff --git a/ecommerce/views/legacy/__init__.py b/ecommerce/views/legacy/__init__.py index 5a1e62e834..835713d73a 100644 --- a/ecommerce/views/legacy/__init__.py +++ b/ecommerce/views/legacy/__init__.py @@ -73,7 +73,13 @@ from flexiblepricing.serializers import FlexiblePriceTierSerializer from main import features from main.constants import ( + USER_MSG_TYPE_BASKET_EMPTY, + USER_MSG_TYPE_COURSE_NON_UPGRADABLE, + USER_MSG_TYPE_DISCOUNT_INVALID, + USER_MSG_TYPE_ENROLL_BLOCKED, + USER_MSG_TYPE_ENROLL_DUPLICATED, USER_MSG_TYPE_PAYMENT_ACCEPTED, + USER_MSG_TYPE_PAYMENT_ACCEPTED_NOVALUE, USER_MSG_TYPE_PAYMENT_CANCELLED, USER_MSG_TYPE_PAYMENT_DECLINED, USER_MSG_TYPE_PAYMENT_ERROR, @@ -101,12 +107,45 @@ def _has_uai_prefix(readable_id): ) return any( - line.purchased_object.readable_id + line.purchased_object + and line.purchased_object.readable_id and _has_uai_prefix(line.purchased_object.readable_id) for line in order.lines.all() ) +def _should_redirect_to_learn(order): + """ + Return True if the user should be redirected to the Learn dashboard + after a successful checkout. + """ + if _has_uai_purchase(order): + return True + + # NOTE: Retrieve global_id from purchaser rather than request user + # because this is used in contexts where the request is anonymous. + # + # One example is the callback view that processes the payment response from + # cybersource. This view is hit via a POST request from a different domain, + # which means no samesite (lax, strict) authentication cookies are sent. + global_id = order.purchaser.global_id + return bool( + global_id + and is_posthog_enabled( + features.REDIRECT_LEARN_DASHBOARD, + default=False, + opt_unique_id=global_id, + ) + ) + + +def _learn_dashboard_redirect(order): + """Build an HttpResponseRedirect to the Learn dashboard with order info.""" + return HttpResponseRedirect( + f"{settings.MIT_LEARN_DASHBOARD_URL}?order_status=fulfilled&order_id={order.id}" + ) + + class ProductsPagination(RefinePagination): default_limit = 2 @@ -545,26 +584,6 @@ class CheckoutApiViewSet(ViewSet): authentication_classes = (SessionAuthentication, TokenAuthentication) permission_classes = (IsAuthenticated,) - @action( - detail=False, methods=["post"], name="Start Checkout", url_name="start_checkout" - ) - def start_checkout(self, request): - """ - API call to start the checkout process. This assembles the basket items - into an Order with Lines for each item, applies the attached basket - discounts, and then calls the payment gateway to prepare for payment. - Returns: - - JSON payload from the ol-django payment gateway app. The payment - gateway returns data necessary to construct a form that will - ultimately POST to the actual payment processor. - """ - try: - payload = api.generate_checkout_payload(request) - except ObjectDoesNotExist: - return Response("No basket", status=status.HTTP_406_NOT_ACCEPTABLE) - - return Response(payload) - @extend_schema( request=RedeemDiscountRequestSerializer, responses={200: RedeemDiscountResponseSerializer}, @@ -777,8 +796,8 @@ def post_checkout_redirect(self, order_state, order, request): reverse("cart"), {"type": USER_MSG_TYPE_PAYMENT_DECLINED} ) elif order_state == OrderStatus.FULFILLED: - if _has_uai_purchase(order): - return HttpResponseRedirect(settings.MIT_LEARN_DASHBOARD_URL) + if _should_redirect_to_learn(order): + return _learn_dashboard_redirect(order) return redirect_with_user_message( reverse("user-dashboard"), @@ -967,21 +986,47 @@ def _create_ga4_context(self, order): ) return ga_purchase_payload - def get(self, request): # noqa: PLR0911 + def get(self, request): # noqa: PLR0911, C901 try: checkout_payload = api.generate_checkout_payload(request) except ObjectDoesNotExist: return HttpResponse("No basket") if "country_blocked" in checkout_payload: - return checkout_payload["response"] + return redirect_with_user_message( + reverse("user-dashboard"), + {"type": USER_MSG_TYPE_ENROLL_BLOCKED}, + ) if "no_checkout" in checkout_payload: - return checkout_payload["response"] + order = checkout_payload["order"] + if _should_redirect_to_learn(order): + return _learn_dashboard_redirect(order) + return redirect_with_user_message( + reverse("user-dashboard"), + { + "type": USER_MSG_TYPE_PAYMENT_ACCEPTED_NOVALUE, + "run": order.lines.first().courseware, + }, + ) if "purchased_same_courserun" in checkout_payload: - return checkout_payload["response"] + return redirect_with_user_message( + reverse("cart"), + {"type": USER_MSG_TYPE_ENROLL_DUPLICATED}, + ) if "purchased_non_upgradeable_courserun" in checkout_payload: - return checkout_payload["response"] + return redirect_with_user_message( + reverse("cart"), + {"type": USER_MSG_TYPE_COURSE_NON_UPGRADABLE}, + ) if "invalid_discounts" in checkout_payload: - return checkout_payload["response"] + return redirect_with_user_message( + reverse("cart"), + {"type": USER_MSG_TYPE_DISCOUNT_INVALID}, + ) + if "basket_empty" in checkout_payload: + return redirect_with_user_message( + reverse("cart"), + {"type": USER_MSG_TYPE_BASKET_EMPTY}, + ) context = { "checkout_payload": checkout_payload, diff --git a/ecommerce/views/legacy/views_test.py b/ecommerce/views/legacy/views_test.py index 8b2f93e9b4..209a62e599 100644 --- a/ecommerce/views/legacy/views_test.py +++ b/ecommerce/views/legacy/views_test.py @@ -7,7 +7,7 @@ import pytest import reversion from django.forms.models import model_to_dict -from django.test.client import Client +from django.test.client import Client, RequestFactory from django.urls import reverse from mitol.common.utils.datetime import now_in_utc from rest_framework import status @@ -15,6 +15,7 @@ from b2b.factories import ContractPageFactory from courses.factories import CourseRunFactory, ProgramFactory, ProgramRunFactory from courses.models import CourseRunEnrollment, PaidCourseRun, ProgramEnrollment +from ecommerce import api from ecommerce.constants import ( DISCOUNT_TYPE_FIXED_PRICE, DISCOUNT_TYPE_PERCENT_OFF, @@ -217,6 +218,18 @@ def create_basket_with_product(user, product): return basket +def create_pending_order(user): + """ + Call generate_checkout_payload to create a PendingOrder with a realistic + CyberSource payload. Returns the payload dict from the payment gateway. + """ + rf = RequestFactory() + request = rf.get("/") + request.user = user + request.session = {} + return api.generate_checkout_payload(request) + + @pytest.mark.parametrize( ["try_flex_pricing_discount", "try_whitespace"], # noqa: PT006 [ @@ -442,42 +455,6 @@ def test_redeem_time_limited_discount( # noqa: PLR0913 assert "not found" in resp_json -@pytest.mark.skip_nplusone_check -def test_start_checkout(user, user_drf_client, products): - """ - Hits the start checkout view, which should create an Order record - and its associated line items. - """ - basket = create_basket(user, products) # noqa: F841 - - resp = user_drf_client.post(reverse("checkout_api-start_checkout")) - - # if there's not a payload in here, something went wrong - assert "payload" in resp.json() - - order = Order.objects.filter(purchaser=user).get() - - assert order.state == OrderStatus.PENDING - - -@pytest.mark.skip_nplusone_check -def test_start_checkout_with_discounts(user, user_drf_client, products, discounts): - """ - Applies a discount, then hits the start checkout view, which should create - an Order record and its associated line items. - """ - test_redeem_discount(user, user_drf_client, products, discounts, False, False) # noqa: FBT003 - - resp = user_drf_client.post(reverse("checkout_api-start_checkout")) - - # if there's not a payload in here, something went wrong - assert "payload" in resp.json() - - order = Order.objects.filter(purchaser=user).get() - - assert order.state == OrderStatus.PENDING - - def test_start_checkout_with_invalid_discounts(user, user_client, products, discounts): """ Applies a discount, invalidates all the discounts, then hits the start @@ -581,8 +558,7 @@ def test_checkout_result( # noqa: PLR0913 basket_exists, ): """ - Generates an order (using the API endpoint) and then cancels it using the endpoint. - There shouldn't be any PendingOrders after that happens. + Generates an order and then processes the checkout callback. """ mocker.patch("hubspot_sync.tasks.sync_deal_with_hubspot.apply_async") settings.OPENEDX_SERVICE_WORKER_API_TOKEN = "mock_api_token" # noqa: S105 @@ -592,9 +568,9 @@ def test_checkout_result( # noqa: PLR0913 ) basket = create_basket(user, products) - resp = user_client.post(reverse("checkout_api-start_checkout")) + checkout_payload = create_pending_order(user) - payload = resp.json()["payload"] + payload = checkout_payload["payload"] payload = { **{f"req_{key}": value for key, value in payload.items()}, "decision": decision, @@ -602,17 +578,10 @@ def test_checkout_result( # noqa: PLR0913 "transaction_id": "12345", } - # Load the pending order from the DB(factory) - should match the ref# in - # the payload we get back - order = Order.objects.get(state=OrderStatus.PENDING, purchaser=user) assert order.reference_number == payload["req_reference_number"] - # This is kind of cheating - CyberSource will send back a payload that is - # signed, but here we're just passing the payload as we got it back from - # the start checkout call. - resp = user_client.post(reverse("checkout-result-callback"), payload) assert resp.status_code == 302 assert resp.url == expected_redirect_url @@ -676,9 +645,97 @@ def test_checkout_result_redirects_uai_b2c_program_to_learn_dashboard( create_basket_with_product(user, product) - resp = user_client.post(reverse("checkout_api-start_checkout")) + checkout_payload = create_pending_order(user) + + payload = checkout_payload["payload"] + payload = { + **{f"req_{key}": value for key, value in payload.items()}, + "decision": "ACCEPT", + "message": "payment processor message", + "transaction_id": "12345", + } + + resp = user_client.post(reverse("checkout-result-callback"), payload) + assert resp.status_code == 302 + order = Order.objects.get(purchaser=user, state=OrderStatus.FULFILLED) + assert ( + resp.url + == f"{settings.MIT_LEARN_DASHBOARD_URL}?order_status=fulfilled&order_id={order.id}" + ) + + +@pytest.mark.skip_nplusone_check +@pytest.mark.dont_mock_enrollments +def test_checkout_result_redirects_to_learn_when_flag_enabled( + settings, + user, + user_client, + products, + mocker, +): + """Fulfilled purchases redirect to Learn dashboard when feature flag is enabled.""" + settings.MIT_LEARN_DASHBOARD_URL = "https://learn.mit.edu/dashboard" + settings.OPENEDX_SERVICE_WORKER_API_TOKEN = "mock_api_token" # noqa: S105 + + mocker.patch("hubspot_sync.tasks.sync_deal_with_hubspot.apply_async") + mocker.patch( + "mitol.payment_gateway.api.PaymentGateway.validate_processor_response", + return_value=True, + ) + mocker.patch( + "ecommerce.views.legacy.is_posthog_enabled", + return_value=True, + ) + + create_basket(user, products) + + checkout_payload = create_pending_order(user) + + payload = checkout_payload["payload"] + payload = { + **{f"req_{key}": value for key, value in payload.items()}, + "decision": "ACCEPT", + "message": "payment processor message", + "transaction_id": "12345", + } - payload = resp.json()["payload"] + resp = user_client.post(reverse("checkout-result-callback"), payload) + assert resp.status_code == 302 + order = Order.objects.get(purchaser=user, state=OrderStatus.FULFILLED) + assert ( + resp.url + == f"{settings.MIT_LEARN_DASHBOARD_URL}?order_status=fulfilled&order_id={order.id}" + ) + + +@pytest.mark.skip_nplusone_check +@pytest.mark.dont_mock_enrollments +def test_checkout_result_no_learn_redirect_without_global_id( + settings, + user_client, + products, + mocker, +): + """Fulfilled purchases fall back to legacy dashboard when user has no global_id.""" + settings.OPENEDX_SERVICE_WORKER_API_TOKEN = "mock_api_token" # noqa: S105 + + user_no_global_id = UserFactory.create(global_id=None) + user_client.force_login(user_no_global_id) + + mocker.patch("hubspot_sync.tasks.sync_deal_with_hubspot.apply_async") + mocker.patch( + "mitol.payment_gateway.api.PaymentGateway.validate_processor_response", + return_value=True, + ) + mock_is_enabled = mocker.patch( + "ecommerce.views.legacy.is_posthog_enabled", + ) + + create_basket(user_no_global_id, products) + + checkout_payload = create_pending_order(user_no_global_id) + + payload = checkout_payload["payload"] payload = { **{f"req_{key}": value for key, value in payload.items()}, "decision": "ACCEPT", @@ -688,7 +745,8 @@ def test_checkout_result_redirects_uai_b2c_program_to_learn_dashboard( resp = user_client.post(reverse("checkout-result-callback"), payload) assert resp.status_code == 302 - assert resp.url == settings.MIT_LEARN_DASHBOARD_URL + assert resp.url == reverse("user-dashboard") + mock_is_enabled.assert_not_called() @pytest.mark.parametrize( @@ -959,16 +1017,12 @@ def test_discount_redemptions_api( assert resp.status_code == 200 - if zerovalue: - with pytest.raises(TypeError) as exc: - resp = user_drf_client.post(reverse("checkout_api-start_checkout")) + checkout_payload = create_pending_order(user) - assert "HttpResponseRedirect" in str(exc) + if zerovalue: + assert "no_checkout" in checkout_payload else: - resp = user_drf_client.post(reverse("checkout_api-start_checkout")) - assert resp.status_code == 200 - - # 100% discount will redirect to user dashboard + assert "payload" in checkout_payload resp = admin_drf_client.get(f"/api/discounts/{discount.id}/redemptions/") assert resp.status_code == 200 @@ -1089,9 +1143,9 @@ def test_checkout_api_result( # noqa: PLR0913 ) basket = create_basket(user, products) - resp = user_client.post(reverse("checkout_api-start_checkout")) + checkout_payload = create_pending_order(user) - payload = resp.json()["payload"] + payload = checkout_payload["payload"] payload = { **{f"req_{key}": value for key, value in payload.items()}, "decision": decision, @@ -1099,17 +1153,10 @@ def test_checkout_api_result( # noqa: PLR0913 "transaction_id": "12345", } - # Load the pending order from the DB(factory) - should match the ref# in - # the payload we get back - order = Order.objects.get(state=OrderStatus.PENDING, purchaser=user) assert order.reference_number == payload["req_reference_number"] - # This is kind of cheating - CyberSource will send back a payload that is - # signed, but here we're just passing the payload as we got it back from - # the start checkout call. - resp = api_client.post(reverse("checkout_result_api"), payload) # checkout_result_api will always respond with a 200 unless validate_processor_response returns false @@ -1155,9 +1202,10 @@ def test_checkout_api_result_verification_failure( ) create_basket(user, products) - resp = user_client.post(reverse("checkout_api-start_checkout")) - payload = resp.json()["payload"] + checkout_payload = create_pending_order(user) + + payload = checkout_payload["payload"] payload = { **{f"req_{key}": value for key, value in payload.items()}, "decision": OrderStatus.PENDING, @@ -1194,10 +1242,9 @@ def test_checkout_api_result_program_accept( basket = create_basket_with_product(user, product) - resp = user_client.post(reverse("checkout_api-start_checkout")) - assert resp.status_code == 200 + checkout_payload = create_pending_order(user) - payload = resp.json()["payload"] + payload = checkout_payload["payload"] payload = { **{f"req_{key}": value for key, value in payload.items()}, "decision": "ACCEPT", @@ -1280,6 +1327,37 @@ def test_start_checkout_with_zero_value(settings, user, user_client, products): ) +@pytest.mark.skip_nplusone_check +def test_start_checkout_with_zero_value_redirects_to_learn( + settings, user, user_client, products, mocker +): + """ + Check that a zero-value checkout redirects to Learn dashboard when flag is enabled. + """ + settings.OPENEDX_SERVICE_WORKER_API_TOKEN = "mock_api_token" # noqa: S105 + settings.MIT_LEARN_DASHBOARD_URL = "https://learn.mit.edu/dashboard" + + mocker.patch( + "ecommerce.views.legacy.is_posthog_enabled", + return_value=True, + ) + + discount = DiscountFactory.create( + discount_type=DISCOUNT_TYPE_PERCENT_OFF, amount=100 + ) + test_redeem_discount(user, user_client, products, [discount], False, False) # noqa: FBT003 + + resp = user_client.get(reverse("checkout_interstitial_page")) + + assert resp.status_code == 302 + order = Order.objects.filter(purchaser=user).get() + assert ( + resp.url + == f"{settings.MIT_LEARN_DASHBOARD_URL}?order_status=fulfilled&order_id={order.id}" + ) + assert USER_MSG_COOKIE_NAME not in resp.cookies + + @pytest.mark.skip_nplusone_check def test_start_checkout_and_ensure_edx_username_created(mocker, settings, products): """ @@ -1391,10 +1469,8 @@ def test_program_product_purchasing(user, user_drf_client): basket = BasketFactory.create(user=user) BasketItem.objects.create(basket=basket, product=product) - with pytest.raises(TypeError) as exc: - user_drf_client.post(reverse("checkout_api-start_checkout")) - - assert "HttpResponseRedirect" in str(exc) + checkout_payload = create_pending_order(user) + assert "no_checkout" in checkout_payload assert ProgramEnrollment.objects.filter( user=user, enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE From c70aa90fba5ccbecb158caba4adff14aa77bb395 Mon Sep 17 00:00:00 2001 From: Doof Date: Wed, 8 Apr 2026 18:00:55 +0000 Subject: [PATCH 4/4] Release 1.145.2 --- RELEASE.rst | 7 +++++++ main/settings.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index b7f63750e4..09247273f0 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,13 @@ Release Notes ============= +Version 1.145.2 +--------------- + +- Indicate order status on redirect via query params for MIT Learn (#3464) +- Fix flakey discount-related tests (#3472) +- Fix Cart -> Learn dashboard for UAI industry verticals (#3466) + Version 1.145.1 (Released April 08, 2026) --------------- diff --git a/main/settings.py b/main/settings.py index 59feeb1403..6eaab58d50 100644 --- a/main/settings.py +++ b/main/settings.py @@ -37,7 +37,7 @@ from main.sentry import init_sentry from openapi.settings_spectacular import open_spectacular_settings -VERSION = "1.145.1" +VERSION = "1.145.2" log = logging.getLogger()