From 665107f8aaa4ad7832185edbef07014a9b2c47e5 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 21 Oct 2026 09:44:46 -0400 Subject: [PATCH 1/9] build: add responses to the testing dependency group httpretty has had no release since 2021 and patches sockets globally, which is how it broke MongoDB connections under pymongo 4.12+ in #39246. `responses` mocks at the `requests` adapter level instead, which covers every HTTP call the httpretty-based tests exercise, and it is already used by the user retirement scripts in this repo. This adds it so the httpretty tests can be migrated one app at a time; httpretty itself is removed once the last use is gone. Co-Authored-By: Claude Fable 5.1 --- pyproject.toml | 1 + requirements/edx/development.txt | 4 ++++ uv.lock | 22 ++++++++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 68ab6af4e578..8cd5840942bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -195,6 +195,7 @@ testing = [ "pytest-randomly", # pytest plugin to randomly order tests "pytest-reportlog", # Per-test timing data including setup/teardown (used for shard rebalancing) "pytest-xdist[psutil]", # Parallel execution of tests on multiple CPU cores or hosts + "responses", # Mocks HTTP responses at the requests library level, used in many tests "singledispatch", # Backport of functools.singledispatch from Python 3.4+, used in tests of XBlock rendering "testfixtures", # Provides a LogCapture utility used by several tests "unidiff", # Required by coverage_pytest_plugin diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index d0514d0ca271..4af590709a3e 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1230,6 +1230,7 @@ pyyaml==6.0.3 # edx-i18n-tools # jsondiff # openedx-platform + # responses # sphinx-autoapi # sphinxcontrib-openapi # swagger-spec-validator @@ -1270,6 +1271,7 @@ requests==2.34.2 # pylti1p3 # python-swiftclient # requests-oauthlib + # responses # sailthru-client # slumber # snowflake-connector-python @@ -1281,6 +1283,7 @@ requests-oauthlib==2.0.0 # via # openedx-platform # social-auth-core +responses==0.26.3 rich==15.0.0 # via import-linter roman-numerals==4.1.0 @@ -1525,6 +1528,7 @@ urllib3==2.8.0 # botocore # elasticsearch # requests + # responses # types-requests uv==0.13.1 # via tox-uv diff --git a/uv.lock b/uv.lock index bf4108f1ef28..d9402ce141f6 100644 --- a/uv.lock +++ b/uv.lock @@ -4911,6 +4911,7 @@ development = [ { name = "pytest-xdist", extra = ["psutil"] }, { name = "pywatchman" }, { name = "recommender-xblock" }, + { name = "responses" }, { name = "ruff" }, { name = "singledispatch" }, { name = "sphinx" }, @@ -4969,6 +4970,7 @@ django42 = [ { name = "pytest-reportlog" }, { name = "pytest-xdist", extra = ["psutil"], marker = "extra == 'group-16-openedx-platform-django42'" }, { name = "recommender-xblock" }, + { name = "responses" }, { name = "ruff" }, { name = "singledispatch" }, { name = "staff-graded-xblock" }, @@ -5017,6 +5019,7 @@ django52 = [ { name = "pytest-reportlog" }, { name = "pytest-xdist", extra = ["psutil"], marker = "extra == 'group-16-openedx-platform-django52'" }, { name = "recommender-xblock" }, + { name = "responses" }, { name = "ruff" }, { name = "singledispatch" }, { name = "staff-graded-xblock" }, @@ -5090,6 +5093,7 @@ testing = [ { name = "pytest-reportlog" }, { name = "pytest-xdist", extra = ["psutil"] }, { name = "recommender-xblock" }, + { name = "responses" }, { name = "ruff" }, { name = "singledispatch" }, { name = "staff-graded-xblock" }, @@ -5317,6 +5321,7 @@ development = [ { name = "pytest-xdist", extras = ["psutil"] }, { name = "pywatchman" }, { name = "recommender-xblock" }, + { name = "responses" }, { name = "ruff" }, { name = "singledispatch" }, { name = "sphinx" }, @@ -5375,6 +5380,7 @@ django42 = [ { name = "pytest-reportlog" }, { name = "pytest-xdist", extras = ["psutil"] }, { name = "recommender-xblock" }, + { name = "responses" }, { name = "ruff" }, { name = "singledispatch" }, { name = "staff-graded-xblock" }, @@ -5423,6 +5429,7 @@ django52 = [ { name = "pytest-reportlog" }, { name = "pytest-xdist", extras = ["psutil"] }, { name = "recommender-xblock" }, + { name = "responses" }, { name = "ruff" }, { name = "singledispatch" }, { name = "staff-graded-xblock" }, @@ -5496,6 +5503,7 @@ testing = [ { name = "pytest-reportlog" }, { name = "pytest-xdist", extras = ["psutil"] }, { name = "recommender-xblock" }, + { name = "responses" }, { name = "ruff" }, { name = "singledispatch" }, { name = "staff-graded-xblock" }, @@ -7011,6 +7019,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, ] +[[package]] +name = "responses" +version = "0.26.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/47/f216a33221db8eff328987661cf18371afee89c62a62b434b963d6b509c9/responses-0.26.3.tar.gz", hash = "sha256:b0c11ca8131b8b227b8d5108e6ed39772222bd5aab030ed430e8f99057c4c409", size = 86335, upload-time = "2026-08-26T19:17:24.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/86/ca7958de70cb0752350575e98229368a3a2f746a2942034b3364e17312bb/responses-0.26.3-py3-none-any.whl", hash = "sha256:74474f799334ac4f37d93b6437ecc3bb1bb5c77a8d31780a338643be2dce0af8", size = 36289, upload-time = "2026-08-26T19:17:23.176Z" }, +] + [[package]] name = "rich" version = "15.0.0" From 38c3f20ca2839aa354bd124a640a8186ae602028 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 21 Oct 2026 09:57:37 -0400 Subject: [PATCH 2/9] test: drop the dead comments-service HTTP mocks from the discussion tests `CommentsServiceMockMixin` registered httpretty responses for the old cs_comments_service endpoints, but since the move to openedx-forum every discussion test resolves the same `register_*` names to `ForumMockUtilsMixin`, which mocks the forum API's Python functions. The only remaining references were an unused import, a base class that was shadowed in the MRO, and `httpretty.enable()` calls in setUp that never had a URL registered against them. Remove the mixin, the request-parsing helpers and callbacks only it used, and the vestigial httpretty setup in the serializer tests. Co-Authored-By: Claude Fable 5.1 --- .../discussion/rest_api/tests/test_api_v2.py | 1 - .../rest_api/tests/test_serializers_v2.py | 13 - .../discussion/rest_api/tests/test_utils.py | 3 +- .../discussion/rest_api/tests/utils.py | 533 ------------------ 4 files changed, 1 insertion(+), 549 deletions(-) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py index 22449eaac14c..a311d3932b14 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py @@ -59,7 +59,6 @@ from lms.djangoapps.discussion.rest_api.tests.utils import ( ForumMockUtilsMixin, make_paginated_api_response, - parsed_body, # noqa: F401 ) from lms.djangoapps.discussion.tests.utils import make_minimal_cs_comment, make_minimal_cs_thread from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup diff --git a/lms/djangoapps/discussion/rest_api/tests/test_serializers_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_serializers_v2.py index 106494f80b1a..17f8a1ca9938 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_serializers_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_serializers_v2.py @@ -7,7 +7,6 @@ from unittest import mock import ddt -import httpretty from django.test import override_settings from django.test.client import RequestFactory @@ -52,10 +51,6 @@ def tearDownClass(cls): def setUp(self): super().setUp() - httpretty.reset() - httpretty.enable() - self.addCleanup(httpretty.reset) - self.addCleanup(httpretty.disable) patcher = mock.patch( "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" ) @@ -407,10 +402,6 @@ def tearDownClass(cls): def setUp(self): super().setUp() - httpretty.reset() - httpretty.enable() - self.addCleanup(httpretty.reset) - self.addCleanup(httpretty.disable) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/dummy") @@ -551,10 +542,6 @@ def tearDownClass(cls): def setUp(self): super().setUp() - httpretty.reset() - httpretty.enable() - self.addCleanup(httpretty.reset) - self.addCleanup(httpretty.disable) patcher = mock.patch( "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" ) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_utils.py b/lms/djangoapps/discussion/rest_api/tests/test_utils.py index 7f67804f77aa..827a6850fec0 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_utils.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_utils.py @@ -11,7 +11,6 @@ from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from lms.djangoapps.discussion.django_comment_client.tests.factories import RoleFactory -from lms.djangoapps.discussion.rest_api.tests.utils import CommentsServiceMockMixin from lms.djangoapps.discussion.rest_api.utils import ( discussion_open_for_user, get_archived_topics, @@ -181,7 +180,7 @@ def test_remove_empty_sequentials(self): @ddt.ddt -class TestBlackoutDates(CommentsServiceMockMixin, ModuleStoreTestCase): +class TestBlackoutDates(ModuleStoreTestCase): """ Test for the is_posting_allowed function """ diff --git a/lms/djangoapps/discussion/rest_api/tests/utils.py b/lms/djangoapps/discussion/rest_api/tests/utils.py index d4dda7ea4cec..07069284252f 100644 --- a/lms/djangoapps/discussion/rest_api/tests/utils.py +++ b/lms/djangoapps/discussion/rest_api/tests/utils.py @@ -5,12 +5,9 @@ import hashlib import json -import re from contextlib import closing from datetime import datetime -from urllib.parse import parse_qs -import httpretty from PIL import Image from pytz import UTC @@ -21,38 +18,6 @@ from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image -def _get_thread_callback(thread_data): - """ - Get a callback function that will return POST/PUT data overridden by - response_overrides. - """ - def callback(request, _uri, headers): - """ - Simulate the thread creation or update endpoint by returning the provided - data along with the data from response_overrides and dummy values for any - additional required fields. - """ - response_data = make_minimal_cs_thread(thread_data) - original_data = response_data.copy() - for key, val_list in parsed_body(request).items(): - val = val_list[0] - if key in ["anonymous", "anonymous_to_peers", "closed", "pinned"]: - response_data[key] = val == "True" - elif key == "edit_reason_code": - response_data["edit_history"] = [ - { - "original_body": original_data["body"], - "author": thread_data.get('username'), - "reason_code": val, - }, - ] - else: - response_data[key] = val - return (200, headers, json.dumps(response_data)) - - return callback - - def make_thread_callback(thread_data): """ Returns a function that simulates thread creation/update behavior, @@ -81,41 +46,6 @@ def callback(*args, **kwargs): return callback -def _get_comment_callback(comment_data, thread_id, parent_id): - """ - Get a callback function that will return a comment containing the given data - plus necessary dummy data, overridden by the content of the POST/PUT - request. - """ - def callback(request, _uri, headers): - """ - Simulate the comment creation or update endpoint as described above. - """ - response_data = make_minimal_cs_comment(comment_data) - original_data = response_data.copy() - # thread_id and parent_id are not included in request payload but - # are returned by the comments service - response_data["thread_id"] = thread_id - response_data["parent_id"] = parent_id - for key, val_list in parsed_body(request).items(): - val = val_list[0] - if key in ["anonymous", "anonymous_to_peers", "endorsed"]: - response_data[key] = val == "True" - elif key == "edit_reason_code": - response_data["edit_history"] = [ - { - "original_body": original_data["body"], - "author": comment_data.get('username'), - "reason_code": val, - }, - ] - else: - response_data[key] = val - return (200, headers, json.dumps(response_data)) - - return callback - - def make_comment_callback(comment_data, thread_id, parent_id): """ Returns a callable that mimics comment creation or update behavior, @@ -158,441 +88,6 @@ def callback(*args, **kwargs): return callback -class CommentsServiceMockMixin: - """Mixin with utility methods for mocking the comments service""" - - def register_get_threads_response(self, threads, page, num_pages): - """Register a mock response for GET on the CS thread list endpoint""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - - httpretty.register_uri( - httpretty.GET, - "http://localhost:4567/api/v1/threads", - body=json.dumps({ - "collection": threads, - "page": page, - "num_pages": num_pages, - "thread_count": len(threads), - }), - status=200 - ) - - def register_get_course_commentable_counts_response(self, course_id, thread_counts): - """Register a mock response for GET on the CS thread list endpoint""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - - httpretty.register_uri( - httpretty.GET, - f"http://localhost:4567/api/v1/commentables/{course_id}/counts", - body=json.dumps(thread_counts), - status=200 - ) - - def register_get_threads_search_response(self, threads, rewrite, num_pages=1): - """Register a mock response for GET on the CS thread search endpoint""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - httpretty.register_uri( - httpretty.GET, - "http://localhost:4567/api/v1/search/threads", - body=json.dumps({ - "collection": threads, - "page": 1, - "num_pages": num_pages, - "corrected_text": rewrite, - "thread_count": len(threads), - }), - status=200 - ) - - def register_post_thread_response(self, thread_data): - """Register a mock response for POST on the CS commentable endpoint""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - httpretty.register_uri( - httpretty.POST, - re.compile(r"http://localhost:4567/api/v1/(\w+)/threads"), - body=_get_thread_callback(thread_data) - ) - - def register_put_thread_response(self, thread_data): - """ - Register a mock response for PUT on the CS endpoint for the given - thread_id. - """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - httpretty.register_uri( - httpretty.PUT, - "http://localhost:4567/api/v1/threads/{}".format(thread_data["id"]), - body=_get_thread_callback(thread_data) - ) - - def register_get_thread_error_response(self, thread_id, status_code): - """Register a mock error response for GET on the CS thread endpoint.""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - httpretty.register_uri( - httpretty.GET, - f"http://localhost:4567/api/v1/threads/{thread_id}", - body="", - status=status_code - ) - - def register_get_thread_response(self, thread): - """ - Register a mock response for GET on the CS thread instance endpoint. - """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - httpretty.register_uri( - httpretty.GET, - "http://localhost:4567/api/v1/threads/{id}".format(id=thread["id"]), - body=json.dumps(thread), - status=200 - ) - - def register_get_comments_response(self, comments, page, num_pages): - """Register a mock response for GET on the CS comments list endpoint""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - - httpretty.register_uri( - httpretty.GET, - "http://localhost:4567/api/v1/comments", - body=json.dumps({ - "collection": comments, - "page": page, - "num_pages": num_pages, - "comment_count": len(comments), - }), - status=200 - ) - - def register_post_comment_response(self, comment_data, thread_id, parent_id=None): - """ - Register a mock response for POST on the CS comments endpoint for the - given thread or parent; exactly one of thread_id and parent_id must be - specified. - """ - if parent_id: - url = f"http://localhost:4567/api/v1/comments/{parent_id}" - else: - url = f"http://localhost:4567/api/v1/threads/{thread_id}/comments" - - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - httpretty.register_uri( - httpretty.POST, - url, - body=_get_comment_callback(comment_data, thread_id, parent_id) - ) - - def register_put_comment_response(self, comment_data): - """ - Register a mock response for PUT on the CS endpoint for the given - comment data (which must include the key "id"). - """ - thread_id = comment_data["thread_id"] - parent_id = comment_data.get("parent_id") - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - httpretty.register_uri( - httpretty.PUT, - "http://localhost:4567/api/v1/comments/{}".format(comment_data["id"]), - body=_get_comment_callback(comment_data, thread_id, parent_id) - ) - - def register_get_comment_error_response(self, comment_id, status_code): - """ - Register a mock error response for GET on the CS comment instance - endpoint. - """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - httpretty.register_uri( - httpretty.GET, - f"http://localhost:4567/api/v1/comments/{comment_id}", - body="", - status=status_code - ) - - def register_get_comment_response(self, response_overrides): - """ - Register a mock response for GET on the CS comment instance endpoint. - """ - comment = make_minimal_cs_comment(response_overrides) - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - httpretty.register_uri( - httpretty.GET, - "http://localhost:4567/api/v1/comments/{id}".format(id=comment["id"]), - body=json.dumps(comment), - status=200 - ) - - def register_get_user_response(self, user, subscribed_thread_ids=None, upvoted_ids=None): - """Register a mock response for GET on the CS user instance endpoint""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - httpretty.register_uri( - httpretty.GET, - f"http://localhost:4567/api/v1/users/{user.id}", - body=json.dumps({ - "id": str(user.id), - "subscribed_thread_ids": subscribed_thread_ids or [], - "upvoted_ids": upvoted_ids or [], - }), - status=200 - ) - - def register_get_user_retire_response(self, user, status=200, body=""): - """Register a mock response for GET on the CS user retirement endpoint""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - httpretty.register_uri( - httpretty.POST, - f"http://localhost:4567/api/v1/users/{user.id}/retire", - body=body, - status=status - ) - - def register_get_username_replacement_response(self, user, status=200, body=""): - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - httpretty.register_uri( - httpretty.POST, - f"http://localhost:4567/api/v1/users/{user.id}/replace_username", - body=body, - status=status - ) - - def register_subscribed_threads_response(self, user, threads, page, num_pages): - """Register a mock response for GET on the CS user instance endpoint""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - httpretty.register_uri( - httpretty.GET, - f"http://localhost:4567/api/v1/users/{user.id}/subscribed_threads", - body=json.dumps({ - "collection": threads, - "page": page, - "num_pages": num_pages, - "thread_count": len(threads), - }), - status=200 - ) - - def register_course_stats_response(self, course_key, stats, page, num_pages): - """Register a mock response for GET on the CS user course stats instance endpoint""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - httpretty.register_uri( - httpretty.GET, - f"http://localhost:4567/api/v1/users/{course_key}/stats", - body=json.dumps({ - "user_stats": stats, - "page": page, - "num_pages": num_pages, - "count": len(stats), - }), - status=200 - ) - - def register_subscription_response(self, user): - """ - Register a mock response for POST and DELETE on the CS user subscription - endpoint - """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - for method in [httpretty.POST, httpretty.DELETE]: - httpretty.register_uri( - method, - f"http://localhost:4567/api/v1/users/{user.id}/subscriptions", - body=json.dumps({}), # body is unused - status=200 - ) - - def register_thread_votes_response(self, thread_id): - """ - Register a mock response for PUT and DELETE on the CS thread votes - endpoint - """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - for method in [httpretty.PUT, httpretty.DELETE]: - httpretty.register_uri( - method, - f"http://localhost:4567/api/v1/threads/{thread_id}/votes", - body=json.dumps({}), # body is unused - status=200 - ) - - def register_comment_votes_response(self, comment_id): - """ - Register a mock response for PUT and DELETE on the CS comment votes - endpoint - """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - for method in [httpretty.PUT, httpretty.DELETE]: - httpretty.register_uri( - method, - f"http://localhost:4567/api/v1/comments/{comment_id}/votes", - body=json.dumps({}), # body is unused - status=200 - ) - - def register_flag_response(self, content_type, content_id): - """Register a mock response for PUT on the CS flag endpoints""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - for path in ["abuse_flag", "abuse_unflag"]: - httpretty.register_uri( - "PUT", - "http://localhost:4567/api/v1/{content_type}s/{content_id}/{path}".format( # noqa: UP032 - content_type=content_type, - content_id=content_id, - path=path - ), - body=json.dumps({}), # body is unused - status=200 - ) - - def register_read_response(self, user, content_type, content_id): - """ - Register a mock response for POST on the CS 'read' endpoint - """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - httpretty.register_uri( - httpretty.POST, - f"http://localhost:4567/api/v1/users/{user.id}/read", - params={'source_type': content_type, 'source_id': content_id}, - body=json.dumps({}), # body is unused - status=200 - ) - - def register_thread_flag_response(self, thread_id): - """Register a mock response for PUT on the CS thread flag endpoints""" - self.register_flag_response("thread", thread_id) - - def register_comment_flag_response(self, comment_id): - """Register a mock response for PUT on the CS comment flag endpoints""" - self.register_flag_response("comment", comment_id) - - def register_delete_thread_response(self, thread_id): - """ - Register a mock response for DELETE on the CS thread instance endpoint - """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - httpretty.register_uri( - httpretty.DELETE, - f"http://localhost:4567/api/v1/threads/{thread_id}", - body=json.dumps({}), # body is unused - status=200 - ) - - def register_delete_comment_response(self, comment_id): - """ - Register a mock response for DELETE on the CS comment instance endpoint - """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - httpretty.register_uri( - httpretty.DELETE, - f"http://localhost:4567/api/v1/comments/{comment_id}", - body=json.dumps({}), # body is unused - status=200 - ) - - def register_user_active_threads(self, user_id, response): - """ - Register a mock response for GET on the CS comment active threads endpoint - """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - httpretty.register_uri( - httpretty.GET, - f"http://localhost:4567/api/v1/users/{user_id}/active_threads", - body=json.dumps(response), - status=200 - ) - - def register_get_subscriptions(self, thread_id, response): - """ - Register a mock response for GET on the CS comment active threads endpoint - """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' - httpretty.register_uri( - httpretty.GET, - f"http://localhost:4567/api/v1/threads/{thread_id}/subscriptions", - body=json.dumps(response), - status=200 - ) - - def assert_query_params_equal(self, httpretty_request, expected_params): - """ - Assert that the given mock request had the expected query parameters - """ - actual_params = dict(querystring(httpretty_request)) - actual_params.pop("request_id") # request_id is random - assert actual_params == expected_params - - def assert_last_query_params(self, expected_params): - """ - Assert that the last mock request had the expected query parameters - """ - self.assert_query_params_equal(httpretty.last_request(), expected_params) - - def request_patch(self, request_data): - """ - make a request to PATCH endpoint and return response - """ - return self.client.patch( - self.url, - json.dumps(request_data), - content_type="application/merge-patch+json" - ) - - def expected_thread_data(self, overrides=None): - """ - Returns expected thread data in API response - """ - response_data = { - "anonymous": False, - "anonymous_to_peers": False, - "author": self.user.username, - "author_label": None, - "created_at": "1970-01-01T00:00:00Z", - "updated_at": "1970-01-01T00:00:00Z", - "raw_body": "Test body", - "rendered_body": "

Test body

", - "preview_body": "Test body", - "abuse_flagged": False, - "abuse_flagged_count": None, - "voted": False, - "vote_count": 0, - "editable_fields": [ - "abuse_flagged", - "anonymous", - "copy_link", - "following", - "raw_body", - "read", - "title", - "topic_id", - "type", - ], - "course_id": str(self.course.id), - "topic_id": "test_topic", - "group_id": None, - "group_name": None, - "title": "Test Title", - "pinned": False, - "closed": False, - "can_delete": True, - "following": False, - "comment_count": 1, - "unread_comment_count": 0, - "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", - "endorsed_comment_list_url": None, - "non_endorsed_comment_list_url": None, - "read": False, - "has_endorsed": False, - "id": "test_thread", - "type": "discussion", - "response_count": 0, - "last_edit": None, - "edit_by_label": None, - "closed_by": None, - "closed_by_label": None, - "close_reason": None, - "close_reason_code": None, - } - response_data.update(overrides or {}) - return response_data - - class ForumMockUtilsMixin(MockForumApiMixin): """Mixin with utility methods for mocking the comments service""" @@ -747,20 +242,6 @@ def register_comment_flag_response(self, comment_id): """Register a mock response for PUT on the CS comment flag endpoints""" self.register_flag_response("comment", comment_id) - def assert_query_params_equal(self, httpretty_request, expected_params): - """ - Assert that the given mock request had the expected query parameters - """ - actual_params = dict(querystring(httpretty_request)) - actual_params.pop("request_id") # request_id is random - assert actual_params == expected_params - - def assert_last_query_params(self, expected_params): - """ - Assert that the last mock request had the expected query parameters - """ - self.assert_query_params_equal(httpretty.last_request(), expected_params) - def request_patch(self, request_data): """ make a request to PATCH endpoint and return response @@ -965,20 +446,6 @@ def get_expected_user_profile(self, username): } -def parsed_body(request): - """Returns a parsed dictionary version of a request body""" - # This could just be HTTPrettyRequest.parsed_body, but that method double-decodes '%2B' -> '+' -> ' '. - # You can just remove this method when this issue is fixed: https://github.com/gabrielfalcao/HTTPretty/issues/240 - return parse_qs(request.body.decode('utf8')) - - -def querystring(request): - """Returns a parsed dictionary version of a query string""" - # This could just be HTTPrettyRequest.querystring, but that method double-decodes '%2B' -> '+' -> ' '. - # You can just remove this method when this issue is fixed: https://github.com/gabrielfalcao/HTTPretty/issues/240 - return parse_qs(request.path.split('?', 1)[-1]) - - class ThreadMock(object): # noqa: UP004 """ A mock thread object From be7dba31eb26b0e7f1be39f9174b2db4aca2782e Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 21 Oct 2026 09:57:37 -0400 Subject: [PATCH 3/9] test: migrate the commerce tests from httpretty to responses The commerce mock context managers used to enable and disable httpretty themselves, which is why nested uses needed `reset_on_exit=False` and the module docstring warned against mixing them with direct httpretty use. They now register on the `responses` default registry and the caller keeps `responses` active, via `@responses.activate` or `responses.start()` in setUp. `expect_called` compares the number of requests to the mocked endpoint before and after the block instead of inspecting the global last request, so it no longer depends on what other mocks did. The verify_student, refunds and course_modes tests that depend on these mocks are converted in the same commit. Request bodies are passed to `json.loads` directly, which accepts both `str` and `bytes`, since `responses` hands back the prepared request body as either depending on how the client sent it. Co-Authored-By: Claude Fable 5.1 --- .../course_modes/tests/test_views.py | 10 +-- .../djangoapps/student/tests/test_refunds.py | 40 +++++------ .../commerce/api/v0/tests/test_views.py | 3 + .../commerce/api/v1/tests/test_views.py | 3 + lms/djangoapps/commerce/tests/__init__.py | 10 +-- lms/djangoapps/commerce/tests/mocks.py | 68 ++++++++++--------- lms/djangoapps/commerce/tests/test_signals.py | 23 ++++--- lms/djangoapps/commerce/tests/test_utils.py | 58 ++++++++-------- .../verify_student/tests/test_views.py | 24 ++++--- 9 files changed, 127 insertions(+), 112 deletions(-) diff --git a/common/djangoapps/course_modes/tests/test_views.py b/common/djangoapps/course_modes/tests/test_views.py index b4b4350aba1a..52d9f03e383b 100644 --- a/common/djangoapps/course_modes/tests/test_views.py +++ b/common/djangoapps/course_modes/tests/test_views.py @@ -10,7 +10,7 @@ import ddt import freezegun -import httpretty +import responses from django.conf import settings from django.test import override_settings from django.urls import reverse @@ -63,7 +63,7 @@ def setUp(self): self.client.login(username=self.user.username, password="edx") @skip_unless_lms - @httpretty.activate + @responses.activate @ddt.data( # is_active?, enrollment_mode, redirect?, has_started (True, 'verified', True, False), @@ -148,7 +148,7 @@ def test_no_id_redirect_otto(self): self.assertRedirects(response, '/test_basket/add/?sku=TEST', fetch_redirect_response=False) ecomm_test_utils.update_commerce_config(enabled=False) - @httpretty.activate + @responses.activate @ddt.data( '', '1,,2', @@ -456,7 +456,7 @@ def test_multiple_mode_creation(self): @skip_unless_lms @with_comprehensive_theme("edx.org") - @httpretty.activate + @responses.activate def test_hide_nav(self): # Create the course modes for mode in ["honor", "verified"]: @@ -688,7 +688,7 @@ def test_embargo_restrict(self): response = self.client.get(self.url) self.assertRedirects(response, redirect_url) - @httpretty.activate + @responses.activate def test_embargo_allow(self): response = self.client.get(self.url) assert response.status_code == 200 diff --git a/common/djangoapps/student/tests/test_refunds.py b/common/djangoapps/student/tests/test_refunds.py index 2b1bf8c8ab4c..bb775a0132ee 100644 --- a/common/djangoapps/student/tests/test_refunds.py +++ b/common/djangoapps/student/tests/test_refunds.py @@ -9,7 +9,7 @@ from zoneinfo import ZoneInfo import ddt -import httpretty +import responses # Explicitly import the cache from ConfigurationModel so we can reset it after each test from config_models.models import cache @@ -129,7 +129,7 @@ def test_refundable_with_cutoff_date(self, cutoff_date): (timedelta(days=2), timedelta(days=1), timedelta(days=2), 1), ) @ddt.unpack - @httpretty.activate + @responses.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): """ @@ -143,11 +143,11 @@ def test_refund_cutoff_date(self, order_date_delta, course_start_delta, expected date_placed = order_date.strftime(ECOMMERCE_DATE_FORMAT) expected_content = f'{{"date_placed": "{date_placed}"}}' - httpretty.register_uri( - httpretty.GET, + responses.add( + responses.GET, f'{TEST_API_URL}/orders/{self.ORDER_NUMBER}/', status=200, body=expected_content, - adding_headers={'Content-Type': JSON} + content_type=JSON ) self.enrollment.course_overview.start = course_start @@ -178,7 +178,7 @@ def test_refund_cutoff_date(self, order_date_delta, course_start_delta, expected (datetime.now(ZoneInfo("UTC")) - timedelta(minutes=5), False), ) @ddt.unpack - @httpretty.activate + @responses.activate @override_settings(ECOMMERCE_API_URL=TEST_API_URL) def test_is_order_voucher_refundable(self, voucher_expiration_date, expected): """ @@ -186,11 +186,11 @@ def test_is_order_voucher_refundable(self, voucher_expiration_date, expected): """ 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, + responses.add( + responses.GET, f'{TEST_API_URL}/orders/{self.ORDER_NUMBER}/', status=200, body=response, - adding_headers={'Content-Type': JSON} + content_type=JSON ) self.enrollment.attributes.create( @@ -205,7 +205,7 @@ 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 + @responses.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.""" @@ -214,11 +214,11 @@ def test_is_order_voucher_refundable_no_attributes(self, ): # no voucher information in orders api response response = json.dumps({"vouchers": []}) - httpretty.register_uri( - httpretty.GET, + responses.add( + responses.GET, f'{TEST_API_URL}/orders/{self.ORDER_NUMBER}/', status=200, body=response, - adding_headers={'Content-Type': JSON} + content_type=JSON ) self.enrollment.attributes.create( @@ -230,11 +230,11 @@ def test_is_order_voucher_refundable_no_attributes(self, ): assert self.enrollment.is_order_voucher_refundable() is False response = json.dumps({"vouchers": None}) - httpretty.register_uri( - httpretty.GET, + responses.add( + responses.GET, f'{TEST_API_URL}/orders/{self.ORDER_NUMBER}/', status=200, body=response, - adding_headers={'Content-Type': JSON} + content_type=JSON ) assert self.enrollment.is_order_voucher_refundable() is False @@ -279,7 +279,7 @@ def test_refund_cutoff_date_with_date_placed_attr(self, mock_ecommerce_api_clien assert self.enrollment.refund_cutoff_date() == (order_date + refund_config.refund_window) mock_ecommerce_api_client.assert_not_called() - @httpretty.activate + @responses.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.""" @@ -287,11 +287,11 @@ def test_multiple_refunds_dashbaord_page_error(self): order_date = now + timedelta(days=1) expected_content = f'{{"date_placed": "{order_date.strftime(ECOMMERCE_DATE_FORMAT)}"}}' - httpretty.register_uri( - httpretty.GET, + responses.add( + responses.GET, f'{TEST_API_URL}/orders/{self.ORDER_NUMBER}/', status=200, body=expected_content, - adding_headers={'Content-Type': JSON} + content_type=JSON ) # creating multiple attributes for same order. diff --git a/lms/djangoapps/commerce/api/v0/tests/test_views.py b/lms/djangoapps/commerce/api/v0/tests/test_views.py index 5462ea6d1574..43b82f05a229 100644 --- a/lms/djangoapps/commerce/api/v0/tests/test_views.py +++ b/lms/djangoapps/commerce/api/v0/tests/test_views.py @@ -9,6 +9,7 @@ import ddt import pytz +import responses from django.test import TestCase from django.test.utils import override_settings from django.urls import reverse, reverse_lazy @@ -290,6 +291,7 @@ def setUp(self): super().setUp() self._login() + @responses.activate def test_order_found(self): """ If the order is located, the view should pass the data from the API. """ @@ -300,6 +302,7 @@ def test_order_found(self): actual = json.loads(response.content.decode('utf-8')) assert actual == self.MOCK_ORDER + @responses.activate def test_order_not_found(self): """ If the order is not found, the view should return a 404. """ with mock_basket_order(basket_id=1, status=404): diff --git a/lms/djangoapps/commerce/api/v1/tests/test_views.py b/lms/djangoapps/commerce/api/v1/tests/test_views.py index 2dd7120e3c7d..44aca58110c3 100644 --- a/lms/djangoapps/commerce/api/v1/tests/test_views.py +++ b/lms/djangoapps/commerce/api/v1/tests/test_views.py @@ -7,6 +7,7 @@ import ddt import pytz +import responses from django.conf import settings from django.contrib.auth.models import Permission from django.test import TestCase @@ -456,6 +457,7 @@ def setUp(self): super().setUp() self._login() + @responses.activate def test_order_found(self): """ If the order is located, the view should pass the data from the API. """ with mock_order_endpoint(order_number=self.ORDER_NUMBER, response=self.MOCK_ORDER): @@ -465,6 +467,7 @@ def test_order_found(self): actual = json.loads(response.content.decode('utf-8')) assert actual == self.MOCK_ORDER + @responses.activate def test_order_not_found(self): """ If the order is not found, the view should return a 404. """ with mock_order_endpoint(order_number=self.ORDER_NUMBER, status=404): diff --git a/lms/djangoapps/commerce/tests/__init__.py b/lms/djangoapps/commerce/tests/__init__.py index 633af5ac4c2b..bc9385ca7a5e 100644 --- a/lms/djangoapps/commerce/tests/__init__.py +++ b/lms/djangoapps/commerce/tests/__init__.py @@ -6,8 +6,8 @@ from urllib.parse import urljoin import ddt -import httpretty import requests # noqa: F401 +import responses from django.conf import settings from django.test import TestCase from edx_rest_api_client.auth import JwtAuth @@ -138,7 +138,7 @@ def setUp(self): self.user = UserFactory() self.base_url = get_ecommerce_api_base_url() - @httpretty.activate + @responses.activate def test_client_unicode(self): """ The client should handle json responses properly when they contain unicode character data. @@ -146,11 +146,11 @@ def test_client_unicode(self): Regression test for ECOM-1606. """ expected_content = '{"result": "Préparatoire"}' - httpretty.register_uri( - httpretty.GET, + responses.add( + responses.GET, f"{settings.ECOMMERCE_API_URL.strip('/')}/baskets/1/order/", status=200, body=expected_content, - adding_headers={'Content-Type': JSON}, + content_type=JSON, ) api_url = urljoin(f"{self.base_url}/", "baskets/1/order/") actual_object = get_ecommerce_api_client(self.user).get(api_url).json() diff --git a/lms/djangoapps/commerce/tests/mocks.py b/lms/djangoapps/commerce/tests/mocks.py index 4e6669cf27fb..926b3b2710e7 100644 --- a/lms/djangoapps/commerce/tests/mocks.py +++ b/lms/djangoapps/commerce/tests/mocks.py @@ -4,7 +4,7 @@ import json -import httpretty +import responses from django.conf import settings from . import factories @@ -14,20 +14,21 @@ class mock_ecommerce_api_endpoint: """ Base class for contextmanagers used to mock calls to api endpoints. - The contextmanager internally activates and deactivates httpretty as - required, therefore it is not advised to use this mock endpoint in - test cases where httpretty is being used directly. + The contextmanager registers its response with ``responses`` on entry and + checks on exit whether the endpoint was called, so the test (or its setUp) + must already have ``responses`` active, e.g. via ``@responses.activate`` + or ``responses.start()``. """ # override this in subclasses. default_response = None - # override this in subclasses, using one of httpretty's method constants + # override this in subclasses, using one of responses' method constants method = None host = settings.ECOMMERCE_API_URL.strip('/') - def __init__(self, response=None, status=200, expect_called=True, exception=None, reset_on_exit=True): + def __init__(self, response=None, status=200, expect_called=True, exception=None): """ Keyword Arguments: response: a JSON-serializable Python type representing the desired response body. @@ -36,23 +37,22 @@ def __init__(self, response=None, status=200, expect_called=True, exception=None to False if we should ensure that no request arrived, or None to skip checking if the request arrived exception: raise this exception instead of returning an HTTP response when called. - reset_on_exit (bool): Indicates if `httpretty` should be reset after the decorator exits. """ self.response = response or self.default_response self.status = status self.expect_called = expect_called self.exception = exception - self.reset_on_exit = reset_on_exit + self._calls_on_enter = 0 def get_uri(self): """ - Returns the uri to register with httpretty for this contextmanager. + Returns the uri to register with responses for this contextmanager. """ return self.host + '/' + self.get_path().lstrip('/') def get_path(self): """ - Returns the path of the URI to register with httpretty for this contextmanager. + Returns the path of the URI to register with responses for this contextmanager. Subclasses must override this method. @@ -61,38 +61,42 @@ def get_path(self): """ raise NotImplementedError - def _exception_body(self, request, uri, headers): - """Helper used to create callbacks in order to have httpretty raise Exceptions.""" - raise self.exception + def _matching_calls(self): + """Count the requests made so far to this contextmanager's endpoint.""" + uri = self.get_uri() + return sum( + 1 for call in responses.calls + if call.request.method == self.method and call.request.url.split('?', 1)[0] == uri + ) def __enter__(self): - httpretty.enable() - httpretty.register_uri( + self._calls_on_enter = self._matching_calls() + if self.exception is not None: + # responses raises an Exception instance given as the body instead of returning a response. + body = self.exception + else: + body = json.dumps(self.response) + responses.add( self.method, self.get_uri(), status=self.status, - body=self._exception_body if self.exception is not None else json.dumps(self.response), - adding_headers={'Content-Type': 'application/json'}, + body=body, + content_type='application/json', ) + return self def __exit__(self, exc_type, exc_val, exc_tb): if self.expect_called is None: - called_if_expected = True - else: - called_if_expected = self.expect_called == (bool(httpretty.last_request().headers)) - httpretty.disable() - - if self.reset_on_exit: - httpretty.reset() - - assert called_if_expected + return + called = self._matching_calls() > self._calls_on_enter + assert called == self.expect_called class mock_basket_order(mock_ecommerce_api_endpoint): """ Mocks calls to E-Commerce API client basket order method. """ default_response = {'number': 1} - method = httpretty.GET + method = responses.GET def __init__(self, basket_id, **kwargs): super().__init__(**kwargs) @@ -106,7 +110,7 @@ class mock_create_refund(mock_ecommerce_api_endpoint): """ Mocks calls to E-Commerce API client refund creation method. """ default_response = [] - method = httpretty.POST + method = responses.POST def get_path(self): return '/refunds/' @@ -118,7 +122,7 @@ class mock_payment_processors(mock_ecommerce_api_endpoint): """ default_response = ['foo', 'bar'] - method = httpretty.GET + method = responses.GET def get_path(self): return "/payment/processors/" @@ -128,7 +132,7 @@ class mock_process_refund(mock_ecommerce_api_endpoint): """ Mocks calls to E-Commerce API client refund process method. """ default_response = [] - method = httpretty.PUT + method = responses.PUT def __init__(self, refund_id, **kwargs): super().__init__(**kwargs) @@ -142,7 +146,7 @@ class mock_order_endpoint(mock_ecommerce_api_endpoint): """ Mocks calls to E-Commerce API client basket order method. """ default_response = {'number': 'EDX-100001'} - method = httpretty.GET + method = responses.GET def __init__(self, order_number, **kwargs): super().__init__(**kwargs) @@ -190,7 +194,7 @@ class mock_get_orders(mock_ecommerce_api_endpoint): ), ] } - method = httpretty.GET + method = responses.GET def get_path(self): return '/orders/' diff --git a/lms/djangoapps/commerce/tests/test_signals.py b/lms/djangoapps/commerce/tests/test_signals.py index 2dc3e2e77bbb..30047612d8d9 100644 --- a/lms/djangoapps/commerce/tests/test_signals.py +++ b/lms/djangoapps/commerce/tests/test_signals.py @@ -9,8 +9,8 @@ from urllib.parse import urljoin import ddt -import httpretty import pytest +import responses from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.test.utils import override_settings @@ -42,6 +42,9 @@ class TestRefundSignal(ModuleStoreTestCase): def setUp(self): super().setUp() + responses.start() + self.addCleanup(responses.stop) + self.addCleanup(responses.reset) # Ensure the E-Commerce service user exists UserFactory(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME, is_staff=True) @@ -152,8 +155,8 @@ def test_notification_when_approval_fails(self, mock_send_notification): 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): + with mock_process_refund(refund_id): + with mock_process_refund(failed_refund_id, status=500): self.send_signal() assert mock_send_notification.called mock_send_notification.assert_called_with(self.course_enrollment.user, [failed_refund_id]) @@ -180,12 +183,12 @@ def test_no_notification_after_approval(self, mock_send_notification): refund_id = 1 with mock_create_refund(status=201, response=[refund_id]): - with mock_process_refund(refund_id, reset_on_exit=False): + with mock_process_refund(refund_id): 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'}) # noqa: PT009 # pylint: disable=line-too-long + last_request = responses.calls[-1].request + self.assertDictEqual(json.loads(last_request.body), {'action': 'approve_payment_only'}) # noqa: PT009 # pylint: disable=line-too-long @mock.patch('lms.djangoapps.commerce.utils._send_refund_notification') def test_notification_no_refund(self, mock_send_notification): @@ -261,7 +264,7 @@ def test_send_refund_notification(self, student_email, mock_zendesk): 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, + responses.add(responses.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', @@ -289,7 +292,7 @@ def test_create_zendesk_ticket_request_error(self): assert not success assert mock_post.called - @httpretty.activate + @responses.activate def test_create_zendesk_ticket(self): """ Verify the Zendesk API is called. """ self._mock_zendesk_api() @@ -301,7 +304,7 @@ def test_create_zendesk_ticket(self): tags = ['auto_refund'] ticket_created = self.call_create_zendesk_ticket(name, email, subject, body, tags) assert ticket_created - last_request = httpretty.last_request() + last_request = responses.calls[-1].request # Verify the headers expected = { @@ -324,7 +327,7 @@ def test_create_zendesk_ticket(self): 'tags': ['LMS'] + tags } } - response_dict = json.loads(last_request.body.decode('utf8')) + response_dict = json.loads(last_request.body) response_dict['ticket']['tags'].sort() expected['ticket']['tags'].sort() self.assertDictEqual(response_dict, expected) # noqa: PT009 diff --git a/lms/djangoapps/commerce/tests/test_utils.py b/lms/djangoapps/commerce/tests/test_utils.py index 264fc1a87482..af64998b75fc 100644 --- a/lms/djangoapps/commerce/tests/test_utils.py +++ b/lms/djangoapps/commerce/tests/test_utils.py @@ -6,7 +6,7 @@ from urllib.parse import urlencode import ddt -import httpretty +import responses from django.conf import settings from django.test import TestCase from django.test.client import RequestFactory @@ -207,10 +207,10 @@ def test_ecommerce_service_not_configured(self, mock_commerce_configured): assert mock_commerce_configured.is_called assert not refund_success - @httpretty.activate + @responses.activate def test_no_ecommerce_connection_and_failure(self): - httpretty.register_uri( - httpretty.POST, + responses.add( + responses.POST, settings.ECOMMERCE_API_URL + 'refunds/', status=404, body='{}', @@ -220,17 +220,17 @@ def test_no_ecommerce_connection_and_failure(self): refund_success = refund_entitlement(course_entitlement) assert not refund_success - @httpretty.activate + @responses.activate def test_ecommerce_successful_refund(self): - httpretty.register_uri( - httpretty.POST, + responses.add( + responses.POST, settings.ECOMMERCE_API_URL + 'refunds/', status=201, body='[1]', content_type='application/json' ) - httpretty.register_uri( - httpretty.PUT, + responses.add( + responses.PUT, settings.ECOMMERCE_API_URL + 'refunds/1/process/', status=200, body=json.dumps({ @@ -249,18 +249,18 @@ def test_ecommerce_successful_refund(self): refund_success = refund_entitlement(course_entitlement) assert refund_success - @httpretty.activate + @responses.activate @patch('lms.djangoapps.commerce.utils._send_refund_notification', return_value=True) def test_ecommerce_refund_failed_process_notification_sent(self, mock_send_notification): - httpretty.register_uri( - httpretty.POST, + responses.add( + responses.POST, settings.ECOMMERCE_API_URL + 'refunds/', status=201, body='[1]', content_type='application/json' ) - httpretty.register_uri( - httpretty.PUT, + responses.add( + responses.PUT, settings.ECOMMERCE_API_URL + 'refunds/1/process/', status=400, body='{}', @@ -273,7 +273,7 @@ def test_ecommerce_refund_failed_process_notification_sent(self, mock_send_notif assert call_args[0] == (course_entitlement.user, [1]) assert refund_success - @httpretty.activate + @responses.activate @patch('lms.djangoapps.commerce.utils._send_refund_notification', return_value=True) def test_ecommerce_refund_not_verified_notification_for_entitlement(self, mock_send_notification): """ @@ -283,15 +283,15 @@ def test_ecommerce_refund_not_verified_notification_for_entitlement(self, mock_s PROFESSIONAL mode is used here although we never auto approve PROFESSIONAL refunds right now """ - httpretty.register_uri( - httpretty.POST, + responses.add( + responses.POST, settings.ECOMMERCE_API_URL + 'refunds/', status=201, body='[1]', content_type='application/json' ) - httpretty.register_uri( - httpretty.PUT, + responses.add( + responses.PUT, settings.ECOMMERCE_API_URL + 'refunds/1/process/', status=400, body='{}', @@ -304,18 +304,18 @@ def test_ecommerce_refund_not_verified_notification_for_entitlement(self, mock_s assert call_args[0] == (course_entitlement.user, [1]) assert refund_success - @httpretty.activate + @responses.activate @patch('lms.djangoapps.commerce.utils._send_refund_notification', return_value=True) def test_ecommerce_refund_send_notification_failed(self, mock_send_notification): - httpretty.register_uri( - httpretty.POST, + responses.add( + responses.POST, settings.ECOMMERCE_API_URL + 'refunds/', status=201, body='[1]', content_type='application/json' ) - httpretty.register_uri( - httpretty.PUT, + responses.add( + responses.PUT, settings.ECOMMERCE_API_URL + 'refunds/1/process/', status=400, body='{}', @@ -330,7 +330,7 @@ def test_ecommerce_refund_send_notification_failed(self, mock_send_notification) assert call_args[0] == (course_entitlement.user, [1]) assert not refund_success - @httpretty.activate + @responses.activate @ddt.data( (["verified", "audit"], "audit"), (["professional"], "professional"), @@ -350,15 +350,15 @@ def test_mode_change_after_refund_seat(self, course_modes, new_mode): mode_display_name=course_mode, ) - httpretty.register_uri( - httpretty.POST, + responses.add( + responses.POST, settings.ECOMMERCE_API_URL + 'refunds/', status=201, body='[1]', content_type='application/json' ) - httpretty.register_uri( - httpretty.PUT, + responses.add( + responses.PUT, settings.ECOMMERCE_API_URL + 'refunds/1/process/', status=200, body=json.dumps({ diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index cb951e28ea2f..0712594b6c12 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -11,7 +11,7 @@ from uuid import uuid4 import ddt -import httpretty +import responses import simplejson as json from bs4 import BeautifulSoup from django.conf import settings @@ -102,10 +102,10 @@ def _mock_payment_processors(): """ Mock out the payment processors endpoint, since we don't run ecommerce for unit tests here. Used in tests where ``mock_payment_processors`` can't be easily used, for example the whole - test is an httpretty context or the mock may or may not be called depending on ddt parameters. + test is a responses context or the mock may or may not be called depending on ddt parameters. """ - httpretty.register_uri( - httpretty.GET, + responses.add( + responses.GET, f"{TEST_API_URL}/payment/processors/", body=json.dumps(['foo', 'bar']), content_type="application/json", @@ -154,6 +154,9 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase, XssTestMixin, Tes def setUp(self): super().setUp() + responses.start() + self.addCleanup(responses.stop) + self.addCleanup(responses.reset) self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD) result = self.client.login(username=self.USERNAME, password=self.PASSWORD) assert result, 'Could not log in' @@ -892,7 +895,6 @@ def _set_contribution(self, amount, course_id): } session.save() - @httpretty.activate @override_settings(ECOMMERCE_API_URL=TEST_API_URL) def _get_page(self, url_name, course_key, expected_status_code=200, skip_first_step=False, assert_headers=False): """Retrieve one of the verification pages. """ @@ -909,7 +911,7 @@ def _get_page(self, url_name, course_key, expected_status_code=200, skip_first_s # ensure the mock api call was made. NOTE: the following line # approximates the check - if the headers were empty it means # there was no last request. - assert httpretty.last_request().headers + assert responses.calls return response def _assert_displayed_mode(self, response, expected_mode): @@ -1300,11 +1302,11 @@ def test_submit_photos_error_does_not_send_email(self): }, "DAYS_GOOD_FOR": 10, }) - @httpretty.activate + @responses.activate def test_submit_photos_for_reverification(self): - httpretty.register_uri( - httpretty.POST, settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_URL"], - status=200, body={}, + responses.add( + responses.POST, settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_URL"], + status=200, json={}, content_type='application/json' ) @@ -1453,7 +1455,7 @@ def _assert_user_name(self, full_name, equality=True): def _get_post_data(self): """Retrieve POST data from the last request. """ - last_request = httpretty.last_request() + last_request = responses.calls[-1].request return json.loads(last_request.body) From 2fe8926c31666b448903f8bd2e679972c1ccd7a4 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 21 Oct 2026 10:02:50 -0400 Subject: [PATCH 4/9] test: migrate the enterprise_support and credit tests to responses Mechanical replacement of `httpretty.register_uri` with `responses.add` and `@httpretty.activate` with `@responses.activate`. Request counts come from `responses.calls` instead of `httpretty.latest_requests`. Co-Authored-By: Claude Fable 5.1 --- .../core/djangoapps/credit/tests/test_api.py | 24 ++++++------- .../tests/mixins/enterprise.py | 34 +++++++++---------- .../enterprise_support/tests/test_api.py | 18 +++++----- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/openedx/core/djangoapps/credit/tests/test_api.py b/openedx/core/djangoapps/credit/tests/test_api.py index 666b744499e6..9563b993a020 100644 --- a/openedx/core/djangoapps/credit/tests/test_api.py +++ b/openedx/core/djangoapps/credit/tests/test_api.py @@ -9,8 +9,8 @@ from zoneinfo import ZoneInfo import ddt -import httpretty import pytest +import responses from django.contrib.auth.models import User # pylint: disable=imported-auth-user from django.core import mail from django.db import connection @@ -199,9 +199,9 @@ def enroll(self, user, course_id, mode): def _mock_ecommerce_courses_api(self, course_key, body, status=200): """ Mock GET requests to the ecommerce course API endpoint. """ - httpretty.reset() - httpretty.register_uri( - httpretty.GET, f'{TEST_API_URL}/courses/{str(course_key)}/?include_products=1', + responses.reset() + responses.add( + responses.GET, f'{TEST_API_URL}/courses/{str(course_key)}/?include_products=1', status=status, body=json.dumps(body), content_type='application/json', ) @@ -619,7 +619,7 @@ def test_remove_credit_requirement_status_req_not_configured(self): req_status = api.get_credit_requirement_status(self.course_key, "bob", namespace="grade", name="grade") assert len(req_status) == 0 - @httpretty.activate + @responses.activate @override_settings( ECOMMERCE_API_URL=TEST_API_URL, ECOMMERCE_SERVICE_WORKER_USERNAME=TEST_ECOMMERCE_WORKER @@ -1218,7 +1218,7 @@ def setUp(self): ) assert not hasattr(self.worker_user, 'profile') - @httpretty.activate + @responses.activate def test_get_credit_provider_display_names_method(self): """Verify that parsed providers list is returns after getting course production information.""" self._mock_ecommerce_courses_api(self.course_key, self.COURSE_API_RESPONSE) @@ -1235,7 +1235,7 @@ def test_get_credit_provider_display_names_method_with_exception(self, mock_get_ assert mock_get_client.called assert response is None - @httpretty.activate + @responses.activate def test_get_credit_provider_display_names_caching(self): """Verify that providers list is cached.""" assert self.credit_config.is_cache_enabled @@ -1250,9 +1250,9 @@ def test_get_credit_provider_display_names_caching(self): self.assertListEqual(self.PROVIDERS_LIST, response_providers) # noqa: PT009 # Verify only one request was made. - assert len(httpretty.httpretty.latest_requests) == 1 + assert len(responses.calls) == 1 - @httpretty.activate + @responses.activate def test_get_credit_provider_display_names_without_caching(self): """Verify that providers list is not cached.""" self.credit_config.cache_ttl = 0 @@ -1267,9 +1267,9 @@ def test_get_credit_provider_display_names_without_caching(self): response_providers = get_credit_provider_attribute_values(self.course_key, 'display_name') self.assertListEqual(self.PROVIDERS_LIST, response_providers) # noqa: PT009 - assert len(httpretty.httpretty.latest_requests) == 2 + assert len(responses.calls) == 2 - @httpretty.activate + @responses.activate @ddt.data( (None, None), ({'products': []}, []), @@ -1304,7 +1304,7 @@ def test_get_provider_api_with_multiple_data(self, data, expected_data): response_providers = get_credit_provider_attribute_values(self.course_key, 'display_name') assert expected_data == response_providers - @httpretty.activate + @responses.activate def test_get_credit_provider_display_names_without_providers(self): """Verify that if all providers are in-active than method return empty list.""" self._mock_ecommerce_courses_api(self.course_key, self.COURSE_API_RESPONSE) diff --git a/openedx/features/enterprise_support/tests/mixins/enterprise.py b/openedx/features/enterprise_support/tests/mixins/enterprise.py index e16b95e619ec..5360ef791f07 100644 --- a/openedx/features/enterprise_support/tests/mixins/enterprise.py +++ b/openedx/features/enterprise_support/tests/mixins/enterprise.py @@ -5,7 +5,7 @@ import json -import httpretty +import responses from django.conf import settings from django.core.cache import cache @@ -31,9 +31,9 @@ def mock_get_enterprise_customer(self, uuid, response, status): Helper to mock the HTTP call to the /enterprise-customer/uuid endpoint """ body = json.dumps(response) - httpretty.register_uri( - method=httpretty.GET, - uri=(self.get_enterprise_url('enterprise-customer') + uuid + '/'), + responses.add( + method=responses.GET, + url=(self.get_enterprise_url('enterprise-customer') + uuid + '/'), body=body, content_type='application/json', status=status, @@ -54,9 +54,9 @@ def mock_enterprise_course_enrollment_post_api( # pylint: disable=invalid-name consent_granted: consent_granted, } api_response_json = json.dumps(api_response) - httpretty.register_uri( - method=httpretty.POST, - uri=self.get_enterprise_url('enterprise-course-enrollment'), + responses.add( + method=responses.POST, + url=self.get_enterprise_url('enterprise-course-enrollment'), body=api_response_json, content_type='application/json' ) @@ -65,9 +65,9 @@ def mock_enterprise_course_enrollment_post_api_failure(self): # pylint: disable """ Helper method to register the enterprise course enrollment API endpoint for a failure. """ - httpretty.register_uri( - method=httpretty.POST, - uri=self.get_enterprise_url('enterprise-course-enrollment'), + responses.add( + method=responses.POST, + url=self.get_enterprise_url('enterprise-course-enrollment'), body='{}', content_type='application/json', status=500 @@ -78,7 +78,7 @@ def mock_consent_response( # pylint: disable=missing-function-docstring username, course_id, ec_uuid, - method=httpretty.GET, + method=responses.GET, granted=True, required=False, exists=True, @@ -92,9 +92,9 @@ def mock_consent_response( # pylint: disable=missing-function-docstring 'consent_required': required, 'exists': exists, } - httpretty.register_uri( + responses.add( method=method, - uri=self.consent_url, + url=self.consent_url, content_type='application/json', body=json.dumps(response_body), status=response_code or 200, @@ -105,7 +105,7 @@ def mock_consent_post(self, username, course_id, ec_uuid): username, course_id, ec_uuid, - method=httpretty.POST, + method=responses.POST, granted=True, exists=True, ) @@ -271,9 +271,9 @@ def mock_enterprise_learner_api( } enterprise_learner_api_response_json = json.dumps(enterprise_learner_api_response) - httpretty.register_uri( - method=httpretty.GET, - uri=self.get_enterprise_url('enterprise-learner'), + responses.add( + method=responses.GET, + url=self.get_enterprise_url('enterprise-learner'), body=enterprise_learner_api_response_json, content_type='application/json' ) diff --git a/openedx/features/enterprise_support/tests/test_api.py b/openedx/features/enterprise_support/tests/test_api.py index e1008cce2054..51343c90bdbc 100644 --- a/openedx/features/enterprise_support/tests/test_api.py +++ b/openedx/features/enterprise_support/tests/test_api.py @@ -4,8 +4,8 @@ from unittest import mock import ddt -import httpretty import pytest +import responses from consent.models import DataSharingConsent from django.conf import settings from django.contrib.auth.models import User # pylint: disable=imported-auth-user @@ -130,7 +130,7 @@ def _assert_get_enterprise_customer_with_cache(self, api_client, enterprise_cust cached_enterprise_customer = cache.get(cache_key) assert cached_enterprise_customer == enterprise_customer - @httpretty.activate + @responses.activate @mock.patch('openedx.features.enterprise_support.api.create_jwt_for_user') def test_enterprise_api_client_with_service_user(self, mock_jwt_builder): """ @@ -151,7 +151,7 @@ def test_enterprise_api_client_with_service_user(self, mock_jwt_builder): enterprise_api_data_for_mock_2 = {'name': 'dummy-enterprise-customer-2', 'uuid': 'enterprise-uuid-2'} self._assert_get_enterprise_customer(enterprise_api_client, enterprise_api_data_for_mock_2) - @httpretty.activate + @responses.activate @mock.patch('openedx.features.enterprise_support.api.create_jwt_for_user') def test_enterprise_api_client_with_user(self, mock_jwt_builder): """ @@ -161,7 +161,7 @@ def test_enterprise_api_client_with_user(self, mock_jwt_builder): self._assert_api_client_with_user(EnterpriseApiClient, mock_jwt_builder) @ddt.data(True, False) - @httpretty.activate + @responses.activate @mock.patch('openedx.features.enterprise_support.api.create_jwt_for_user') def test_enterprise_api_client_with_user_post_enrollment(self, should_raise_http_error, mock_jwt_builder): """ @@ -201,7 +201,7 @@ def test_enterprise_customer_from_api_cache_miss(self, mock_client_class, mock_u assert actual_result is None assert not mock_client_class.called - @httpretty.activate + @responses.activate @mock.patch('openedx.features.enterprise_support.api.create_jwt_for_user') def test_enterprise_consent_api_client_with_service_user(self, mock_jwt_builder): """ @@ -210,7 +210,7 @@ def test_enterprise_consent_api_client_with_service_user(self, mock_jwt_builder) """ self._assert_api_service_client(ConsentApiServiceClient, mock_jwt_builder) - @httpretty.activate + @responses.activate @mock.patch('openedx.features.enterprise_support.api.create_jwt_for_user') def test_enterprise_consent_api_client_with_user(self, mock_jwt_builder): """ @@ -349,7 +349,7 @@ def test_get_enterprise_course_enrollments(self, is_enterprise_enabled, mock_ent assert len(enterprise_course_enrollments) == 1 assert enterprise_course_enrollments[0].id == ece.id - @httpretty.activate + @responses.activate @mock.patch('openedx.features.enterprise_support.api.get_enterprise_learner_data_from_db') @mock.patch('openedx.features.enterprise_support.api.EnterpriseCustomer') @mock.patch('openedx.features.enterprise_support.api.get_partial_pipeline') @@ -379,7 +379,7 @@ def mock_get_enterprise_customer(**kwargs): enterprise_customer = enterprise_customer_for_request(dummy_request) assert enterprise_customer == {'real': 'enterprisecustomer'} - httpretty.reset() + responses.reset() # Verify that the method `enterprise_customer_for_request` returns no # enterprise customer if the enterprise customer API throws 404. @@ -388,7 +388,7 @@ def mock_get_enterprise_customer(**kwargs): enterprise_customer = enterprise_customer_for_request(dummy_request) assert enterprise_customer is None - httpretty.reset() + responses.reset() # Verify that the method `enterprise_customer_for_request` returns # expected enterprise customer against the requesting user even if From a43b8f18e1665db7610e12586bbf31d84cb445f8 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 21 Oct 2026 10:06:56 -0400 Subject: [PATCH 5/9] test: migrate the programs, catalog and api_admin tests to responses Mostly mechanical, with a few things worth knowing: - `@httpretty.activate` on a class decorated every test method; the same decorator from `responses` wraps the class object itself, so pytest stops collecting the class and its tests silently disappear. The class-level uses in the cache_programs and edx_api_utils tests become `responses.start()` in setUp with stop and reset as cleanups. - `responses` matches a registration on scheme, host and path only, so the per-page pathway mocks in cache_programs match on their query parameters explicitly with `matchers.query_param_matcher`. - Callback bodies take the prepared request alone and return the response headers themselves, and query strings are read from the request URL since there is no `querystring` attribute. - The edx_api_utils test passed a `url` keyword into `httpretty.Response` where it was ignored; it now goes to `_mock_catalog_api`, which is where it was meant to go. Co-Authored-By: Claude Fable 5.1 --- .../djangoapps/util/tests/mixins/discovery.py | 8 +- .../djangoapps/api_admin/tests/test_views.py | 52 ++++++------- .../commands/tests/test_cache_programs.py | 56 +++++++------- .../djangoapps/programs/tests/test_tasks.py | 34 ++++---- .../djangoapps/programs/tests/test_utils.py | 39 ++++++---- openedx/core/lib/tests/test_edx_api_utils.py | 77 +++++++------------ 6 files changed, 127 insertions(+), 139 deletions(-) diff --git a/common/djangoapps/util/tests/mixins/discovery.py b/common/djangoapps/util/tests/mixins/discovery.py index c673047d5e72..eff6a9a1d289 100644 --- a/common/djangoapps/util/tests/mixins/discovery.py +++ b/common/djangoapps/util/tests/mixins/discovery.py @@ -5,7 +5,7 @@ import json -import httpretty +import responses from django.conf import settings from django.core.cache import cache @@ -37,9 +37,9 @@ def mock_course_discovery_api_for_catalog_contains(self, catalog_id=1, course_ru self.COURSE_DISCOVERY_CATALOGS_URL, catalog_id, ','.join(course_run_ids) ) - httpretty.register_uri( - method=httpretty.GET, - uri=catalog_contains_uri, + responses.add( + method=responses.GET, + url=catalog_contains_uri, body=course_discovery_api_response_json, content_type='application/json' ) diff --git a/openedx/core/djangoapps/api_admin/tests/test_views.py b/openedx/core/djangoapps/api_admin/tests/test_views.py index d30f12e17838..77af4da54905 100644 --- a/openedx/core/djangoapps/api_admin/tests/test_views.py +++ b/openedx/core/djangoapps/api_admin/tests/test_views.py @@ -2,9 +2,10 @@ import json +from urllib.parse import urlparse import ddt -import httpretty +import responses from django.conf import settings from django.test import TestCase from django.test.utils import override_settings @@ -231,15 +232,14 @@ def setUp(self): self.user = UserFactory(password=password, is_staff=True) self.client.login(username=self.user.username, password=password) - def mock_catalog_endpoint(self, data, catalog_id=None, method=httpretty.GET, status_code=200): + def mock_catalog_endpoint(self, data, catalog_id=None, method=responses.GET, status_code=200): """ Mock the Course Catalog API's catalog endpoint. """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock Catalog API calls.' url = '{root}/catalogs/'.format(root=settings.COURSE_CATALOG_API_URL.rstrip('/')) if catalog_id: url += f'{catalog_id}/' - httpretty.register_uri( + responses.add( method, url, body=json.dumps(data), @@ -261,7 +261,7 @@ def test_get(self): response = self.client.get(self.url) assert response.status_code == 200 - @httpretty.activate + @responses.activate def test_post(self): catalog_user = UserFactory() self.mock_catalog_endpoint({'results': []}) @@ -283,21 +283,21 @@ def setUp(self): self.catalog_user = UserFactory() self.url = reverse('api_admin:catalog-list', kwargs={'username': self.catalog_user.username}) - @httpretty.activate + @responses.activate def test_get(self): catalog = CatalogFactory(viewers=[self.catalog_user.username]) self.mock_catalog_endpoint({'results': [catalog.attributes]}) response = self.client.get(self.url) self.assertContains(response, catalog.name) - @httpretty.activate + @responses.activate def test_get_no_catalogs(self): """Verify that the view works when no catalogs are set up.""" self.mock_catalog_endpoint({}, status_code=404) response = self.client.get(self.url) assert response.status_code == 200 - @httpretty.activate + @responses.activate def test_post(self): catalog_data = { 'name': 'test-catalog', @@ -305,13 +305,13 @@ def test_post(self): 'viewers': [self.catalog_user.username] } catalog_id = 123 - self.mock_catalog_endpoint(dict(catalog_data, id=catalog_id), method=httpretty.POST) + self.mock_catalog_endpoint(dict(catalog_data, id=catalog_id), method=responses.POST) response = self.client.post(self.url, catalog_data) - assert httpretty.last_request().method == 'POST' + assert responses.calls[-1].request.method == 'POST' self.mock_catalog_endpoint(CatalogFactory().attributes, catalog_id=catalog_id) self.assertRedirects(response, reverse('api_admin:catalog-edit', kwargs={'catalog_id': catalog_id})) - @httpretty.activate + @responses.activate def test_post_invalid(self): catalog = CatalogFactory(viewers=[self.catalog_user.username]) self.mock_catalog_endpoint({'results': [catalog.attributes]}) @@ -322,7 +322,7 @@ def test_post_invalid(self): }) assert response.status_code == 400 # Assert that no POST was made to the catalog API - assert len([r for r in httpretty.httpretty.latest_requests if r.method == 'POST']) == 0 + assert len([r for r in responses.calls if r.request.method == 'POST']) == 0 @skip_unless_lms @@ -336,43 +336,43 @@ def setUp(self): self.catalog = CatalogFactory(viewers=[self.catalog_user.username]) self.url = reverse('api_admin:catalog-edit', kwargs={'catalog_id': self.catalog.id}) - @httpretty.activate + @responses.activate def test_get(self): self.mock_catalog_endpoint(self.catalog.attributes, catalog_id=self.catalog.id) response = self.client.get(self.url) self.assertContains(response, self.catalog.name) - @httpretty.activate + @responses.activate def test_delete(self): self.mock_catalog_endpoint( self.catalog.attributes, - method=httpretty.DELETE, + method=responses.DELETE, catalog_id=self.catalog.id ) response = self.client.post(self.url, {'delete-catalog': 'on'}) self.assertRedirects(response, reverse('api_admin:catalog-search')) - assert httpretty.last_request().method == 'DELETE' # pylint: disable=no-member - assert httpretty.last_request().path == \ - f'/api/v1/catalogs/{self.catalog.id}/' # pylint: disable=no-member - assert len(httpretty.httpretty.latest_requests) == 1 + assert responses.calls[-1].request.method == 'DELETE' # pylint: disable=no-member + assert urlparse(responses.calls[-1].request.url).path == \ + f'/api/v1/catalogs/{self.catalog.id}/' + assert len(responses.calls) == 1 - @httpretty.activate + @responses.activate def test_edit(self): # Mock both PATCH and GET endpoints before making the POST request - self.mock_catalog_endpoint(self.catalog.attributes, method=httpretty.PATCH, catalog_id=self.catalog.id) + self.mock_catalog_endpoint(self.catalog.attributes, method=responses.PATCH, catalog_id=self.catalog.id) new_attributes = dict(self.catalog.attributes, **{'delete-catalog': 'off', 'name': 'changed'}) self.mock_catalog_endpoint(new_attributes, catalog_id=self.catalog.id) response = self.client.post(self.url, new_attributes) self.assertRedirects(response, reverse('api_admin:catalog-edit', kwargs={'catalog_id': self.catalog.id})) - @httpretty.activate + @responses.activate def test_edit_invalid(self): self.mock_catalog_endpoint(self.catalog.attributes, catalog_id=self.catalog.id) new_attributes = dict(self.catalog.attributes, **{'delete-catalog': 'off', 'name': ''}) response = self.client.post(self.url, new_attributes) assert response.status_code == 400 # Assert that no PATCH was made to the Catalog API - assert len([r for r in httpretty.httpretty.latest_requests if r.method == 'PATCH']) == 0 + assert len([r for r in responses.calls if r.request.method == 'PATCH']) == 0 @skip_unless_lms @@ -384,11 +384,11 @@ def setUp(self): super().setUp() self.url = reverse('api_admin:catalog-preview') - @httpretty.activate + @responses.activate def test_get(self): data = {'count': 1, 'results': ['test data'], 'next': None, 'prev': None} - httpretty.register_uri( - httpretty.GET, + responses.add( + responses.GET, '{root}/courses/'.format(root=settings.COURSE_CATALOG_API_URL.rstrip('/')), body=json.dumps(data), content_type='application/json' diff --git a/openedx/core/djangoapps/catalog/management/commands/tests/test_cache_programs.py b/openedx/core/djangoapps/catalog/management/commands/tests/test_cache_programs.py index b981b3d4f4ac..7038df5e1c3d 100644 --- a/openedx/core/djangoapps/catalog/management/commands/tests/test_cache_programs.py +++ b/openedx/core/djangoapps/catalog/management/commands/tests/test_cache_programs.py @@ -3,11 +3,13 @@ """ import json +from urllib.parse import parse_qs, urlparse -import httpretty import pytest +import responses from django.core.cache import cache from django.core.management import call_command +from responses import matchers from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.catalog.cache import ( @@ -28,7 +30,6 @@ @skip_unless_lms -@httpretty.activate class TestCachePrograms(CatalogIntegrationMixin, CacheIsolationTestCase, SiteMixin): """ Defines tests for the ``cache_programs`` management command. @@ -37,8 +38,10 @@ class TestCachePrograms(CatalogIntegrationMixin, CacheIsolationTestCase, SiteMix def setUp(self): super().setUp() + responses.start() + self.addCleanup(responses.stop) + self.addCleanup(responses.reset) - httpretty.httpretty.reset() self.catalog_integration = self.create_catalog_integration() self.site_domain = 'testsite.com' @@ -97,47 +100,45 @@ def setUp(self): def mock_list(self, site=""): """ Mock the data returned by the program listing API endpoint. """ - # pylint: disable=unused-argument - def list_callback(request, uri, headers): + def list_callback(request): """ The mock listing callback. """ expected = { 'exclude_utm': ['1'], 'status': ['active', 'retired'], 'uuids_only': ['1'] } - assert request.querystring == expected + assert parse_qs(urlparse(request.url).query) == expected uuids = self.uuids[self.site_domain2] if site else self.uuids[self.site_domain] - return (200, headers, json.dumps(uuids)) + return (200, {}, json.dumps(uuids)) - httpretty.register_uri( - httpretty.GET, + responses.add_callback( + responses.GET, self.list_url, - body=list_callback, - content_type='application/json' + callback=list_callback, + content_type='application/json', ) def mock_detail(self, uuid, program): """ Mock the data returned by the program detail API endpoint. """ - # pylint: disable=unused-argument - def detail_callback(request, uri, headers): + def detail_callback(request): """ The mock detail callback. """ expected = { 'exclude_utm': ['1'], } - assert request.querystring == expected + assert parse_qs(urlparse(request.url).query) == expected - return (200, headers, json.dumps(program)) + return (200, {}, json.dumps(program)) - httpretty.register_uri( - httpretty.GET, + responses.add_callback( + responses.GET, self.detail_tpl.format(uuid=uuid), - body=detail_callback, - content_type='application/json' + callback=detail_callback, + content_type='application/json', ) def mock_pathways(self, pathways, page_number=1, final=True): """ Mock the data for discovery's credit pathways endpoint. """ - def pathways_callback(request, uri, headers): # pylint: disable=unused-argument + def pathways_callback(request): """ Mocks the pathways response. """ expected = { @@ -145,7 +146,7 @@ def pathways_callback(request, uri, headers): # pylint: disable=unused-argument 'page': [str(page_number)], 'status': ['published'] } - assert request.querystring == expected + assert parse_qs(urlparse(request.url).query) == expected body = { 'count': len(pathways), @@ -154,14 +155,15 @@ def pathways_callback(request, uri, headers): # pylint: disable=unused-argument 'results': pathways } - return (200, headers, json.dumps(body)) + return (200, {}, json.dumps(body)) - httpretty.register_uri( - httpretty.GET, - self.pathway_url + f'?status=published&exclude_utm=1&page={page_number}', - body=pathways_callback, + # Each page is a separate mock on the same path, so match on the query parameters. + responses.add_callback( + responses.GET, + self.pathway_url, + callback=pathways_callback, content_type='application/json', - match_querystring=True, + match=[matchers.query_param_matcher({'status': 'published', 'exclude_utm': '1', 'page': str(page_number)})], ) def test_handle_domain(self): diff --git a/openedx/core/djangoapps/programs/tests/test_tasks.py b/openedx/core/djangoapps/programs/tests/test_tasks.py index 881f9e657fa7..d28f0f69ef6a 100644 --- a/openedx/core/djangoapps/programs/tests/test_tasks.py +++ b/openedx/core/djangoapps/programs/tests/test_tasks.py @@ -9,9 +9,9 @@ from zoneinfo import ZoneInfo import ddt -import httpretty import pytest import requests +import responses from celery.exceptions import MaxRetriesExceededError from django.conf import settings from django.test import TestCase, override_settings @@ -83,7 +83,7 @@ class AwardProgramCertificateTestCase(TestCase): Test the award_program_certificate function """ - @httpretty.activate + @responses.activate @mock.patch("openedx.core.djangoapps.programs.tasks.get_credentials_api_base_url") def test_award_program_certificate(self, mock_get_api_base_url): """ @@ -95,8 +95,8 @@ def test_award_program_certificate(self, mock_get_api_base_url): test_client = requests.Session() test_client.auth = SuppliedJwtAuth("test-token") - httpretty.register_uri( - httpretty.POST, + responses.add( + responses.POST, "http://test-server/credentials/", ) @@ -110,7 +110,7 @@ def test_award_program_certificate(self, mock_get_api_base_url): "type": tasks.PROGRAM_CERTIFICATE, }, } - last_request_body = httpretty.last_request().body.decode("utf-8") + last_request_body = responses.calls[-1].request.body assert json.loads(last_request_body) == expected_body @@ -473,7 +473,7 @@ def setUp(self): # pylint: disable=super-method-not-called status="downloadable", ) - @httpretty.activate + @responses.activate @mock.patch("openedx.core.djangoapps.programs.tasks.get_credentials_api_base_url") def test_post_course_certificate(self, mock_get_api_base_url): """ @@ -483,8 +483,8 @@ def test_post_course_certificate(self, mock_get_api_base_url): test_client = requests.Session() test_client.auth = SuppliedJwtAuth("test-token") - httpretty.register_uri( - httpretty.POST, + responses.add( + responses.POST, "http://test-server/credentials/", ) @@ -500,7 +500,7 @@ def test_post_course_certificate(self, mock_get_api_base_url): }, "date_override": None, } - last_request_body = httpretty.last_request().body.decode("utf-8") + last_request_body = responses.calls[-1].request.body assert json.loads(last_request_body) == expected_body @@ -665,7 +665,7 @@ class RevokeProgramCertificateTestCase(TestCase): Test the revoke_program_certificate function """ - @httpretty.activate + @responses.activate @mock.patch("openedx.core.djangoapps.programs.tasks.get_credentials_api_base_url") def test_revoke_program_certificate(self, mock_get_api_base_url): """ @@ -676,8 +676,8 @@ def test_revoke_program_certificate(self, mock_get_api_base_url): test_client = requests.Session() test_client.auth = SuppliedJwtAuth("test-token") - httpretty.register_uri( - httpretty.POST, + responses.add( + responses.POST, "http://test-server/credentials/", ) @@ -691,7 +691,7 @@ def test_revoke_program_certificate(self, mock_get_api_base_url): "type": tasks.PROGRAM_CERTIFICATE, }, } - last_request_body = httpretty.last_request().body.decode("utf-8") + last_request_body = responses.calls[-1].request.body assert json.loads(last_request_body) == expected_body @@ -986,7 +986,7 @@ def setUp(self): "course_id": "testCourse", } - @httpretty.activate + @responses.activate @mock.patch("openedx.core.djangoapps.programs.tasks.get_credentials_api_base_url") def test_post_course_certificate_configuration(self, mock_get_api_base_url): """ @@ -996,8 +996,8 @@ def test_post_course_certificate_configuration(self, mock_get_api_base_url): test_client = requests.Session() test_client.auth = SuppliedJwtAuth("test-token") - httpretty.register_uri( - httpretty.POST, + responses.add( + responses.POST, "http://test-server/course_certificates/", ) @@ -1011,7 +1011,7 @@ def test_post_course_certificate_configuration(self, mock_get_api_base_url): "certificate_available_date": available_date, "is_active": True, } - last_request_body = httpretty.last_request().body.decode("utf-8") + last_request_body = responses.calls[-1].request.body assert json.loads(last_request_body) == expected_body diff --git a/openedx/core/djangoapps/programs/tests/test_utils.py b/openedx/core/djangoapps/programs/tests/test_utils.py index cf81a40820cd..000f2fe27fcb 100644 --- a/openedx/core/djangoapps/programs/tests/test_utils.py +++ b/openedx/core/djangoapps/programs/tests/test_utils.py @@ -6,10 +6,11 @@ from collections import namedtuple from copy import deepcopy from unittest import mock +from urllib.parse import parse_qs, urlparse from zoneinfo import ZoneInfo import ddt -import httpretty +import responses from django.conf import settings from django.test import TestCase from django.test.utils import override_settings @@ -59,6 +60,12 @@ LOGGER_NAME = 'openedx.core.djangoapps.programs.utils' +def _last_request_query(): + """Return the parsed query string of the most recent mocked request.""" + return parse_qs(urlparse(responses.calls[-1].request.url).query) + + + @ddt.ddt @skip_unless_lms @mock.patch("lms.djangoapps.certificates.api.auto_certificate_generation_enabled", mock.Mock(return_value=True)) @@ -1567,7 +1574,7 @@ def test_can_enroll(self, can_enroll, mock_has_perm): assert data['courses'][0]['course_runs'][0]['can_enroll'] == can_enroll - @httpretty.activate + @responses.activate def test_fetching_program_discounted_price(self): """ Authenticated users eligible for one click purchase should see the purchase button @@ -1580,8 +1587,8 @@ def test_fetching_program_discounted_price(self): 'currency': 'USD', 'total_incl_tax': 50.0 } - httpretty.register_uri( - httpretty.GET, + responses.add( + responses.GET, self.ECOMMERCE_CALCULATE_DISCOUNT_ENDPOINT, body=json.dumps(mock_discount_data), content_type='application/json' @@ -1590,11 +1597,11 @@ def test_fetching_program_discounted_price(self): data = ProgramMarketingDataExtender(self.program, self.user).extend() self._update_discount_data(mock_discount_data) - assert httpretty.last_request().querystring.get('username')[0] == self.user.username # pylint: disable=no-member, line-too-long + assert _last_request_query().get('username')[0] == self.user.username assert data['skus'] == [course['course_runs'][0]['seats'][0]['sku'] for course in self.program['courses']] assert data['discount_data'] == mock_discount_data - @httpretty.activate + @responses.activate @override_waffle_switch(ALWAYS_CALCULATE_PROGRAM_PRICE_AS_ANONYMOUS_USER, True) def test_fetching_program_price_when_forced_as_anonymous_user(self): """ @@ -1607,16 +1614,16 @@ def test_fetching_program_price_when_forced_as_anonymous_user(self): 'currency': 'USD', 'total_incl_tax': 50.0 } - httpretty.register_uri( - httpretty.GET, + responses.add( + responses.GET, self.ECOMMERCE_CALCULATE_DISCOUNT_ENDPOINT, body=json.dumps(mock_discount_data), content_type='application/json' ) ProgramMarketingDataExtender(self.program, self.user).extend() - assert httpretty.last_request().querystring.get('is_anonymous')[0] == 'True' # pylint: disable=no-member, line-too-long + assert _last_request_query().get('is_anonymous')[0] == 'True' - @httpretty.activate + @responses.activate def test_fetching_program_discounted_price_as_anonymous_user(self): """ Anonymous users should see the purchase button same way the authenticated users do @@ -1628,8 +1635,8 @@ def test_fetching_program_discounted_price_as_anonymous_user(self): 'currency': 'USD', 'total_incl_tax': 50.0 } - httpretty.register_uri( - httpretty.GET, + responses.add( + responses.GET, self.ECOMMERCE_CALCULATE_DISCOUNT_ENDPOINT, body=json.dumps(mock_discount_data), content_type='application/json' @@ -1639,7 +1646,7 @@ def test_fetching_program_discounted_price_as_anonymous_user(self): data = ProgramMarketingDataExtender(self.program, user).extend() self._update_discount_data(mock_discount_data) - assert httpretty.last_request().querystring.get('is_anonymous', None) is not None # pylint: disable=no-member, line-too-long + assert _last_request_query().get('is_anonymous', None) is not None assert data['skus'] == [course['course_runs'][0]['seats'][0]['sku'] for course in self.program['courses']] assert data['discount_data'] == mock_discount_data @@ -1652,15 +1659,15 @@ def test_fetching_program_discounted_price_no_applicable_seats(self): assert len(data['skus']) == 0 - @httpretty.activate + @responses.activate def test_fetching_program_discounted_price_api_exception_caught(self): """ User should be able to do a one click purchase of a program even if the ecommerce API throws an exception during the calculation of program discounted price. """ self._prepare_program_for_discounted_price_calculation_endpoint() - httpretty.register_uri( - httpretty.GET, + responses.add( + responses.GET, self.ECOMMERCE_CALCULATE_DISCOUNT_ENDPOINT, status=400, content_type='application/json' diff --git a/openedx/core/lib/tests/test_edx_api_utils.py b/openedx/core/lib/tests/test_edx_api_utils.py index 725645408d9f..35cc71e62297 100644 --- a/openedx/core/lib/tests/test_edx_api_utils.py +++ b/openedx/core/lib/tests/test_edx_api_utils.py @@ -5,7 +5,7 @@ from unittest import mock from urllib.parse import urljoin -import httpretty +import responses from django.core.cache import cache from common.djangoapps.student.tests.factories import UserFactory @@ -21,7 +21,6 @@ @skip_unless_lms -@httpretty.activate class TestGetEdxApiData( CatalogIntegrationMixin, CredentialsApiConfigMixin, CacheIsolationTestCase ): @@ -33,29 +32,29 @@ class TestGetEdxApiData( def setUp(self): super().setUp() + responses.start() + self.addCleanup(responses.stop) + self.addCleanup(responses.reset) self.user = UserFactory() self.base_api_url = ( CatalogIntegration.current().get_internal_api_url().strip("/") ) - httpretty.httpretty.reset() cache.clear() - def _mock_catalog_api(self, responses, url=None): - assert ( - httpretty.is_enabled() - ), "httpretty must be enabled to mock Catalog API calls." - + def _mock_catalog_api(self, mock_responses, url=None): + """Register one mocked response per dict of ``responses.add`` kwargs, returned in order.""" url = url if url else urljoin(f"{self.base_api_url}/", "programs/") - httpretty.register_uri(httpretty.GET, url, responses=responses) + for kwargs in mock_responses: + responses.add(responses.GET, url, content_type="application/json", **kwargs) def _assert_num_requests(self, count): """ DRY helper for verifying request counts. """ - assert len(httpretty.httpretty.latest_requests) == count + assert len(responses.calls) == count def test_get_unpaginated_data(self): """ @@ -71,7 +70,7 @@ def test_get_unpaginated_data(self): } self._mock_catalog_api( - [httpretty.Response(body=json.dumps(data), content_type="application/json")] + [{"body": json.dumps(data)}] ) with mock.patch("requests.Session") as mock_init: @@ -99,7 +98,7 @@ def test_get_paginated_data(self): expected_collection = ["some", "test", "data"] url = urljoin(f"{self.base_api_url}/", "/programs/?page={}") - responses = [] + mock_responses = [] for page, record in enumerate(expected_collection, start=1): data = { "next": url.format(page + 1) @@ -109,11 +108,11 @@ def test_get_paginated_data(self): } body = json.dumps(data) - responses.append( - httpretty.Response(body=body, content_type="application/json") + mock_responses.append( + {"body": body} ) - self._mock_catalog_api(responses) + self._mock_catalog_api(mock_responses) actual_collection = get_api_data( catalog_integration, @@ -133,7 +132,7 @@ def test_get_paginated_data_do_not_traverse_pagination(self): api = get_catalog_api_client(self.user) url = urljoin(f"{self.base_api_url}/", "/programs/?page={}") - responses = [ + mock_responses = [ { "next": url.format(2), "results": ["some"], @@ -143,14 +142,12 @@ def test_get_paginated_data_do_not_traverse_pagination(self): "results": ["test"], }, ] - expected_response = responses[0] + expected_response = mock_responses[0] self._mock_catalog_api( [ - httpretty.Response( - body=json.dumps(body), content_type="application/json" - ) - for body in responses + {"body": json.dumps(body)} + for body in mock_responses ] ) @@ -178,9 +175,7 @@ def test_get_specific_resource(self): self._mock_catalog_api( [ - httpretty.Response( - body=json.dumps(expected_resource), content_type="application/json" - ) + {"body": json.dumps(expected_resource)} ], url=url, ) @@ -216,9 +211,7 @@ def test_get_specific_resource_with_falsey_id(self): self._mock_catalog_api( [ - httpretty.Response( - body=json.dumps(expected_resource), content_type="application/json" - ) + {"body": json.dumps(expected_resource)} ], url=url, ) @@ -251,9 +244,7 @@ def test_get_specific_fields_from_cache_response(self): self._mock_catalog_api( [ - httpretty.Response( - body=json.dumps(response), content_type="application/json" - ) + {"body": json.dumps(response)} ], url=url, ) @@ -303,9 +294,7 @@ def test_cache_utilization(self): self._mock_catalog_api( [ - httpretty.Response( - body=json.dumps(data), content_type="application/json" - ) + {"body": json.dumps(data)} ], ) @@ -316,9 +305,7 @@ def test_cache_utilization(self): self._mock_catalog_api( [ - httpretty.Response( - body=json.dumps(expected_resource), content_type="application/json" - ) + {"body": json.dumps(expected_resource)} ], url=url, ) @@ -392,9 +379,7 @@ def test_data_retrieval_failure(self, mock_exception): self._mock_catalog_api( [ - httpretty.Response( - body="clunk", content_type="application/json", status_code=500 - ) + {"body": "clunk", "status": 500} ] ) @@ -437,9 +422,7 @@ def test_data_retrieval_failure_with_id(self, mock_exception): self._mock_catalog_api( [ - httpretty.Response( - body="clunk", content_type="application/json", status_code=500 - ) + {"body": "clunk", "status": 500} ] ) @@ -466,13 +449,9 @@ def test_data_retrieval_raise_on_error(self): self._mock_catalog_api( [ - httpretty.Response( - body="clunk", - content_type="application/json", - status_code=500, - url=url, - ) - ] + {"body": "clunk", "status": 500} + ], + url=url, ) with self.assertRaises(Exception): # noqa: B017, PT027 From 14b9e7f4bcfb7cbd38aceba3e046abec3bc09191 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 21 Oct 2026 10:06:56 -0400 Subject: [PATCH 6/9] test: migrate the third-party auth tests to responses `ThirdPartyOAuthTestMixin` carried a class-level `@httpretty.activate` that every OAuth test class in auth_exchange, oauth_dispatch and user_authn repeated. The mixin's setUp now starts `responses` and registers stop and reset as cleanups, and the per-class decorators go away, since `responses.activate` on a class would hide its tests from pytest. The TestShib SAML spec used httpretty callbacks for the SAP SuccessFactors endpoints. Those become `responses.add_callback` callbacks that take the prepared request. Two tests re-register the OData user endpoint to override the default mock from setUp; httpretty served the newest registration for a path but `responses` serves the oldest, so those tests remove the setUp mock first. Request bodies are compared as bytes through a small helper because `requests` prepares form data as `str`. Co-Authored-By: Claude Fable 5.1 --- .../tests/specs/test_testshib.py | 98 +++++++++++-------- .../third_party_auth/tests/utils.py | 10 +- .../auth_exchange/tests/test_forms.py | 3 - .../auth_exchange/tests/test_views.py | 3 - .../oauth_dispatch/tests/test_views.py | 2 - .../user_authn/views/tests/test_register.py | 2 - 6 files changed, 61 insertions(+), 57 deletions(-) diff --git a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py index 8819de5deff8..c4082356a19d 100644 --- a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py +++ b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py @@ -11,7 +11,7 @@ from urllib.parse import parse_qs, quote, urlparse import ddt -import httpretty +import responses from django.contrib import auth from freezegun import freeze_time from social_core import actions @@ -38,6 +38,13 @@ TESTSHIB_SSO_URL = "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO" +def _body_bytes(request): + """Return the mocked request's body as bytes, whichever form the client sent it in.""" + body = request.body or b"" + return body if isinstance(body, bytes) else body.encode("utf-8") + + + class SamlIntegrationTestUtilities: """ Class contains methods particular to SAML integration testing so that they @@ -61,34 +68,34 @@ def setUp(self): entity_id="https://saml.example.none", ) # Mock out HTTP requests that may be made to TestShib: - httpretty.enable() - httpretty.reset() - self.addCleanup(httpretty.reset) # pylint: disable=no-member - self.addCleanup(httpretty.disable) # pylint: disable=no-member + responses.start() + responses.reset() + self.addCleanup(responses.reset) # pylint: disable=no-member + self.addCleanup(responses.stop) # pylint: disable=no-member - def metadata_callback(_request, _uri, headers): + def metadata_callback(_request): """Return a cached copy of TestShib's metadata by reading it from disk""" return ( 200, - headers, + {}, self.read_data_file("testshib_metadata.xml"), ) # pylint: disable=no-member - httpretty.register_uri(httpretty.GET, TESTSHIB_METADATA_URL, content_type="text/xml", body=metadata_callback) + responses.add_callback(responses.GET, TESTSHIB_METADATA_URL, callback=metadata_callback, content_type="text/xml") - def cache_duration_metadata_callback(_request, _uri, headers): + def cache_duration_metadata_callback(_request): """Return a cached copy of TestShib's metadata with a cacheDuration attribute""" return ( 200, - headers, + {}, self.read_data_file("testshib_metadata_with_cache_duration.xml"), ) # pylint: disable=no-member - httpretty.register_uri( - httpretty.GET, + responses.add_callback( + responses.GET, TESTSHIB_METADATA_URL_WITH_CACHE_DURATION, + callback=cache_duration_metadata_callback, content_type="text/xml", - body=cache_duration_metadata_callback, ) # Configure the SAML library to use the same request ID for every request. @@ -123,7 +130,6 @@ def _configure_testshib_provider(self, **kwargs): saml_provider = self.configure_saml_provider(**kwargs) # pylint: disable=no-member if fetch_metadata: - assert httpretty.is_enabled() # pylint: disable=no-member num_total, num_skipped, num_attempted, num_updated, num_failed, failure_messages = fetch_saml_metadata() if assert_metadata_updates: assert num_total == 1 # pylint: disable=no-member @@ -375,7 +381,6 @@ def test_configure_testshib_provider_with_cache_duration(self): kwargs.setdefault("icon_class", "fa-university") kwargs.setdefault("attr_email", "urn:oid:1.3.6.1.4.1.5923.1.1.1.6") # eduPersonPrincipalName self.configure_saml_provider(**kwargs) - assert httpretty.is_enabled() num_total, num_skipped, num_attempted, num_updated, num_failed, failure_messages = fetch_saml_metadata() assert num_total == 1 assert num_skipped == 0 @@ -420,62 +425,63 @@ class SuccessFactorsIntegrationTest(SamlIntegrationTestUtilities, IntegrationTes def setUp(self): """ - Mock out HTTP calls to various endpoints using httpretty. + Mock out HTTP calls to various endpoints using responses. """ super().setUp() # Mock the call to the SAP SuccessFactors assertion endpoint SAPSF_ASSERTION_URL = "http://successfactors.com/oauth/idp" - def assertion_callback(_request, _uri, headers): + def assertion_callback(_request): """ Return a fake assertion after checking that the input is what we expect. """ - assert b"private_key=fake_private_key_here" in _request.body - assert b"user_id=myself" in _request.body - assert b"token_url=http%3A%2F%2Fsuccessfactors.com%2Foauth%2Ftoken" in _request.body - assert b"client_id=TatVotSEiCMteSNWtSOnLanCtBGwNhGB" in _request.body - return (200, headers, "fake_saml_assertion") + assert b"private_key=fake_private_key_here" in _body_bytes(_request) + assert b"user_id=myself" in _body_bytes(_request) + assert b"token_url=http%3A%2F%2Fsuccessfactors.com%2Foauth%2Ftoken" in _body_bytes(_request) + assert b"client_id=TatVotSEiCMteSNWtSOnLanCtBGwNhGB" in _body_bytes(_request) + return (200, {}, "fake_saml_assertion") - httpretty.register_uri(httpretty.POST, SAPSF_ASSERTION_URL, content_type="text/plain", body=assertion_callback) + responses.add_callback(responses.POST, SAPSF_ASSERTION_URL, callback=assertion_callback, content_type="text/plain") SAPSF_BAD_ASSERTION_URL = "http://successfactors.com/oauth-fake/idp" - def bad_callback(_request, _uri, headers): + def bad_callback(_request): """ Return a 404 error when someone tries to call the URL. """ - return (404, headers, "NOT AN ASSERTION") + return (404, {}, "NOT AN ASSERTION") - httpretty.register_uri(httpretty.POST, SAPSF_BAD_ASSERTION_URL, content_type="text/plain", body=bad_callback) + responses.add_callback(responses.POST, SAPSF_BAD_ASSERTION_URL, callback=bad_callback, content_type="text/plain") # Mock the call to the SAP SuccessFactors token endpoint SAPSF_TOKEN_URL = "http://successfactors.com/oauth/token" - def token_callback(_request, _uri, headers): + def token_callback(_request): """ Return a fake assertion after checking that the input is what we expect. """ - assert b"assertion=fake_saml_assertion" in _request.body - assert b"company_id=NCC1701D" in _request.body - assert b"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Asaml2-bearer" in _request.body - assert b"client_id=TatVotSEiCMteSNWtSOnLanCtBGwNhGB" in _request.body - return (200, headers, '{"access_token": "faketoken"}') + assert b"assertion=fake_saml_assertion" in _body_bytes(_request) + assert b"company_id=NCC1701D" in _body_bytes(_request) + assert b"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Asaml2-bearer" in _body_bytes(_request) + assert b"client_id=TatVotSEiCMteSNWtSOnLanCtBGwNhGB" in _body_bytes(_request) + return (200, {}, '{"access_token": "faketoken"}') - httpretty.register_uri(httpretty.POST, SAPSF_TOKEN_URL, content_type="application/json", body=token_callback) + responses.add_callback(responses.POST, SAPSF_TOKEN_URL, callback=token_callback, content_type="application/json") # Mock the call to the SAP SuccessFactors OData user endpoint ODATA_USER_URL = ( "http://api.successfactors.com/odata/v2/User(userId='myself')" "?$select=firstName,lastName,defaultFullName,email" ) + self.odata_user_url = ODATA_USER_URL - def user_callback(request, _uri, headers): + def user_callback(request): auth_header = request.headers.get("Authorization") assert auth_header == "Bearer faketoken" return ( 200, - headers, + {}, json.dumps( { "d": { @@ -490,28 +496,32 @@ def user_callback(request, _uri, headers): ), ) - httpretty.register_uri(httpretty.GET, ODATA_USER_URL, content_type="application/json", body=user_callback) + responses.add_callback(responses.GET, ODATA_USER_URL, callback=user_callback, content_type="application/json") def _mock_odata_api_for_error(self, odata_api_root_url, username): """ Mock an error response when calling the OData API for user details. """ - def callback(request, uri, headers): # pylint: disable=unused-argument + def callback(request): """ Return a 500 error when someone tries to call the URL. """ - headers["CorrelationId"] = "aefd38b7-c92c-445a-8c7a-487a3f0c7a9d" - headers["RequestNo"] = "[787177]" # This is the format SAPSF returns for the transaction request number + headers = { + "CorrelationId": "aefd38b7-c92c-445a-8c7a-487a3f0c7a9d", + "RequestNo": "[787177]", # This is the format SAPSF returns for the transaction request number + } return 500, headers, "Failure!" + # Replace the default OData user mock from setUp; responses serves registrations in order. + responses.remove(responses.GET, self.odata_user_url) fields = ",".join(SapSuccessFactorsIdentityProvider.default_field_mapping.copy()) url = "{root_url}User(userId='{user_id}')?$select={fields}".format( # noqa: UP032 root_url=odata_api_root_url, user_id=username, fields=fields, ) - httpretty.register_uri(httpretty.GET, url, body=callback, content_type="application/json") + responses.add_callback(responses.GET, url, callback=callback, content_type="application/json") return url def test_register_insufficient_sapsf_metadata(self): @@ -571,12 +581,12 @@ def test_register_sapsf_with_value_default(self): "?$select=firstName,country,lastName,defaultFullName,email" ) - def user_callback(request, _uri, headers): + def user_callback(request): auth_header = request.headers.get("Authorization") assert auth_header == "Bearer faketoken" return ( 200, - headers, + {}, json.dumps( { "d": { @@ -590,7 +600,9 @@ def user_callback(request, _uri, headers): ), ) - httpretty.register_uri(httpretty.GET, ODATA_USER_URL, content_type="application/json", body=user_callback) + # Replace the default OData user mock from setUp; responses serves registrations in order. + responses.remove(responses.GET, self.odata_user_url) + responses.add_callback(responses.GET, ODATA_USER_URL, callback=user_callback, content_type="application/json") provider_settings = { "sapsf_oauth_root_url": "http://successfactors.com/oauth/", diff --git a/common/djangoapps/third_party_auth/tests/utils.py b/common/djangoapps/third_party_auth/tests/utils.py index f47761386c30..61dce8fa7a9b 100644 --- a/common/djangoapps/third_party_auth/tests/utils.py +++ b/common/djangoapps/third_party_auth/tests/utils.py @@ -4,7 +4,7 @@ import json from base64 import b64encode -import httpretty +import responses from oauth2_provider.models import Application from onelogin.saml2.utils import OneLogin_Saml2_Utils from social_core.backends.facebook import API_VERSION as FACEBOOK_API_VERSION @@ -16,7 +16,6 @@ from .testutil import ThirdPartyAuthTestMixin -@httpretty.activate class ThirdPartyOAuthTestMixin(ThirdPartyAuthTestMixin): """ Mixin with tests for third party oauth views. A TestCase that includes @@ -34,6 +33,9 @@ class ThirdPartyOAuthTestMixin(ThirdPartyAuthTestMixin): def setUp(self): # pylint: disable=arguments-differ super().setUp() + responses.start() + self.addCleanup(responses.stop) + self.addCleanup(responses.reset) if self.CREATE_USER: self.user = UserFactory.create(password='secret') UserSocialAuth.objects.create(user=self.user, provider=self.BACKEND, uid=self.social_uid) @@ -81,8 +83,8 @@ def _setup_provider_response_with_body(self, status, body): """ Register a mock response for the third party user information endpoint with given status and body. """ - httpretty.register_uri( - httpretty.GET, + responses.add( + responses.GET, self.USER_URL, body=body, status=status, diff --git a/openedx/core/djangoapps/auth_exchange/tests/test_forms.py b/openedx/core/djangoapps/auth_exchange/tests/test_forms.py index 5682b6f8bb57..9062e0cd9300 100644 --- a/openedx/core/djangoapps/auth_exchange/tests/test_forms.py +++ b/openedx/core/djangoapps/auth_exchange/tests/test_forms.py @@ -6,7 +6,6 @@ import unittest -import httpretty import social_django.utils as social_utils from django.contrib.sessions.middleware import SessionMiddleware from django.test import TestCase @@ -54,7 +53,6 @@ def _assert_success(self, data, expected_scopes, expected_logged_in_user=None): # This is necessary because cms does not implement third party auth @unittest.skipUnless(TPA_FEATURE_ENABLED, TPA_FEATURES_KEY + " not enabled") -@httpretty.activate class DOTAccessTokenExchangeFormTestFacebook( DOTAdapterMixin, AccessTokenExchangeFormTest, @@ -70,7 +68,6 @@ class DOTAccessTokenExchangeFormTestFacebook( # This is necessary because cms does not implement third party auth @unittest.skipUnless(TPA_FEATURE_ENABLED, TPA_FEATURES_KEY + " not enabled") -@httpretty.activate class DOTAccessTokenExchangeFormTestGoogle( DOTAdapterMixin, AccessTokenExchangeFormTest, diff --git a/openedx/core/djangoapps/auth_exchange/tests/test_views.py b/openedx/core/djangoapps/auth_exchange/tests/test_views.py index 2c34b17681c8..067f29077fc1 100644 --- a/openedx/core/djangoapps/auth_exchange/tests/test_views.py +++ b/openedx/core/djangoapps/auth_exchange/tests/test_views.py @@ -9,7 +9,6 @@ from datetime import datetime, timedelta import ddt -import httpretty from django.test import TestCase from django.test.utils import override_settings from django.urls import reverse @@ -115,7 +114,6 @@ def test_disabled_user(self): @unittest.skipUnless(TPA_FEATURE_ENABLED, TPA_FEATURES_KEY + " not enabled") -@httpretty.activate class DOTAccessTokenExchangeViewTestFacebook( DOTAdapterMixin, AccessTokenExchangeViewTest, @@ -130,7 +128,6 @@ class DOTAccessTokenExchangeViewTestFacebook( # This is necessary because cms does not implement third party auth @unittest.skipUnless(TPA_FEATURE_ENABLED, TPA_FEATURES_KEY + " not enabled") -@httpretty.activate class DOTAccessTokenExchangeViewTestGoogle( DOTAdapterMixin, AccessTokenExchangeViewTest, diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py index 457837623e6e..4e1287c5caf8 100644 --- a/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py +++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py @@ -7,7 +7,6 @@ from unittest.mock import call, patch import ddt -import httpretty from django.conf import settings from django.test import RequestFactory, TestCase from django.urls import reverse @@ -405,7 +404,6 @@ def test_disable_jwt_waffle_switch_disabled_mobile(self, mock_is_request_from_mo @ddt.ddt -@httpretty.activate class TestAccessTokenExchangeView(ThirdPartyOAuthTestMixinGoogle, ThirdPartyOAuthTestMixin, _DispatchingViewTestCase, mixins.AccessTokenMixin): """ diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_register.py b/openedx/core/djangoapps/user_authn/views/tests/test_register.py index 009509a6a064..a3606d661c8e 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_register.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py @@ -6,7 +6,6 @@ from zoneinfo import ZoneInfo import ddt -import httpretty from django.conf import settings from django.contrib.auth.models import User # pylint: disable=imported-auth-user from django.core import mail @@ -2392,7 +2391,6 @@ def test_registration_allowed_when_embargo_disabled(self): self.assertEqual(response.status_code, 200) # noqa: PT009 -@httpretty.activate @ddt.ddt class ThirdPartyRegistrationTestMixin( OpenEdxEventsTestMixin, ThirdPartyOAuthTestMixin, CacheIsolationTestCase From 56987a3038c7cd5ca83223243b60af7db5edc314 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 21 Oct 2026 10:07:16 -0400 Subject: [PATCH 7/9] build: drop httpretty now that no test uses it httpretty's last release was 1.1.4 in 2021. It patches the socket module globally, which broke MongoDB connections under pymongo 4.12+ in #39246, and it does not support Python 3.14. Every test that used it has been migrated to `responses`, so remove it from the testing group and the lock. Co-Authored-By: Claude Fable 5.1 --- pyproject.toml | 1 - requirements/edx/development.txt | 1 - uv.lock | 14 -------------- 3 files changed, 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8cd5840942bd..8c343b2804fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -180,7 +180,6 @@ testing = [ "edx-lint", # pylint extensions for Open edX repositories "factory-boy", # Library for creating test fixtures, used in many tests "freezegun", # Pinning the freezegun version because 0.3.13 is causing failures which have also been reported on the git repo by public.; Allows tests to mock the output of assorted datetime module functions - "httpretty", # Library for mocking HTTP requests, used in many tests "import-linter", # Tool for making assertions about which modules can import which others "mock", # Deprecated alias to standard library `unittest.mock` "ruff", # Fast Python linter and formatter diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 4af590709a3e..7aedf41ce7fd 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -729,7 +729,6 @@ html5lib==1.1 # xblocks-contrib httpcore==1.0.9 # via httpx -httpretty==1.1.4 httpx==0.28.1 # via # firebase-admin diff --git a/uv.lock b/uv.lock index d9402ce141f6..baa9c749ea79 100644 --- a/uv.lock +++ b/uv.lock @@ -3283,12 +3283,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] -[[package]] -name = "httpretty" -version = "1.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/19/850b7ed736319d0c4088581f4fc34f707ef14461947284026664641e16d4/httpretty-1.1.4.tar.gz", hash = "sha256:20de0e5dd5a18292d36d928cc3d6e52f8b2ac73daec40d41eb62dee154933b68", size = 442389, upload-time = "2021-08-16T19:35:31.4Z" } - [[package]] name = "httpx" version = "0.28.1" @@ -4886,7 +4880,6 @@ development = [ { name = "factory-boy" }, { name = "freezegun" }, { name = "gitpython" }, - { name = "httpretty" }, { name = "import-linter" }, { name = "libsass" }, { name = "mailsnake" }, @@ -4949,7 +4942,6 @@ django42 = [ { name = "enterprise-integrated-channels" }, { name = "factory-boy" }, { name = "freezegun" }, - { name = "httpretty" }, { name = "import-linter" }, { name = "mailsnake" }, { name = "mock" }, @@ -4998,7 +4990,6 @@ django52 = [ { name = "enterprise-integrated-channels" }, { name = "factory-boy" }, { name = "freezegun" }, - { name = "httpretty" }, { name = "import-linter" }, { name = "mailsnake" }, { name = "mock" }, @@ -5072,7 +5063,6 @@ testing = [ { name = "enterprise-integrated-channels" }, { name = "factory-boy" }, { name = "freezegun" }, - { name = "httpretty" }, { name = "import-linter" }, { name = "mailsnake" }, { name = "mock" }, @@ -5296,7 +5286,6 @@ development = [ { name = "factory-boy" }, { name = "freezegun" }, { name = "gitpython" }, - { name = "httpretty" }, { name = "import-linter" }, { name = "libsass" }, { name = "mailsnake" }, @@ -5359,7 +5348,6 @@ django42 = [ { name = "enterprise-integrated-channels" }, { name = "factory-boy" }, { name = "freezegun" }, - { name = "httpretty" }, { name = "import-linter" }, { name = "mailsnake" }, { name = "mock" }, @@ -5408,7 +5396,6 @@ django52 = [ { name = "enterprise-integrated-channels" }, { name = "factory-boy" }, { name = "freezegun" }, - { name = "httpretty" }, { name = "import-linter" }, { name = "mailsnake" }, { name = "mock" }, @@ -5482,7 +5469,6 @@ testing = [ { name = "enterprise-integrated-channels" }, { name = "factory-boy" }, { name = "freezegun" }, - { name = "httpretty" }, { name = "import-linter" }, { name = "mailsnake" }, { name = "mock" }, From f7f0c8e293c938f0d3fc53f1e9574ed6004bcb9c Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 21 Oct 2026 10:29:56 -0400 Subject: [PATCH 8/9] fixup! test: migrate the commerce tests from httpretty to responses --- common/djangoapps/course_modes/tests/test_views.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/common/djangoapps/course_modes/tests/test_views.py b/common/djangoapps/course_modes/tests/test_views.py index 52d9f03e383b..e707723ad23b 100644 --- a/common/djangoapps/course_modes/tests/test_views.py +++ b/common/djangoapps/course_modes/tests/test_views.py @@ -10,7 +10,6 @@ import ddt import freezegun -import responses from django.conf import settings from django.test import override_settings from django.urls import reverse @@ -22,7 +21,6 @@ from common.djangoapps.util.testing import UrlResetMixin from common.djangoapps.util.tests.mixins.discovery import CourseCatalogServiceMockMixin from lms.djangoapps.commerce.tests import test_utils as ecomm_test_utils -from lms.djangoapps.commerce.tests.mocks import mock_payment_processors from lms.djangoapps.verify_student.services import IDVerificationService from openedx.core.djangoapps.catalog.tests.mixins import CatalogIntegrationMixin from openedx.core.djangoapps.embargo.test_utils import restrict_course @@ -63,7 +61,6 @@ def setUp(self): self.client.login(username=self.user.username, password="edx") @skip_unless_lms - @responses.activate @ddt.data( # is_active?, enrollment_mode, redirect?, has_started (True, 'verified', True, False), @@ -148,7 +145,6 @@ def test_no_id_redirect_otto(self): self.assertRedirects(response, '/test_basket/add/?sku=TEST', fetch_redirect_response=False) ecomm_test_utils.update_commerce_config(enabled=False) - @responses.activate @ddt.data( '', '1,,2', @@ -301,8 +297,7 @@ def test_choose_mode_redirect(self, course_mode, expected_redirect): else: self.fail("Must provide a valid redirect URL name") - with mock_payment_processors(expect_called=None): - self.assertRedirects(response, redirect_url, fetch_redirect_response=False,) + self.assertRedirects(response, redirect_url, fetch_redirect_response=False) def test_choose_mode_audit_enroll_on_post(self): audit_mode = 'audit' @@ -456,7 +451,6 @@ def test_multiple_mode_creation(self): @skip_unless_lms @with_comprehensive_theme("edx.org") - @responses.activate def test_hide_nav(self): # Create the course modes for mode in ["honor", "verified"]: @@ -688,7 +682,6 @@ def test_embargo_restrict(self): response = self.client.get(self.url) self.assertRedirects(response, redirect_url) - @responses.activate def test_embargo_allow(self): response = self.client.get(self.url) assert response.status_code == 200 From ca032f6aa3ab49cca3efbd307259de02d4ab33c8 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Thu, 22 Oct 2026 08:42:59 -0400 Subject: [PATCH 9/9] fixup! test: migrate the commerce tests from httpretty to responses --- lms/djangoapps/commerce/tests/mocks.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lms/djangoapps/commerce/tests/mocks.py b/lms/djangoapps/commerce/tests/mocks.py index 926b3b2710e7..c612a5192223 100644 --- a/lms/djangoapps/commerce/tests/mocks.py +++ b/lms/djangoapps/commerce/tests/mocks.py @@ -18,6 +18,15 @@ class mock_ecommerce_api_endpoint: checks on exit whether the endpoint was called, so the test (or its setUp) must already have ``responses`` active, e.g. via ``@responses.activate`` or ``responses.start()``. + + These used to activate the HTTP mocking library (httpretty) themselves on + entry and deactivate it on exit. That meant a test using one of these + did not need to activate anything, but it also meant that nesting two of + them, or using one inside a test that was already mocking HTTP, tore + down the outer mocks on exit; the old ``reset_on_exit`` flag existed only + to work around that. Keeping activation with the test avoids the + problem, at the cost of every test that uses one of these needing + ``@responses.activate`` (or ``responses.start()`` in setUp). """ # override this in subclasses.